From da02b796a1a0a9f20caa1f8df9cadf4742404473 Mon Sep 17 00:00:00 2001 From: cfs-data <145435153+cfs-data@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:21:38 +0200 Subject: [PATCH 1/4] first commit --- .../scratch-core/src/conversion/exceptions.py | 12 ------- .../conversion/surface_comparison/pipeline.py | 15 +++++---- .../conversion/surface_comparison/utils.py | 31 +++++++++++++------ src/processors/router.py | 4 +-- tests/processors/test_router.py | 16 ---------- 5 files changed, 31 insertions(+), 47 deletions(-) diff --git a/packages/scratch-core/src/conversion/exceptions.py b/packages/scratch-core/src/conversion/exceptions.py index 4c9fe933..e0ecce4b 100644 --- a/packages/scratch-core/src/conversion/exceptions.py +++ b/packages/scratch-core/src/conversion/exceptions.py @@ -1,15 +1,3 @@ -class ImageNotIsotropicError(Exception): - """Raised when an image is not isotropic.""" - - def __init__(self, scale_x: float | int, scale_y: float | int): - super().__init__(scale_x, scale_y) - self.scale_x = scale_x - self.scale_y = scale_y - - def __str__(self) -> str: - return f"Image is not isotropic: scale_x={self.scale_x}, while scale_y={self.scale_y}." - - class NoValidGridCellsError(Exception): """Raised when no valid grid cells are generated.""" diff --git a/packages/scratch-core/src/conversion/surface_comparison/pipeline.py b/packages/scratch-core/src/conversion/surface_comparison/pipeline.py index f65e5720..01b75c82 100644 --- a/packages/scratch-core/src/conversion/surface_comparison/pipeline.py +++ b/packages/scratch-core/src/conversion/surface_comparison/pipeline.py @@ -31,7 +31,7 @@ ProcessedMark, ) from conversion.surface_comparison.utils import ( - assert_image_is_isotropic, + make_image_isotropic, resolve_nan_fill_value, ) @@ -46,6 +46,7 @@ def compare_surfaces( Executes the pipeline: + 0. **Square up** — either image is resampled to isotropic if the tilt correction left its axes on different scales. 1. **Resample** — the comparison image is resampled to the pixel size of the reference image. 2. **Generate grid** — a centered rectangular grid of cells is placed over the reference image. 3. **Build the full-resolution stage** — the scale-aligned comparison image and reference templates, padded. @@ -65,14 +66,12 @@ def compare_surfaces( search configuration, and CMC classification thresholds. :returns: A ComparisonResult containing per-cell registration results, the consensus rotation and translation, and CMC counts. - :raises ValueError: If the image's pixel grid is not isotropic. """ - reference_image = reference_mark.filtered_mark.scan_image - comparison_image_original = comparison_mark.filtered_mark.scan_image - - # Everything below uses scale_x for both axes, so anisotropy would go unnoticed. - assert_image_is_isotropic(reference_image) - assert_image_is_isotropic(comparison_image_original) + # Everything below uses scale_x for both axes, so anisotropy left by the tilt correction must go first. + reference_image = make_image_isotropic(reference_mark.filtered_mark.scan_image) + comparison_image_original = make_image_isotropic( + comparison_mark.filtered_mark.scan_image + ) # Step 1: Resample comparison to reference scale (for the fine stage) logger.debug("starting resample") diff --git a/packages/scratch-core/src/conversion/surface_comparison/utils.py b/packages/scratch-core/src/conversion/surface_comparison/utils.py index 848d322d..52faa136 100644 --- a/packages/scratch-core/src/conversion/surface_comparison/utils.py +++ b/packages/scratch-core/src/conversion/surface_comparison/utils.py @@ -5,13 +5,13 @@ from container_models.base import FloatArray1D, FloatArray2D, Points2D from container_models.scan_image import ScanImage -from conversion.exceptions import ImageNotIsotropicError +from conversion.resample import resample_scan_image_nan_aware from conversion.surface_comparison.models import Cell, ComparisonParams -# Tolerances for np.isclose() when comparing pixel scales (e.g. isotropy check, matching scales between images). -# Tilt correction divides each axis by its own cos(tilt), so 5e-3 accepts a tilt difference up to ~5.7 degrees. +# Tolerances for np.isclose() when comparing pixel scales (isotropy check, matching scales between images). +# atol stays 0.0 to keep the check relative; at 1e-3 a scale difference shifts the image edge by about half a pixel. SCALE_COMPARISON_ATOL = 0.0 -SCALE_COMPARISON_RTOL = 5e-3 +SCALE_COMPARISON_RTOL = 1e-3 def convert_meters_to_pixels( @@ -101,16 +101,29 @@ def _cells_correlation_to_grid(cells: Sequence[Cell]) -> FloatArray2D: return cell_correlations -def assert_image_is_isotropic(scan_image: ScanImage) -> None: - if not np.isclose( +def make_image_isotropic(scan_image: ScanImage) -> ScanImage: + """ + Put *scan_image* on a square pixel grid, which the rest of the CMC pipeline assumes. + + Marks are resampled to isotropic when parsed, but the tilt correction divides each axis by its own + cos(tilt) and so reintroduces a small anisotropy. Differences below SCALE_COMPARISON_RTOL are left alone. + + :param scan_image: Image to square up; its scale_x defines the target grid. + :returns: The image itself when already isotropic, otherwise a copy resampled onto scale_x. + """ + if np.isclose( scan_image.scale_x, scan_image.scale_y, atol=SCALE_COMPARISON_ATOL, rtol=SCALE_COMPARISON_RTOL, ): - raise ImageNotIsotropicError( - scale_x=scan_image.scale_x, scale_y=scan_image.scale_y - ) + return scan_image + logger.debug( + "Resampling to isotropic: scale_x={:.4g}, scale_y={:.4g}", + scan_image.scale_x, + scan_image.scale_y, + ) + return resample_scan_image_nan_aware(scan_image, scan_image.scale_x) def resolve_nan_fill_value( diff --git a/src/processors/router.py b/src/processors/router.py index 37ea9b2c..da243fed 100644 --- a/src/processors/router.py +++ b/src/processors/router.py @@ -1,7 +1,7 @@ from http import HTTPStatus from conversion.data_formats import MarkImpressionType -from conversion.exceptions import ImageNotIsotropicError, NoValidGridCellsError +from conversion.exceptions import NoValidGridCellsError from conversion.export.mark import load_mark_from_path, save_mark from conversion.export.profile import load_profile_from_path from conversion.surface_comparison.models import ProcessedMark @@ -103,7 +103,7 @@ async def calculate_score_impression(impression_params: CalculateScoreImpression comparison_mark=mark_comp_processed, params=impression_params.comparison_params, ) - except (NoValidGridCellsError, ImageNotIsotropicError) as exception: + except NoValidGridCellsError as exception: message = str(exception) logger.error(message) raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY, message) diff --git a/tests/processors/test_router.py b/tests/processors/test_router.py index cf0a8a59..2085ebea 100644 --- a/tests/processors/test_router.py +++ b/tests/processors/test_router.py @@ -287,22 +287,6 @@ def load_side_effect(*args, **kwargs): assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert "expected a MarkImpressionType" in response.json()["detail"] - def test_image_not_isotropic_returns_422(self, client: TestClient, json_data: dict) -> None: - """422 is returned when the mark image has different scale_x and scale_y.""" - anisotropic_mark = Mark( - scan_image=ScanImage(data=np.array([[0.0]]), scale_x=1e-6, scale_y=2e-6), - mark_type=MarkImpressionType.BREECH_FACE_IMPRESSION, - ) - - def load_side_effect(*args, **kwargs): - return anisotropic_mark - - with patch("processors.router.load_mark_from_path", side_effect=load_side_effect): - response = client.post("/processor/" + ProcessorEndpoint.CALCULATE_SCORE_IMPRESSION, json=json_data) - - assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - assert "not isotropic" in response.json()["detail"] - def test_no_valid_grid_cells_returns_422(self, client: TestClient, json_data: dict) -> None: """422 is returned when the mark image is all-NaN so no grid cells can be generated.""" all_nan_mark = Mark( From 85dfce34ed4d9c89b575981c8d8b2858f71d3376 Mon Sep 17 00:00:00 2001 From: cfs-data <145435153+cfs-data@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:28:14 +0200 Subject: [PATCH 2/4] rewrite docstring --- .../scratch-core/src/conversion/surface_comparison/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/scratch-core/src/conversion/surface_comparison/utils.py b/packages/scratch-core/src/conversion/surface_comparison/utils.py index 52faa136..24ad97cc 100644 --- a/packages/scratch-core/src/conversion/surface_comparison/utils.py +++ b/packages/scratch-core/src/conversion/surface_comparison/utils.py @@ -105,11 +105,11 @@ def make_image_isotropic(scan_image: ScanImage) -> ScanImage: """ Put *scan_image* on a square pixel grid, which the rest of the CMC pipeline assumes. - Marks are resampled to isotropic when parsed, but the tilt correction divides each axis by its own + Images are resampled to isotropic when parsed, but the tilt correction divides each axis by its own cos(tilt) and so reintroduces a small anisotropy. Differences below SCALE_COMPARISON_RTOL are left alone. - :param scan_image: Image to square up; its scale_x defines the target grid. - :returns: The image itself when already isotropic, otherwise a copy resampled onto scale_x. + :param scan_image: Image to resample to isotropic resolution + :returns: The image itself when already isotropic, otherwise a copy resampled onto scale_x """ if np.isclose( scan_image.scale_x, From a0f18244fe598b7704494ada34ed72810067acbf Mon Sep 17 00:00:00 2001 From: cfs-data <145435153+cfs-data@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:29:29 +0200 Subject: [PATCH 3/4] better comment --- .../scratch-core/src/conversion/surface_comparison/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/scratch-core/src/conversion/surface_comparison/pipeline.py b/packages/scratch-core/src/conversion/surface_comparison/pipeline.py index 01b75c82..9ace38dc 100644 --- a/packages/scratch-core/src/conversion/surface_comparison/pipeline.py +++ b/packages/scratch-core/src/conversion/surface_comparison/pipeline.py @@ -67,7 +67,7 @@ def compare_surfaces( :returns: A ComparisonResult containing per-cell registration results, the consensus rotation and translation, and CMC counts. """ - # Everything below uses scale_x for both axes, so anisotropy left by the tilt correction must go first. + # Everything below uses scale_x for both axes, so anisotropy left by the tilt correction must be removed first. reference_image = make_image_isotropic(reference_mark.filtered_mark.scan_image) comparison_image_original = make_image_isotropic( comparison_mark.filtered_mark.scan_image From 65aff3b3d359ec61201ab5c4b296a5e6efa0c650 Mon Sep 17 00:00:00 2001 From: cfs-data <145435153+cfs-data@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:35:46 +0200 Subject: [PATCH 4/4] better comment --- .../scratch-core/src/conversion/surface_comparison/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/scratch-core/src/conversion/surface_comparison/utils.py b/packages/scratch-core/src/conversion/surface_comparison/utils.py index 24ad97cc..5b345da7 100644 --- a/packages/scratch-core/src/conversion/surface_comparison/utils.py +++ b/packages/scratch-core/src/conversion/surface_comparison/utils.py @@ -9,7 +9,7 @@ from conversion.surface_comparison.models import Cell, ComparisonParams # Tolerances for np.isclose() when comparing pixel scales (isotropy check, matching scales between images). -# atol stays 0.0 to keep the check relative; at 1e-3 a scale difference shifts the image edge by about half a pixel. +# atol stays 0.0 to keep the check relative; at 1e-3 the axes drift apart by ~1 pixel per 1000 pixels. SCALE_COMPARISON_ATOL = 0.0 SCALE_COMPARISON_RTOL = 1e-3