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
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
if: github.event_name == 'pull_request'
run: |
git fetch origin ${{ github.base_ref }} --depth=1
just cov-diff
just cov-diff origin/${{ github.base_ref }}

- name: Code Coverage Summary Report
uses: irongut/CodeCoverageSummary@v1.3.0
Expand Down
5 changes: 3 additions & 2 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,11 @@ ci job="":
[ -z "{{ job }}" ] && act --list || act --job {{ job }} --quiet

# run coverage difference between current branch and main
cov-diff:
cov-diff base_branch="origin/main":
[ -f coverage.xml ] || just test xml
@just log "Getting coverage difference against main"
@just log "Getting coverage difference against {{ base_branch }}"
uv run diff-cover coverage.xml \
--compare-branch {{ base_branch }} \
--diff-range-notation '..' \
--fail-under 80 \
--format markdown:diff_coverage.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from collections.abc import Iterable

from container_models.scan_image import ScanImage
from conversion.surface_comparison.grid import GridCell
from conversion.surface_comparison.models import (
ComparisonParams,
Cell,
CellMetaData,
GridCell,
ProcessedMark,
)

Expand Down
125 changes: 88 additions & 37 deletions packages/scratch-core/src/conversion/surface_comparison/grid.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,108 @@
from container_models.base import FloatArray2D
from container_models.scan_image import ScanImage
from conversion.surface_comparison.models import ComparisonParams
import math

from dataclasses import dataclass
import numpy as np

from container_models.base import FloatArray2D
from container_models.scan_image import ScanImage
from conversion.surface_comparison.models import GridCell
from conversion.surface_comparison.utils import convert_meters_to_pixels


@dataclass(frozen=True)
class GridCell:
def generate_grid(
scan_image: ScanImage, cell_size: tuple[float, float], minimum_fill_fraction: float
) -> list[GridCell]:
"""
Container class for storing generated grid cells.
Generate a centered grid of cells covering the image. The image is assumed to be isotropic.

All the values of the attributes and properties are in pixel units.
The grid is symmetrically centered on the image using the MATLAB even/odd
seed logic. Cells with insufficient valid data are filtered out.

:param top_left: Tuple containing the top-left pixel coordinates (x, y) corresponding to the reference image.
:param cell_data: 2D array containing the sliced image data from the reference image.
:param scan_image: reference scan image
:param cell_size: size of the cells of the grid in meters
:param minimum_fill_fraction: minimum fraction of valid data of each cell
:return: list of valid grid cells
"""
cell_width, cell_height = convert_meters_to_pixels(cell_size, scan_image.scale_x)
Comment thread
SimoneAriens marked this conversation as resolved.
xs = _tile_axis(scan_image.width, cell_width)
ys = _tile_axis(scan_image.height, cell_height)

top_left: tuple[int, int]
cell_data: FloatArray2D
output = []
for y in ys:
for x in xs:
cell_data = extract_patch(
scan_image=scan_image,
coordinates=(x, y),
Comment thread
SimoneAriens marked this conversation as resolved.
patch_size=(cell_width, cell_height),
fill_value=np.nan,
)
cell = GridCell(top_left=(x, y), cell_data=cell_data)
if cell.fill_fraction < minimum_fill_fraction:
continue
output.append(cell)
return output

@property
def width(self) -> int:
return self.cell_data.shape[1]

@property
def height(self) -> int:
return self.cell_data.shape[0]
def extract_patch(
scan_image: ScanImage,
coordinates: tuple[int, int],
Comment thread
SimoneAriens marked this conversation as resolved.
patch_size: tuple[int, int],
fill_value: float = np.nan,
) -> FloatArray2D:
"""
Extract a rectangular patch from a scan image, padding with fill_value
where the patch extends beyond the image boundaries.

@property
def center(self) -> tuple[float, float]:
return self.top_left[0] + self.width / 2, self.top_left[1] + self.height / 2
:param scan_image: source image to extract from
:param coordinates: (x, y) top-left corner of the patch, may be negative, in pixel coordinates
:param patch_size: (width, height) of the output patch
:param fill_value: value to use for out-of-bounds pixels
:return: 2D array of shape (height, width)
"""
x, y = coordinates
Comment thread
SimoneAriens marked this conversation as resolved.
width, height = patch_size

@property
def fill_fraction(self) -> float:
return float(np.count_nonzero(~np.isnan(self.cell_data)) / self.cell_data.size)
# Find the overlap between the patch and the image
row_start = max(y, 0)
row_end = min(y + height, scan_image.height)
col_start = max(x, 0)
col_end = min(x + width, scan_image.width)

def fill_nans(self, fill_value: float):
self.cell_data[np.isnan(self.cell_data)] = fill_value
if row_start >= row_end or col_start >= col_end:
Comment thread
SimoneAriens marked this conversation as resolved.
Comment thread
SimoneAriens marked this conversation as resolved.
raise ValueError(
f"Patch at ({x}, {y}) with size ({width}, {height}) "
f"has no overlap with image of size ({scan_image.width}, {scan_image.height})"
)

# Get the patch
patch = scan_image.data[row_start:row_end, col_start:col_end]

def generate_grid(scan_image: ScanImage, params: ComparisonParams) -> list[GridCell]:
"""TODO: Implement function."""
# Pad where needed
pad_top = row_start - y
pad_bottom = y + height - row_end
pad_left = col_start - x
pad_right = x + width - col_end

# Create a dummy cell
x, y = 0, 0 # Top-left coordinates of cell in reference image
pixel_size = scan_image.scale_x # Assumes isotropic image
width, height = convert_meters_to_pixels(
values=params.cell_size, pixel_size=pixel_size
padded = np.pad(
patch,
pad_width=((pad_top, pad_bottom), (pad_left, pad_right)),
mode="constant",
constant_values=fill_value,
)
dummy = GridCell(
top_left=(x, y), cell_data=scan_image.data[y : y + height, x : x + width]
)
return [dummy]
return padded


def _tile_axis(image_size: int, cell_size: int) -> list[int]:
"""Generate top-left coordinates for cells along one axis.

Places cells symmetrically around the midpoint of the image. When an
odd number of cells fits, one cell is centered on the midpoint. When
even, two cells straddle it.

:param image_size: image size in pixels along the axis
:param cell_size: cell size in pixels along the axis
:return: sorted list of top-left coordinates
"""
n = math.ceil(image_size / cell_size)
offsets = np.arange(n) - n / 2
top_lefts = np.round(offsets * cell_size + image_size / 2).astype(int)
return top_lefts.tolist()
32 changes: 32 additions & 0 deletions packages/scratch-core/src/conversion/surface_comparison/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import numpy as np
from pydantic import Field, field_validator, PositiveFloat
from collections.abc import Sequence
from dataclasses import dataclass
Expand Down Expand Up @@ -130,3 +131,34 @@ class ComparisonParams(ConfigBaseModel):
search_angle_min: float = -180.0
search_angle_max: float = 180.0
search_angle_step: float = Field(default=1.0, gt=0.0)


@dataclass(frozen=True)
class GridCell:
"""
Container class for storing generated grid cells.

All the values of the attributes and properties are in pixel units.

:param top_left: Tuple containing the top-left pixel coordinates (x, y) corresponding to the reference image.
:param cell_data: 2D array containing the sliced image data from the reference image.
"""

top_left: tuple[int, int]
cell_data: FloatArray2D

@property
def width(self) -> int:
return self.cell_data.shape[1]

@property
def height(self) -> int:
return self.cell_data.shape[0]

@property
def center(self) -> tuple[float, float]:
return self.top_left[0] + self.width / 2, self.top_left[1] + self.height / 2

@property
def fill_fraction(self) -> float:
return float(np.count_nonzero(~np.isnan(self.cell_data)) / self.cell_data.size)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import numpy as np


from conversion.resample import resample_scan_image_and_mask
from conversion.surface_comparison.cell_registration import (
coarse_registration,
Expand Down Expand Up @@ -32,7 +33,11 @@ def compare_surfaces(
)

# Step 2: Generate grid cells
grid_cells = generate_grid(scan_image=reference_image, params=params)
grid_cells = generate_grid(
scan_image=reference_image,
cell_size=params.cell_size,
minimum_fill_fraction=params.minimum_fill_fraction,
)

# Step 3: Coarse registration
fill_value_reference = float(np.nanmean(reference_image.data))
Expand Down
Loading
Loading