Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions packages/scratch-core/src/conversion/exceptions.py
Original file line number Diff line number Diff line change
@@ -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."""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
ProcessedMark,
)
from conversion.surface_comparison.utils import (
assert_image_is_isotropic,
make_image_isotropic,
resolve_nan_fill_value,
)

Expand All @@ -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.
Expand All @@ -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 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
)

# Step 1: Resample comparison to reference scale (for the fine stage)
logger.debug("starting resample")
Expand Down
31 changes: 22 additions & 9 deletions packages/scratch-core/src/conversion/surface_comparison/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 the axes drift apart by ~1 pixel per 1000 pixels.
SCALE_COMPARISON_ATOL = 0.0
SCALE_COMPARISON_RTOL = 5e-3
SCALE_COMPARISON_RTOL = 1e-3


def convert_meters_to_pixels(
Expand Down Expand Up @@ -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.

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 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,
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)
Comment thread
cfs-data marked this conversation as resolved.


def resolve_nan_fill_value(
Expand Down
4 changes: 2 additions & 2 deletions src/processors/router.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 0 additions & 16 deletions tests/processors/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading