From f9b44cd169154a0d3d1bfdaa7d11bd0a046fc580 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:44:28 +0000 Subject: [PATCH 1/8] Initial plan From b140466d82151fde725392abb6a8d2d505238951 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:58:22 +0000 Subject: [PATCH 2/8] Add pixel shift image comparison Co-authored-by: forsyth2 <30700190+forsyth2@users.noreply.github.com> --- tests/images/test_image_checker.py | 35 +++++++++++++++++- tests/integration/image_checker.py | 58 ++++++++++++++++++++++-------- 2 files changed, 78 insertions(+), 15 deletions(-) diff --git a/tests/images/test_image_checker.py b/tests/images/test_image_checker.py index 44328687..7b515f31 100644 --- a/tests/images/test_image_checker.py +++ b/tests/images/test_image_checker.py @@ -2,14 +2,22 @@ from typing import List, Optional from mache import MachineInfo +from PIL import Image, ImageDraw from tests.integration.image_checker import _compare_actual_and_expected +def _write_image(path: str) -> None: + image = Image.new("RGB", (100, 100), "white") + draw = ImageDraw.Draw(image) + draw.rectangle((20, 20, 80, 80), fill="black") + image.save(path) + + # Run this test with: # cd zppy # pytest tests/images/test_image_checker.py -def test_compare(): +def test_compare() -> None: missing_images: List[str] = [] mismatched_images: List[str] = [] @@ -42,3 +50,28 @@ def test_compare(): ) assert missing_images == [] assert mismatched_images == ["CRU-TREFHT-ANN-land_60S90N"] + + +def test_compare_ignores_small_pixel_shift(tmp_path) -> None: + expected_path = tmp_path / "expected.png" + actual_path = tmp_path / "actual.png" + _write_image(str(expected_path)) + + expected_image = Image.open(expected_path) + actual_image = Image.new("RGB", expected_image.size, "white") + actual_image.paste(expected_image, (1, -2)) + actual_image.save(actual_path) + + missing_images: List[str] = [] + mismatched_images: List[str] = [] + _compare_actual_and_expected( + missing_images, + mismatched_images, + "shifted.png", + str(actual_path), + str(expected_path), + str(tmp_path / "diffs"), + ) + + assert missing_images == [] + assert mismatched_images == [] diff --git a/tests/integration/image_checker.py b/tests/integration/image_checker.py index 25ad7fb3..a0758a5a 100644 --- a/tests/integration/image_checker.py +++ b/tests/integration/image_checker.py @@ -10,6 +10,10 @@ from PIL import Image, ImageChops, ImageDraw +MAXIMUM_PIXEL_SHIFT = 2 +MAXIMUM_MISMATCH_FRACTION = 0.0002 + + # Classes ##################################################################### class Parameters(object): def __init__(self, args: Dict[str, str]): @@ -271,21 +275,11 @@ def _compare_actual_and_expected( # If `diff.getbbox()` is None, then the images are in theory equal assert diff.getbbox() is None else: - # Sometimes, a few pixels will differ, but the two images appear identical. - # https://codereview.stackexchange.com/questions/55902/fastest-way-to-count-non-zero-pixels-using-python-and-pillow - nonzero_pixels = ( - diff.crop(bbox) - .point(lambda x: 255 if x else 0) - .convert("L") - .point(bool) - .getdata() - ) - num_nonzero_pixels = sum(nonzero_pixels) - width, height = expected_png.size - num_pixels = width * height - fraction = num_nonzero_pixels / num_pixels + fraction = _get_mismatched_fraction(diff, expected_png.size) # Fraction of mismatched pixels should be less than 0.02% - if fraction >= 0.0002: + if fraction >= MAXIMUM_MISMATCH_FRACTION and not _images_match_after_shift( + actual_png, expected_png + ): verbose = False if verbose: print("\npath_to_actual_png={}".format(path_to_actual_png)) @@ -324,6 +318,42 @@ def _compare_actual_and_expected( ) +def _get_mismatched_fraction(diff: Image.Image, size: tuple[int, int]) -> float: + # Sometimes, a few pixels will differ, but the two images appear identical. + # https://codereview.stackexchange.com/questions/55902/fastest-way-to-count-non-zero-pixels-using-python-and-pillow + bbox = diff.getbbox() + if bbox is None: + return 0.0 + nonzero_pixels = ( + diff.crop(bbox) + .point(lambda x: 255 if x else 0) + .convert("L") + .point(bool) + .getdata() + ) + return sum(nonzero_pixels) / (size[0] * size[1]) + + +def _images_match_after_shift(actual_png: Image.Image, expected_png: Image.Image) -> bool: + if actual_png.size != expected_png.size: + return False + + for horizontal_shift in range(-MAXIMUM_PIXEL_SHIFT, MAXIMUM_PIXEL_SHIFT + 1): + for vertical_shift in range(-MAXIMUM_PIXEL_SHIFT, MAXIMUM_PIXEL_SHIFT + 1): + if horizontal_shift == 0 and vertical_shift == 0: + continue + shifted_actual_png = ImageChops.offset( + actual_png, horizontal_shift, vertical_shift + ) + shifted_diff = ImageChops.difference(shifted_actual_png, expected_png) + if ( + _get_mismatched_fraction(shifted_diff, expected_png.size) + < MAXIMUM_MISMATCH_FRACTION + ): + return True + return False + + def _draw_box(image, diff, output_path: str): # https://stackoverflow.com/questions/41405632/draw-a-rectangle-and-a-text-in-it-using-pil draw = ImageDraw.Draw(image) From 3cc9e87009f612188c66cf9b0f991dd5f1711716 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:58:41 +0000 Subject: [PATCH 3/8] Optimize pixel shift comparison Co-authored-by: forsyth2 <30700190+forsyth2@users.noreply.github.com> --- tests/images/test_image_checker.py | 3 ++- tests/integration/image_checker.py | 20 +++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/images/test_image_checker.py b/tests/images/test_image_checker.py index 7b515f31..990672c8 100644 --- a/tests/images/test_image_checker.py +++ b/tests/images/test_image_checker.py @@ -1,4 +1,5 @@ import os +from pathlib import Path from typing import List, Optional from mache import MachineInfo @@ -52,7 +53,7 @@ def test_compare() -> None: assert mismatched_images == ["CRU-TREFHT-ANN-land_60S90N"] -def test_compare_ignores_small_pixel_shift(tmp_path) -> None: +def test_compare_ignores_small_pixel_shift(tmp_path: Path) -> None: expected_path = tmp_path / "expected.png" actual_path = tmp_path / "actual.png" _write_image(str(expected_path)) diff --git a/tests/integration/image_checker.py b/tests/integration/image_checker.py index a0758a5a..ed6a5a1d 100644 --- a/tests/integration/image_checker.py +++ b/tests/integration/image_checker.py @@ -7,7 +7,7 @@ import matplotlib.image as mpimg from mache import MachineInfo from matplotlib import pyplot as plt -from PIL import Image, ImageChops, ImageDraw +from PIL import Image, ImageChops, ImageDraw, ImageStat MAXIMUM_PIXEL_SHIFT = 2 @@ -338,6 +338,8 @@ def _images_match_after_shift(actual_png: Image.Image, expected_png: Image.Image if actual_png.size != expected_png.size: return False + minimum_difference = None + best_diff = None for horizontal_shift in range(-MAXIMUM_PIXEL_SHIFT, MAXIMUM_PIXEL_SHIFT + 1): for vertical_shift in range(-MAXIMUM_PIXEL_SHIFT, MAXIMUM_PIXEL_SHIFT + 1): if horizontal_shift == 0 and vertical_shift == 0: @@ -346,12 +348,16 @@ def _images_match_after_shift(actual_png: Image.Image, expected_png: Image.Image actual_png, horizontal_shift, vertical_shift ) shifted_diff = ImageChops.difference(shifted_actual_png, expected_png) - if ( - _get_mismatched_fraction(shifted_diff, expected_png.size) - < MAXIMUM_MISMATCH_FRACTION - ): - return True - return False + difference = sum(ImageStat.Stat(shifted_diff).sum) + if minimum_difference is None or difference < minimum_difference: + minimum_difference = difference + best_diff = shifted_diff + + assert best_diff is not None + return ( + _get_mismatched_fraction(best_diff, expected_png.size) + < MAXIMUM_MISMATCH_FRACTION + ) def _draw_box(image, diff, output_path: str): From 7541f41af435b32191645bd626dc83731f65b9b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:59:51 +0000 Subject: [PATCH 4/8] Avoid wrapped pixel shift comparisons Co-authored-by: forsyth2 <30700190+forsyth2@users.noreply.github.com> --- tests/integration/image_checker.py | 54 +++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/tests/integration/image_checker.py b/tests/integration/image_checker.py index ed6a5a1d..8d794972 100644 --- a/tests/integration/image_checker.py +++ b/tests/integration/image_checker.py @@ -1,7 +1,7 @@ import os import shutil from math import ceil -from typing import Dict, List +from typing import Dict, List, Optional, Tuple import matplotlib.backends.backend_pdf import matplotlib.image as mpimg @@ -338,28 +338,64 @@ def _images_match_after_shift(actual_png: Image.Image, expected_png: Image.Image if actual_png.size != expected_png.size: return False - minimum_difference = None - best_diff = None + minimum_difference: Optional[float] = None + best_diff: Optional[Image.Image] = None + best_size: Optional[Tuple[int, int]] = None for horizontal_shift in range(-MAXIMUM_PIXEL_SHIFT, MAXIMUM_PIXEL_SHIFT + 1): for vertical_shift in range(-MAXIMUM_PIXEL_SHIFT, MAXIMUM_PIXEL_SHIFT + 1): if horizontal_shift == 0 and vertical_shift == 0: continue - shifted_actual_png = ImageChops.offset( - actual_png, horizontal_shift, vertical_shift + actual_overlap, expected_overlap = _get_overlapping_images( + actual_png, expected_png, horizontal_shift, vertical_shift ) - shifted_diff = ImageChops.difference(shifted_actual_png, expected_png) - difference = sum(ImageStat.Stat(shifted_diff).sum) + shifted_diff = ImageChops.difference(actual_overlap, expected_overlap) + width, height = shifted_diff.size + difference = sum(ImageStat.Stat(shifted_diff).sum) / (width * height) if minimum_difference is None or difference < minimum_difference: minimum_difference = difference best_diff = shifted_diff + best_size = shifted_diff.size - assert best_diff is not None + if best_diff is None or best_size is None: + return False return ( - _get_mismatched_fraction(best_diff, expected_png.size) + _get_mismatched_fraction(best_diff, best_size) < MAXIMUM_MISMATCH_FRACTION ) +def _get_overlapping_images( + actual_png: Image.Image, + expected_png: Image.Image, + horizontal_shift: int, + vertical_shift: int, +) -> Tuple[Image.Image, Image.Image]: + width, height = actual_png.size + actual_left = max(0, -horizontal_shift) + actual_upper = max(0, -vertical_shift) + expected_left = max(0, horizontal_shift) + expected_upper = max(0, vertical_shift) + overlap_width = width - abs(horizontal_shift) + overlap_height = height - abs(vertical_shift) + actual_overlap = actual_png.crop( + ( + actual_left, + actual_upper, + actual_left + overlap_width, + actual_upper + overlap_height, + ) + ) + expected_overlap = expected_png.crop( + ( + expected_left, + expected_upper, + expected_left + overlap_width, + expected_upper + overlap_height, + ) + ) + return actual_overlap, expected_overlap + + def _draw_box(image, diff, output_path: str): # https://stackoverflow.com/questions/41405632/draw-a-rectangle-and-a-text-in-it-using-pil draw = ImageDraw.Draw(image) From 09629dc4d8bdb1c259197824b7de66c56f802174 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:00:35 +0000 Subject: [PATCH 5/8] Simplify pixel shift matching Co-authored-by: forsyth2 <30700190+forsyth2@users.noreply.github.com> --- tests/integration/image_checker.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/integration/image_checker.py b/tests/integration/image_checker.py index 8d794972..56d2b7ea 100644 --- a/tests/integration/image_checker.py +++ b/tests/integration/image_checker.py @@ -318,7 +318,7 @@ def _compare_actual_and_expected( ) -def _get_mismatched_fraction(diff: Image.Image, size: tuple[int, int]) -> float: +def _get_mismatched_fraction(diff: Image.Image, size: Tuple[int, int]) -> float: # Sometimes, a few pixels will differ, but the two images appear identical. # https://codereview.stackexchange.com/questions/55902/fastest-way-to-count-non-zero-pixels-using-python-and-pillow bbox = diff.getbbox() @@ -340,7 +340,6 @@ def _images_match_after_shift(actual_png: Image.Image, expected_png: Image.Image minimum_difference: Optional[float] = None best_diff: Optional[Image.Image] = None - best_size: Optional[Tuple[int, int]] = None for horizontal_shift in range(-MAXIMUM_PIXEL_SHIFT, MAXIMUM_PIXEL_SHIFT + 1): for vertical_shift in range(-MAXIMUM_PIXEL_SHIFT, MAXIMUM_PIXEL_SHIFT + 1): if horizontal_shift == 0 and vertical_shift == 0: @@ -354,12 +353,11 @@ def _images_match_after_shift(actual_png: Image.Image, expected_png: Image.Image if minimum_difference is None or difference < minimum_difference: minimum_difference = difference best_diff = shifted_diff - best_size = shifted_diff.size - if best_diff is None or best_size is None: + if best_diff is None: return False return ( - _get_mismatched_fraction(best_diff, best_size) + _get_mismatched_fraction(best_diff, best_diff.size) < MAXIMUM_MISMATCH_FRACTION ) From 02edd70db0e1285e65b4e580d94884ebd49bcd64 Mon Sep 17 00:00:00 2001 From: Ryan Forsyth Date: Fri, 28 Aug 2026 17:33:33 -0500 Subject: [PATCH 6/8] Address comments --- tests/images/test_image_checker.py | 9 +++++---- tests/integration/image_checker.py | 30 +++++++++++------------------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/tests/images/test_image_checker.py b/tests/images/test_image_checker.py index 990672c8..284677df 100644 --- a/tests/images/test_image_checker.py +++ b/tests/images/test_image_checker.py @@ -58,10 +58,11 @@ def test_compare_ignores_small_pixel_shift(tmp_path: Path) -> None: actual_path = tmp_path / "actual.png" _write_image(str(expected_path)) - expected_image = Image.open(expected_path) - actual_image = Image.new("RGB", expected_image.size, "white") - actual_image.paste(expected_image, (1, -2)) - actual_image.save(actual_path) + with Image.open(expected_path) as expected_image: + expected_image = expected_image.convert("RGB") + actual_image = Image.new("RGB", expected_image.size, "white") + actual_image.paste(expected_image, (1, -2)) + actual_image.save(actual_path) missing_images: List[str] = [] mismatched_images: List[str] = [] diff --git a/tests/integration/image_checker.py b/tests/integration/image_checker.py index 56d2b7ea..48e66a25 100644 --- a/tests/integration/image_checker.py +++ b/tests/integration/image_checker.py @@ -1,14 +1,13 @@ import os import shutil from math import ceil -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Tuple import matplotlib.backends.backend_pdf import matplotlib.image as mpimg from mache import MachineInfo from matplotlib import pyplot as plt -from PIL import Image, ImageChops, ImageDraw, ImageStat - +from PIL import Image, ImageChops, ImageDraw MAXIMUM_PIXEL_SHIFT = 2 MAXIMUM_MISMATCH_FRACTION = 0.0002 @@ -284,8 +283,6 @@ def _compare_actual_and_expected( if verbose: print("\npath_to_actual_png={}".format(path_to_actual_png)) print("path_to_expected_png={}".format(path_to_expected_png)) - print("diff has {} nonzero pixels.".format(num_nonzero_pixels)) - print("total number of pixels={}".format(num_pixels)) print("num_nonzero_pixels/num_pixels fraction={}".format(fraction)) mismatched_images.append(image_name) @@ -334,12 +331,12 @@ def _get_mismatched_fraction(diff: Image.Image, size: Tuple[int, int]) -> float: return sum(nonzero_pixels) / (size[0] * size[1]) -def _images_match_after_shift(actual_png: Image.Image, expected_png: Image.Image) -> bool: +def _images_match_after_shift( + actual_png: Image.Image, expected_png: Image.Image +) -> bool: if actual_png.size != expected_png.size: return False - minimum_difference: Optional[float] = None - best_diff: Optional[Image.Image] = None for horizontal_shift in range(-MAXIMUM_PIXEL_SHIFT, MAXIMUM_PIXEL_SHIFT + 1): for vertical_shift in range(-MAXIMUM_PIXEL_SHIFT, MAXIMUM_PIXEL_SHIFT + 1): if horizontal_shift == 0 and vertical_shift == 0: @@ -348,18 +345,13 @@ def _images_match_after_shift(actual_png: Image.Image, expected_png: Image.Image actual_png, expected_png, horizontal_shift, vertical_shift ) shifted_diff = ImageChops.difference(actual_overlap, expected_overlap) - width, height = shifted_diff.size - difference = sum(ImageStat.Stat(shifted_diff).sum) / (width * height) - if minimum_difference is None or difference < minimum_difference: - minimum_difference = difference - best_diff = shifted_diff + if ( + _get_mismatched_fraction(shifted_diff, shifted_diff.size) + < MAXIMUM_MISMATCH_FRACTION + ): + return True - if best_diff is None: - return False - return ( - _get_mismatched_fraction(best_diff, best_diff.size) - < MAXIMUM_MISMATCH_FRACTION - ) + return False def _get_overlapping_images( From 060b78381b552b2e28238f53ab15a37c902e8dbf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:55:53 +0000 Subject: [PATCH 7/8] Use FFT-based phase correlation for pixel-shift detection Co-authored-by: forsyth2 <30700190+forsyth2@users.noreply.github.com> --- conda/dev.yml | 1 + tests/integration/image_checker.py | 79 ++++++++++++++++++++++++------ 2 files changed, 64 insertions(+), 16 deletions(-) diff --git a/conda/dev.yml b/conda/dev.yml index 591a7f32..f90ea3b4 100644 --- a/conda/dev.yml +++ b/conda/dev.yml @@ -18,6 +18,7 @@ dependencies: - jinja2>=3.1.2 # Changed from =3.1.2 - mache>=1.5.0 - mpas_tools>=0.15.0 + - numpy - pillow>=9.2.0 # Changed from =9.2.0 # Testing # ======================= diff --git a/tests/integration/image_checker.py b/tests/integration/image_checker.py index 48e66a25..e4b9bf0b 100644 --- a/tests/integration/image_checker.py +++ b/tests/integration/image_checker.py @@ -5,11 +5,15 @@ import matplotlib.backends.backend_pdf import matplotlib.image as mpimg +import numpy as np from mache import MachineInfo from matplotlib import pyplot as plt from PIL import Image, ImageChops, ImageDraw -MAXIMUM_PIXEL_SHIFT = 2 +# The FFT-based shift estimate in `_estimate_shift` can in principle return +# any shift up to half the image's width/height, but shifts larger than this +# are treated as genuine mismatches rather than harmless translations. +MAXIMUM_PIXEL_SHIFT = 10 MAXIMUM_MISMATCH_FRACTION = 0.0002 @@ -337,21 +341,64 @@ def _images_match_after_shift( if actual_png.size != expected_png.size: return False - for horizontal_shift in range(-MAXIMUM_PIXEL_SHIFT, MAXIMUM_PIXEL_SHIFT + 1): - for vertical_shift in range(-MAXIMUM_PIXEL_SHIFT, MAXIMUM_PIXEL_SHIFT + 1): - if horizontal_shift == 0 and vertical_shift == 0: - continue - actual_overlap, expected_overlap = _get_overlapping_images( - actual_png, expected_png, horizontal_shift, vertical_shift - ) - shifted_diff = ImageChops.difference(actual_overlap, expected_overlap) - if ( - _get_mismatched_fraction(shifted_diff, shifted_diff.size) - < MAXIMUM_MISMATCH_FRACTION - ): - return True - - return False + # Rather than exhaustively trying every candidate shift (which costs up + # to (2 * MAXIMUM_PIXEL_SHIFT + 1)^2 image diffs), use phase correlation + # (an FFT-based technique) to find the single best-aligning shift in one + # pass. See: https://en.wikipedia.org/wiki/Phase_correlation + horizontal_shift, vertical_shift = _estimate_shift(actual_png, expected_png) + if horizontal_shift == 0 and vertical_shift == 0: + return False + if ( + abs(horizontal_shift) > MAXIMUM_PIXEL_SHIFT + or abs(vertical_shift) > MAXIMUM_PIXEL_SHIFT + ): + return False + + actual_overlap, expected_overlap = _get_overlapping_images( + actual_png, expected_png, horizontal_shift, vertical_shift + ) + shifted_diff = ImageChops.difference(actual_overlap, expected_overlap) + return ( + _get_mismatched_fraction(shifted_diff, shifted_diff.size) + < MAXIMUM_MISMATCH_FRACTION + ) + + +def _estimate_shift( + actual_png: Image.Image, expected_png: Image.Image +) -> Tuple[int, int]: + # Use phase correlation to estimate the (horizontal, vertical) pixel + # shift that best aligns `actual_png` with `expected_png`. This finds the + # shift in a single FFT-based operation, regardless of how large the + # shift is, rather than searching over every candidate shift. + actual_array = np.asarray(actual_png.convert("L"), dtype=np.float64) + expected_array = np.asarray(expected_png.convert("L"), dtype=np.float64) + + actual_fft = np.fft.fft2(actual_array) + expected_fft = np.fft.fft2(expected_array) + # The order here matters: this yields a peak at (horizontal_shift, + # vertical_shift) such that actual(x, y) == expected(x + horizontal_shift, + # y + vertical_shift), matching the convention used by + # `_get_overlapping_images`. + cross_power = expected_fft * np.conj(actual_fft) + magnitude = np.abs(cross_power) + # Avoid division by zero where the cross power is (near) zero. + magnitude[magnitude < 1e-10] = 1e-10 + correlation = np.abs(np.fft.ifft2(cross_power / magnitude)) + + height, width = correlation.shape + peak_row, peak_col = np.unravel_index(np.argmax(correlation), correlation.shape) + + # The correlation surface wraps around at the image boundaries, so a peak + # in the second half of the axis corresponds to a negative shift. + horizontal_shift = int(peak_col) + if horizontal_shift > width // 2: + horizontal_shift -= width + vertical_shift = int(peak_row) + if vertical_shift > height // 2: + vertical_shift -= height + + return horizontal_shift, vertical_shift def _get_overlapping_images( From 19420b5f954580cc6a8a5859011b0b96a658dd96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:27:39 +0000 Subject: [PATCH 8/8] Append try# suffix to image_check_failures dir instead of overwriting Co-authored-by: forsyth2 <30700190+forsyth2@users.noreply.github.com> --- tests/integration/image_checker.py | 35 ++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/tests/integration/image_checker.py b/tests/integration/image_checker.py index e4b9bf0b..5b907e13 100644 --- a/tests/integration/image_checker.py +++ b/tests/integration/image_checker.py @@ -52,6 +52,22 @@ def __init__( # Specialized setup ########################################################### +def _get_unused_diff_dir(base_diff_dir: str) -> str: + """Return a diff_dir path that does not already exist. + + If base_diff_dir does not exist, it is returned unchanged. Otherwise, + a "_tryN" suffix (starting at N=2) is appended until an unused + directory name is found. This prevents reruns of the image checker + from overwriting the results of previous runs. + """ + if not os.path.exists(base_diff_dir): + return base_diff_dir + try_number = 2 + while os.path.exists(f"{base_diff_dir}_try{try_number}"): + try_number += 1 + return f"{base_diff_dir}_try{try_number}" + + def set_up_and_run_image_checker( cfg_specifier: str, case_name: str, @@ -62,22 +78,19 @@ def set_up_and_run_image_checker( ): print(f"Image checking {cfg_specifier}") actual_images_dir = f"{expansions['user_www']}zppy_weekly_{cfg_specifier}_www/{expansions['unique_id']}/{case_name}/" + base_diff_dir = ( + f"{actual_images_dir}image_check_failures_{cfg_specifier}{diff_dir_suffix}" + ) d: Dict[str, str] = { "actual_images_dir": actual_images_dir, "expected_images_dir": f"{expansions['expected_dir']}expected_{cfg_specifier}", - "diff_dir": f"{actual_images_dir}image_check_failures_{cfg_specifier}{diff_dir_suffix}", + "diff_dir": _get_unused_diff_dir(base_diff_dir), "expected_images_list": f"{expansions['expected_dir']}image_list_expected_{cfg_specifier}.txt", } - print(f"Removing diff_dir={d['diff_dir']} to produce new results") - if os.path.exists(d["diff_dir"]): - try: - shutil.rmtree(d["diff_dir"]) - except PermissionError: - print( - f"{d['diff_dir']} cannot be removed. Execute permissions are needed to remove files. Adding execute permission and trying again." - ) - _chmod_recursive(d["diff_dir"], 0o744) - shutil.rmtree(d["diff_dir"]) + if d["diff_dir"] != base_diff_dir: + print( + f"diff_dir={base_diff_dir} already exists; using {d['diff_dir']} instead to avoid overwriting previous results" + ) print("Image checking dict:") for key in d: print(f"{key}: {d[key]}")