diff --git a/python/MaterialXTest/tests_to_html.py b/python/MaterialXTest/tests_to_html.py index 96ccbdde30..bca66d8b67 100644 --- a/python/MaterialXTest/tests_to_html.py +++ b/python/MaterialXTest/tests_to_html.py @@ -4,37 +4,151 @@ import os import datetime import argparse +import json +REDUCE_ENABLED = False +DIFF_ENABLED = False try: - # Install pillow via pip to enable image differencing and statistics. - from PIL import Image, ImageChops, ImageStat + # Load modules for image diff, resizing and base64 encoding. + import base64 + import cv2 + import numpy as np + REDUCE_ENABLED = True DIFF_ENABLED = True except Exception: - DIFF_ENABLED = False - -def computeDiff(image1Path, image2Path, imageDiffPath): - try: - if os.path.exists(imageDiffPath): - os.remove(imageDiffPath) - - if not os.path.exists(image1Path): - print ("Image diff input missing: " + image1Path) - return - - if not os.path.exists(image2Path): - print ("Image diff input missing: " + image2Path) - return - - image1 = Image.open(image1Path).convert('RGB') - image2 = Image.open(image2Path).convert('RGB') - diff = ImageChops.difference(image1, image2) - diff.save(imageDiffPath) - diffStat = ImageStat.Stat(diff) - return sum(diffStat.rms) / (3.0 * 255.0) - except Exception: - if os.path.exists(imageDiffPath): - os.remove(imageDiffPath) - print ("Failed to create image diff between: " + image1Path + ", " + image2Path) + print("- OpenCV and NumPy need to be installed for image diff and base64 for reduced image features to be supported.") + +class OpenCVImageUtils: + def __init__(self): + self.compute_reduced : bool = False + self.reduced_width : int = 512 + self.difference_method = 'RMS' + self.MAX_DIFFERENCE : float = 1.0 + self.hash_function = None + self._reset() + + def _reset(self): + self.difference_image : str = "" + self.difference_value : float = self.MAX_DIFFERENCE + self.difference_image : str = "" + + def set_reduced(self, compute_reduced: bool, width: int): + self.compute_reduced = compute_reduced + self.reduced_width = width + if self.reduced_width <= 16: + self.reduced_width = 16 + + def set_difference_method(self, method: str): + self.difference_method = method + if method == 'COLORMOMENT': + self.hash_function = cv2.img_hash.ColorMomentHash_create() + print('- Setting hash function to: ' + method) + self.MAX_DIFFERENCE = 100.0 + elif method == 'RMS': + self.hash_function = None + self.MAX_DIFFERENCE = 1.0 + else: + self.hash_function = None + self.MAX_DIFFERENCE = 1.0 + + def get_difference_image(self) -> str: + return self.difference_image + + def get_difference_value(self) -> float: + return self.difference_value + + def compute_difference(self, image1Path, image2Path, imagediff_path_): + + self._reset() + + if not image1Path or not image2Path: + return False + + try: + # Remove existing diff image if present + if not self.compute_reduced and os.path.exists(imagediff_path_): + os.remove(imagediff_path_) + + # Check input existence + if not os.path.exists(image1Path): + print("Image diff input missing: " + image1Path) + return False + if not os.path.exists(image2Path): + print("Image diff input missing: " + image2Path) + return False + + # Read images + img1 = cv2.imread(image1Path, cv2.IMREAD_COLOR) + img2 = cv2.imread(image2Path, cv2.IMREAD_COLOR) + if img1 is None or img2 is None: + print("Failed to read images.") + return False + + # Resize to same dimensions + if img1.shape != img2.shape: + img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0])) + + # Compute absolute difference + diff = cv2.absdiff(img1, img2) + + # Save diff image or compute reduced version + if self.compute_reduced: + self.difference_image = self.get_reduced_image_data_img(diff, self.reduced_width) # Resize diff image for smaller size + else: + cv2.imwrite(imagediff_path_, diff) + self.difference_image = imagediff_path_ + + # Compute RMS per channel + if self.difference_method == 'RMS': + diff_float = diff.astype(np.float32) + rms = np.sqrt(np.mean(np.square(diff_float), axis=(0, 1))) # per channel + self.difference_value = float(np.mean(rms) / 255.0) # normalized average across RGB channels + #print(f"RMS difference between images: {self.difference_value}") + return True + elif self.difference_method == 'COLORMOMENT' and self.hash_function is not None: + hash1 = self.hash_function.compute(img1) + hash2 = self.hash_function.compute(img2) + self.difference_value = self.hash_function.compare(hash1, hash2) + #print(f"Color Moments difference between images: {self.difference_value}") + return True + else: + #print(f"Unsupported difference method: {self.difference_method}") + return False + + except Exception as e: + if not self.compute_difference and os.path.exists(imagediff_path_): + os.remove(imagediff_path_) + print(f"Failed to create image diff between: {image1Path}, {image2Path}") + print(str(e)) + + return False + + def get_reduced_image_data(self, image_path, width): + if not image_path or not os.path.isfile(image_path): + return None + try: + img = cv2.imread(image_path) + except Exception: + return None + return self.get_reduced_image_data_img(img, width) + + def get_reduced_image_data_img(self, img, width): + if img is None: + return None + try: + h, w0 = img.shape[:2] + w = width if width and width > 0 else 512 + aspect = h / w0 + new_size = (w, int(w * aspect)) + resized = cv2.resize(img, new_size, interpolation=cv2.INTER_AREA) + # Encode as JPEG for smaller memory size + ret, buf = cv2.imencode('.jpg', resized, [int(cv2.IMWRITE_JPEG_QUALITY), 80]) + if not ret: + return None + b64 = base64.b64encode(buf.tobytes()).decode('utf-8') + return f"data:image/jpeg;base64,{b64}" + except Exception: + return None def main(args=None): @@ -44,32 +158,18 @@ def main(args=None): parser.add_argument('-i3', '--inputdir3', dest='inputdir3', action='store', help='Third input directory', default="") parser.add_argument('-o', '--outputfile', dest='outputfile', action='store', help='Output file name', default="tests.html") parser.add_argument('-d', '--diff', dest='CREATE_DIFF', action='store_true', help='Perform image diff', default=False) + parser.add_argument('-dm', '--diffmethod', dest='diffmethod', choices=['RMS','COLORMOMENT'], help='Image difference method to use. Currently only RMS is supported.', default='RMS') + parser.add_argument('-e', '--error', dest='error', action='store', help='Filter out results with RMS less than this. Negative means all results are kept.', default=-1, type=float) parser.add_argument('-t', '--timestamp', dest='ENABLE_TIMESTAMPS', action='store_true', help='Write image timestamps', default=False) - parser.add_argument('-w', '--imagewidth', type=int, dest='imagewidth', action='store', help='Set image display width', default=256) - parser.add_argument('-ht', '--imageheight', type=int, dest='imageheight', action='store', help='Set image display height', default=256) - parser.add_argument('-cp', '--cellpadding', type=int, dest='cellpadding', action='store', help='Set table cell padding', default=0) - parser.add_argument('-tb', '--tableborder', type=int, dest='tableborder', action='store', help='Table border width. 0 means no border', default=3) + parser.add_argument('-w', '--imagewidth', type=int, dest='imagewidth', action='store', help='Set image display width. Default is 512. <= 0 means to resize dynamically', default=512) parser.add_argument('-l1', '--lang1', dest='lang1', action='store', help='First target language for comparison. Default is glsl', default="glsl") parser.add_argument('-l2', '--lang2', dest='lang2', action='store', help='Second target language for comparison. Default is osl', default="osl") parser.add_argument('-l3', '--lang3', dest='lang3', action='store', help='Third target language for comparison. Default is empty', default="") - parser.add_argument('-e', '--error', dest='error', action='store', help='Filter out results with RMS less than this. Negative means all results are kept.', default=-1, type=float) + parser.add_argument('-f', '--format', dest='format', choices=['html', 'json', 'markdown'], help='Output format: html, json, or markdown', default='html') + parser.add_argument('-r', '--reduced', dest='reduced', action='store_true', help='Produce reduced-size images for display', default=False) args = parser.parse_args(args) - - fh = open(args.outputfile,"w+") - fh.write("\n") - fh.write("") - fh.write("\n") + # Build report data (groups -> rows -> columns), then render to desired format. if args.inputdir1 == ".": args.inputdir1 = os.getcwd() @@ -86,13 +186,24 @@ def main(args=None): useThirdLang = args.lang3 - if useThirdLang: - fh.write("

" + args.lang1 + " (in: " + args.inputdir1 + ") vs "+ args.lang2 + " (in: " + args.inputdir2 + ") vs "+ args.lang3 + " (in: " + args.inputdir3 + ")

\n") - else: - fh.write("

" + args.lang1 + " (in: " + args.inputdir1 + ") vs "+ args.lang2 + " (in: " + args.inputdir2 + ")

\n") + image_utils = None + if args.CREATE_DIFF: + if not DIFF_ENABLED: + print("--diff argument ignored. Diff utility not installed.") + else: + image_utils = OpenCVImageUtils() + if args.reduced: + if not REDUCE_ENABLED: + print("--reduced argument ignored. Image reduction utility not installed.") + else: + if image_utils is None: + image_utils = OpenCVImageUtils() - if not DIFF_ENABLED and args.CREATE_DIFF: - print("--diff argument ignored. Diff utility not installed.") + difference_method = args.diffmethod + if image_utils: + print(f"Using difference method: '{difference_method}' and reduced images: '{args.reduced}' with width: '{args.imagewidth}'") + image_utils.set_reduced(args.reduced, args.imagewidth) + image_utils.set_difference_method(difference_method) # Remove potential trailing path separators if args.inputdir1[-1:] == '/' or args.inputdir1[-1:] == '\\': @@ -140,91 +251,286 @@ def main(args=None): langFiles3.append(file3) langPaths3.append(path3) - if langFiles1: - curPath = "" - for file1, file2, file3, path1, path2, path3 in zip(langFiles1, langFiles2, langFiles3, langPaths1, langPaths2, langPaths3): - - fullPath1 = os.path.join(path1, file1) if file1 else None - fullPath2 = os.path.join(path2, file2) if file2 else None - fullPath3 = os.path.join(path3, file3) if file3 else None - diffPath1 = diffPath2 = diffPath3 = None - diffRms1 = diffRms2 = diffRms3 = None - - if file1 and file2 and DIFF_ENABLED and args.CREATE_DIFF: - diffPath1 = fullPath1[0:-8] + "_" + args.lang1 + "-1_vs_" + args.lang2 + "-2_diff.png" - diffRms1 = computeDiff(fullPath1, fullPath2, diffPath1) - - if useThirdLang and file1 and file3 and DIFF_ENABLED and args.CREATE_DIFF: - diffPath2 = fullPath1[0:-8] + "_" + args.lang1 + "-1_vs_" + args.lang3 + "-3_diff.png" - diffRms2 = computeDiff(fullPath1, fullPath3, diffPath2) - diffPath3 = fullPath1[0:-8] + "_" + args.lang2 + "-2_vs_" + args.lang3 + "-3_diff.png" - diffRms3 = computeDiff(fullPath2, fullPath3, diffPath3) - - if args.error >= 0: - ok1 = (not diffPath1) or (not diffRms1) or (diffRms1 and diffRms1 <= args.error) - ok2 = (not diffPath2) or (not diffRms2) or (diffRms2 and diffRms2 <= args.error) - ok3 = (not diffPath3) or (not diffRms3) or (diffRms3 and diffRms3 <= args.error) - if ok1 and ok2 and ok3: - continue - - if curPath != path1: - if curPath != "": - fh.write("\n") - fh.write("

" + os.path.normpath(path1) + ":

\n") - fh.write("\n") - curPath = path1 - - def prependFileUri(filepath: str) -> str: - if os.path.isabs(filepath): - return 'file:///' + filepath - else: + # Helper to format image paths based on output format. + # - html: use file:/// scheme for absolute paths + # - markdown/json: use paths relative to the output file directory when possible + def prepend_file_uri(filepath: str, for_format: str) -> str: + if filepath is None: + return None + if os.path.isabs(filepath): + if for_format == 'html': + return 'file:///' + filepath + else: + out_dir = os.path.dirname(args.outputfile) if args.outputfile else os.getcwd() + try: + rel = os.path.relpath(filepath, start=out_dir) + return rel + except Exception: return filepath + else: + return filepath + + # groups: list of { group: str, rows: [ { columns: [ { image: str, reduced_image: str, text: str } ] } ] } + groups = [] + + def build_groups(): - fh.write("\n") - if fullPath1: - fh.write("\n") - if fullPath2: - fh.write("\n") - if fullPath3: - fh.write("\n") - if diffPath1: - fh.write("\n") - if diffPath2: - fh.write("\n") - if diffPath3: - fh.write("\n") - fh.write("\n") - - fh.write("\n") - if fullPath1: - fh.write("\n") - if fullPath2: - fh.write("\n") - if fullPath3: - fh.write("\n") - if diffPath1: - rms = " (RMS " + "%.5f" % diffRms1 + ")" if diffRms1 else "" - fh.write("\n") - if diffPath2: - rms = " (RMS " + "%.5f" % diffRms2 + ")" if diffRms2 else "" - fh.write("\n") - if diffPath3: - rms = " (RMS " + "%.5f" % diffRms3 + ")" if diffRms3 else "" - fh.write("\n") - fh.write("\n") - - fh.write("
" + file1) - if args.ENABLE_TIMESTAMPS and os.path.isfile(fullPath1): - fh.write("
(" + str(datetime.datetime.fromtimestamp(os.path.getmtime(fullPath1))) + ")") - fh.write("
" + file2) - if args.ENABLE_TIMESTAMPS and os.path.isfile(fullPath2): - fh.write("
(" + str(datetime.datetime.fromtimestamp(os.path.getmtime(fullPath2))) + ")") - fh.write("
" + file3) - if args.ENABLE_TIMESTAMPS and os.path.isfile(fullPath3): - fh.write("
(" + str(datetime.datetime.fromtimestamp(os.path.getmtime(fullPath3))) + ")") - fh.write("
" + args.lang1.upper() + " vs. " + args.lang2.upper() + rms + "" + args.lang1.upper() + " vs. " + args.lang3.upper() + rms + "" + args.lang2.upper() + " vs. " + args.lang3.upper() + rms + "
\n") - fh.write("\n") - fh.write("\n") + if langFiles1: + curPath = "" + current_group = None + for file1, file2, file3, path1, path2, path3 in zip(langFiles1, langFiles2, langFiles3, langPaths1, langPaths2, langPaths3): + + full_path_1 = os.path.join(path1, file1) if file1 else None + full_path_2 = os.path.join(path2, file2) if file2 else None + full_path_3 = os.path.join(path3, file3) if file3 else None + diff_path_1 = diff_path_2 = diff_path_3 = None + diff_value_1 = diff_value_2 = diff_value_3 = None + + if file1 and file2 and image_utils: + if full_path_1 and full_path_2: + base_prefix = full_path_1[:-8] if len(full_path_1) >= 8 else full_path_1 + diff_path_1 = base_prefix + "_" + args.lang1 + "-1_vs_" + args.lang2 + "-2_diff.png" + if (image_utils.compute_difference(full_path_1, full_path_2, diff_path_1)): + diff_value_1 = image_utils.get_difference_value() + diff_path_1 = image_utils.get_difference_image() + if useThirdLang and file1 and file3 and image_utils: + if full_path_1 and full_path_3: + base_prefix = full_path_1[:-8] if len(full_path_1) >= 8 else full_path_1 + diff_path_2 = base_prefix + "_" + args.lang1 + "-1_vs_" + args.lang3 + "-3_diff.png" + if (image_utils.compute_difference(full_path_1, full_path_3, diff_path_2)): + diff_value_2 = image_utils.get_difference_value() + diff_path_2 = image_utils.get_difference_image() + diff_path_3 = base_prefix + "_" + args.lang2 + "-2_vs_" + args.lang3 + "-3_diff.png" + if (image_utils.compute_difference(full_path_2, full_path_3, diff_path_3)): + diff_value_3 = image_utils.get_difference_value() + diff_path_3 = image_utils.get_difference_image() + + # Row filtering based on tolerance: + # - If error < 0: do not prune (always include rows) + # - If error > 0: prune rows where all computed difference values are below the threshold + if image_utils and args.error > 0: + diffs_present = [] + if diff_value_1 is not None: + diffs_present.append(diff_value_1) + if diff_value_2 is not None: + diffs_present.append(diff_value_2) + if diff_value_3 is not None: + diffs_present.append(diff_value_3) + # If we have at least one diff value and all are below the threshold, skip this row + if diffs_present and all(d < args.error for d in diffs_present): + continue + + # Detect group change and create group container (ensure not None) + if current_group is None or curPath != path1: + current_group = { + "group": os.path.normpath(path1), + "rows": [] + } + groups.append(current_group) + curPath = path1 + + # Build columns for this row in the order: images (1..3) then diffs (1..3) + columns = [] + + def make_column(image_path, text): + col = {"image": prepend_file_uri(image_path, args.format), "text": text} + if args.reduced and image_utils: + col["reduced_image"] = image_utils.get_reduced_image_data(image_path, args.imagewidth) + else: + col["reduced_image"] = None + return col + + if full_path_1: + text1 = file1 + if args.ENABLE_TIMESTAMPS and os.path.isfile(full_path_1): + text1 += "
(" + str(datetime.datetime.fromtimestamp(os.path.getmtime(full_path_1))) + ")" + columns.append(make_column(full_path_1, text1)) + + if full_path_2: + text2 = file2 + if args.ENABLE_TIMESTAMPS and os.path.isfile(full_path_2): + text2 += "
(" + str(datetime.datetime.fromtimestamp(os.path.getmtime(full_path_2))) + ")" + columns.append(make_column(full_path_2, text2)) + + if full_path_3: + text3 = file3 + if args.ENABLE_TIMESTAMPS and os.path.isfile(full_path_3): + text3 += "
(" + str(datetime.datetime.fromtimestamp(os.path.getmtime(full_path_3))) + ")" + columns.append(make_column(full_path_3, text3)) + + def make_diff_column(diff_path, label, diff_value): + col = {"image": prepend_file_uri(diff_path, args.format), "text": label} + if args.reduced and image_utils: + col["reduced_image"] = image_utils.get_reduced_image_data(diff_path, args.imagewidth) + else: + col["reduced_image"] = None + return col + + if diff_path_1: + label = (f"{args.lang1.upper()} vs. {args.lang2.upper()} ({difference_method}: " + "%.5f)" % diff_value_1 ) if diff_value_1 is not None else "" + columns.append(make_diff_column(diff_path_1, label, diff_value_1)) + if diff_path_2: + label = (f"{args.lang1.upper()} vs. {args.lang3.upper()} ({difference_method}: " + "%.5f)" % diff_value_2 ) if diff_value_2 is not None else "" + columns.append(make_diff_column(diff_path_2, label, diff_value_2)) + if diff_path_3: + label = (f"{args.lang2.upper()} vs. {args.lang3.upper()} ({difference_method}: " + "%.5f)" % diff_value_3 ) if diff_value_3 is not None else "" + columns.append(make_diff_column(diff_path_3, label, diff_value_3)) + + current_group["rows"].append({"columns": columns}) + + def output_json(groups): + output = { + "meta": { + "inputdir1": args.inputdir1, + "inputdir2": args.inputdir2, + "inputdir3": args.inputdir3, + "lang1": args.lang1, + "lang2": args.lang2, + "lang3": args.lang3, + "createDiff": bool(args.CREATE_DIFF and DIFF_ENABLED), + "diffMethod": args.diffmethod, + "diffMax": args.diffmax, + "timestamps": bool(args.ENABLE_TIMESTAMPS), + "imagewidth": args.imagewidth, + "tolerance": args.error, + }, + "groups": groups + } + outputfile = args.outputfile.replace('.html', '.json') if args.outputfile.endswith('.html') else args.outputfile + print('Writing JSON output to: ' + outputfile) + with open(outputfile, "w+") as fh: + json.dump(output, fh, indent=2) + + def output_markdown(groups): + md_parts = [] + + # Header + if useThirdLang: + md_parts.append("### " + args.lang1 + " (in: " + args.inputdir1 + ") vs " + args.lang2 + " (in: " + args.inputdir2 + ") vs " + args.lang3 + " (in: " + args.inputdir3 + ")\n\n") + else: + md_parts.append("### " + args.lang1 + " (in: " + args.inputdir1 + ") vs " + args.lang2 + " (in: " + args.inputdir2 + ")\n\n") + + # Render each group as a table + for group in groups: + md_parts.append("##### " + group["group"] + "\n\n") + + if not group["rows"]: + continue + + # Determine number of columns from first row + num_cols = len(group["rows"][0]["columns"]) if group["rows"] else 0 + + if num_cols == 0: + continue + + # Create markdown table header + md_parts.append("|") + for i in range(num_cols): + md_parts.append("|")#" Column " + str(i+1) + " |") + md_parts.append("\n") + + # Create separator row + md_parts.append("|") + for i in range(num_cols): + md_parts.append(" --- |") + md_parts.append("\n") + + # Render each row + for row in group["rows"]: + # Image row + md_parts.append("|") + for col in row["columns"]: + img_src = col.get("reduced_image") if args.reduced and col.get("reduced_image") else col.get("image") + if img_src: + md_parts.append(f" |") + else: + md_parts.append(" |") + md_parts.append("\n") + + # Text row + md_parts.append("|") + for col in row["columns"]: + text = col.get("text", "") + # Replace '_" with ' ' to allow wrapping in Markdown viewers + text = text.replace("_", " ") + md_parts.append(" " + text + " |") + md_parts.append("\n") + + md_parts.append("\n") + + outputfile = args.outputfile.replace('.html', '.md') if args.outputfile.endswith('.html') else args.outputfile + print('Writing Markdown output to: ' + outputfile) + with open(outputfile, "w+") as fh: + fh.write(''.join(md_parts)) + + def output_html(groups): + html_parts = [] + html_parts.append("\n") + html_parts.append("\n") + html_parts.append("\n") + html_parts.append(" \n") + html_parts.append(" \n") + html_parts.append(" Test Results\n") + html_parts.append(" \n") + html_parts.append(" \n") + html_parts.append("\n") + html_parts.append("\n") + html_parts.append("
\n") + + if useThirdLang: + html_parts.append("
" + args.lang1 + " (in: " + args.inputdir1 + ") vs "+ args.lang2 + " (in: " + args.inputdir2 + ") vs "+ args.lang3 + " (in: " + args.inputdir3 + ")
\n") + else: + html_parts.append("
" + args.lang1 + " (in: " + args.inputdir1 + ") vs "+ args.lang2 + " (in: " + args.inputdir2 + ")
\n") + + for group in groups: + html_parts.append("
\n") + html_parts.append("
" + group["group"] + ":
\n") + for row in group["rows"]: + html_parts.append("
\n") + for col in row["columns"]: + if args.imagewidth and args.imagewidth > 0: + html_parts.append("
\n") + else: + html_parts.append("
\n") + img_src = col.get("reduced_image") if args.reduced and col.get("reduced_image") else col.get("image") + if img_src: + html_parts.append(" " + col.get("text", "").replace("<br>", " ") + "") + html_parts.append("
" + col.get("text", "") + "
\n") + html_parts.append("
\n") + html_parts.append("
\n") + html_parts.append("
\n") + + html_parts.append("
\n") + html_parts.append(" \n") + html_parts.append("\n") + html_parts.append("\n") + + print('Writing HTML output to: ' + args.outputfile) + with open(args.outputfile, "w+") as fh: + fh.write(''.join(html_parts)) + + # Build groups data structure + build_groups() + + # Render output: JSON, Markdown, or HTML + if args.format == 'json': + output_json(groups) + + elif args.format == 'markdown': + output_markdown(groups) + else: + output_html(groups) if __name__ == "__main__": main(sys.argv[1:])