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
27 changes: 13 additions & 14 deletions datalab/gui/processor/geometry_postprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@
import importlib
from typing import Literal

import numpy as np
import sigima.proc.image as sipi
from sigima.objects import ImageObj
from sigima.proc.decorator import ComputationMetadata
from sigima.proc.image.transformations import IMAGE_OPERATIONS

# Import GeometryAdapter directly from its module (not from the package __init__)
# to avoid pulling in datalab.adapters_metadata.common -> datalab.config -> PlotPy/Qt
Expand All @@ -35,17 +35,22 @@ def apply_geometry_transform(
operation: Literal[
"translate", "scale", "rotate90", "rotate270", "fliph", "flipv", "transpose"
],
src: ImageObj | None = None,
**kwargs,
) -> None:
"""Apply a geometric transformation to all geometry results in an object.

This uses the Sigima transformation system for proper geometric operations.
For image objects, rotations are performed around the image center to match
how the image data is transformed.
For the exact image operations (flips, quarter turns and transposition), the
results move exactly as the pixels of the source image, so that they stay on
the image features they describe.

Args:
obj: The object containing geometry results to transform
operation: The transformation operation name
src: Source image of the exact image operations (``rotate90``,
``rotate270``, ``fliph``, ``flipv`` and ``transpose``), whose pixel grid
defines how the results move (required for these operations)
**kwargs: Optional parameters for the transformation (e.g., angle for rotate)
"""
assert operation in [
Expand All @@ -65,6 +70,8 @@ def apply_geometry_transform(
if not kwargs or "sx" not in kwargs or "sy" not in kwargs:
raise ValueError("scale operation requires 'sx' and 'sy' parameters")
sx, sy = kwargs["sx"], kwargs["sy"]
elif src is None:
raise ValueError(f"{operation} operation requires the source image")
for adapter in list(GeometryAdapter.iterate_from_obj(obj)):
geom = adapter.result
assert geom is not None, "Geometry should not be None"
Expand All @@ -73,16 +80,8 @@ def apply_geometry_transform(
tr_geom = sipi.transformer.translate(geom, dx, dy)
elif operation == "scale":
tr_geom = sipi.transformer.scale(geom, sx, sy, (obj.xc, obj.yc))
elif operation == "rotate90":
tr_geom = sipi.transformer.rotate(geom, -np.pi / 2, (obj.xc, obj.yc))
elif operation == "rotate270":
tr_geom = sipi.transformer.rotate(geom, np.pi / 2, (obj.xc, obj.yc))
elif operation == "fliph":
tr_geom = sipi.transformer.fliph(geom, obj.xc)
elif operation == "flipv":
tr_geom = sipi.transformer.flipv(geom, obj.yc)
elif operation == "transpose":
tr_geom = sipi.transformer.transpose(geom)
elif operation in IMAGE_OPERATIONS:
tr_geom = sipi.transformer.transform_like_image(geom, src, operation)
else:
raise ValueError(f"Unknown operation: {operation}")

Expand Down Expand Up @@ -132,7 +131,7 @@ def __call__(self, src_obj, param=None):
dst_obj = self.func(src_obj, param)
else:
dst_obj = self.func(src_obj)
apply_geometry_transform(dst_obj, operation=self.operation)
apply_geometry_transform(dst_obj, operation=self.operation, src=src_obj)
return dst_obj

def __getstate__(self):
Expand Down
6 changes: 1 addition & 5 deletions datalab/gui/processor/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -883,11 +883,7 @@ def compute_offset_correction(self, param: ROI2DParam | None = None) -> None:
if exec_dialog(dlg):
x0, y0, x1, y1 = dlg.get_rect_coords()
param = ROI2DParam.create(
geometry="rectangle",
x0=int(x0),
y0=int(y0),
dx=int(x1 - x0),
dy=int(y1 - y0),
geometry="rectangle", x0=x0, y0=y0, dx=x1 - x0, dy=y1 - y0
)
else:
return
Expand Down
107 changes: 87 additions & 20 deletions datalab/tests/features/image/geometry_transform_unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@
from typing import Callable

import numpy as np
import pytest
import sigima.objects as sio
import sigima.params as sip
import sigima.proc.image as sipi
from sigima.tests import data as test_data

from datalab.adapters_metadata.geometry_adapter import GeometryAdapter
from datalab.gui.processor.geometry_postprocess import apply_geometry_transform
from datalab.gui.processor.geometry_postprocess import (
GeometricTransformWrapper,
apply_geometry_transform,
)
from datalab.tests import datalab_test_app_context


Expand Down Expand Up @@ -43,25 +47,30 @@ def __get_geometries(obj: sio.ImageObj) -> list[sio.GeometryResult]:


def __get_expected_geometry_result(
orig_geom: sio.GeometryResult, operation: str
orig_geom: sio.GeometryResult, operation: str, src: sio.ImageObj
) -> sio.GeometryResult:
"""Get expected geometry result for a given operation.

Args:
orig_geom: Original geometry result
operation: Operation name (rotate90, rotate270, etc.)
src: Source image of the operation (uniform coordinates)

Returns:
Expected geometry result after transformation
"""
# For image transformations, rotations should be around the image center
# (300, 300 for our 600x600 test image)
xc, yc = (300.0, 300.0)
# The image operations move the pixel centers: flips mirror the coordinates
# about the center of the pixel grid (299.5, 299.5 for our 600x600 test image)
nrows, ncols = src.data.shape
xc = src.x0 + 0.5 * (ncols - 1) * src.dx
yc = src.y0 + 0.5 * (nrows - 1) * src.dy

if operation == "rotate90":
exp_geom = sipi.transformer.rotate(orig_geom, -np.pi / 2, (xc, yc))
# Counter-clockwise: (x, y) -> (y, 2 * xc - x)
exp_geom = sipi.transformer.flipv(sipi.transformer.transpose(orig_geom), xc)
elif operation == "rotate270":
exp_geom = sipi.transformer.rotate(orig_geom, np.pi / 2, (xc, yc))
# Clockwise: (x, y) -> (2 * yc - y, x)
exp_geom = sipi.transformer.fliph(sipi.transformer.transpose(orig_geom), yc)
elif operation == "fliph":
exp_geom = sipi.transformer.fliph(orig_geom, xc)
elif operation == "flipv":
Expand All @@ -83,6 +92,7 @@ def __validate_geometry_transformation(
tr_geometries: list[sio.GeometryResult],
or_geometries: list[sio.GeometryResult],
operation: str,
src: sio.ImageObj,
test_context: str = "geometry",
) -> None:
"""Validate that geometry transformation was applied correctly.
Expand All @@ -91,11 +101,12 @@ def __validate_geometry_transformation(
tr_geometries: List of transformed geometry results
or_geometries: List of original geometry results
operation: Operation name (rotate90, rotate270, etc.)
src: Source image of the operation
test_context: Context string for error messages (e.g., "App", "Unit")
"""
for i, tr_geom in enumerate(tr_geometries):
original_geom = or_geometries[i]
expected_geom = __get_expected_geometry_result(original_geom, operation)
expected_geom = __get_expected_geometry_result(original_geom, operation, src)

# Compare the actual transformation result with expected
np.testing.assert_allclose(
Expand Down Expand Up @@ -160,7 +171,7 @@ def __validate_roi_transformation(
sio.KindShape.RECTANGLE,
coords=np.asarray([or_roi.get_physical_coords(or_obj)]),
)
expected_geom = __get_expected_geometry_result(temp_geom, operation)
expected_geom = __get_expected_geometry_result(temp_geom, operation, or_obj)
exp_coords = expected_geom.coords[0]
np.testing.assert_allclose(
roi.get_physical_coords(tr_obj),
Expand Down Expand Up @@ -245,20 +256,19 @@ def test_func() -> None:
# (skip geometry transformation for resize and binning as they preserve
# physical coordinates)
if operation not in ["resize", "binning"]:
apply_geometry_transform(tr_obj, operation)
apply_geometry_transform(tr_obj, operation, src=obj)

# Validate basic transformation requirements
__validate_basic_transformation(tr_obj, obj)

# Validate geometry transformation
tr_geometries = __get_geometries(tr_obj)
__validate_geometry_transformation(
tr_geometries, or_geometries, operation, "Unit"
tr_geometries, or_geometries, operation, obj, "Unit"
)

# Validate ROI transformation (only for simple operations)
simple_ops = ["fliph", "flipv", "transpose"]
if operation in simple_ops:
# Validate ROI transformation (only for exact image operations)
if operation not in ["resize", "binning"]:
__validate_roi_transformation(tr_obj, obj, operation)

# Set proper function name and docstring
Expand Down Expand Up @@ -318,12 +328,8 @@ def test_func() -> None:
# Verify that image data is properly transformed
__validate_image_data_transformation(tr_obj, obj, operation)

# For simple transformations, validate ROI transformation
# Skip ROI validation for rotation operations due to inconsistency in Sigima
# library between image data rotation (around image center) and ROI coordinate
# rotation (around ROI center)
simple_ops = ["fliph", "flipv", "transpose"]
if operation in simple_ops:
# For exact image operations, validate ROI transformation
if operation not in ["resize", "binning"]:
__validate_roi_transformation(tr_obj, obj, operation)

# Set proper function name and docstring
Expand All @@ -337,6 +343,67 @@ def test_func() -> None:
return test_func


# =============================================================================
# Results moved with the pixels
# =============================================================================


EXACT_OPERATIONS = ["rotate90", "rotate270", "fliph", "flipv", "transpose"]


def __create_feature_image(uniform: bool) -> sio.ImageObj:
"""Create an image with an off-center peak (pixel at row 11, column 17), on a
non-square grid with an origin offset and anisotropic pixels (or non-uniform
coordinates)."""
nrows, ncols = 40, 64
y, x = np.mgrid[0:nrows, 0:ncols]
data = np.exp(-((x - 17.0) ** 2 / 20.0 + (y - 11.0) ** 2 / 8.0))
obj = sio.create_image("Off-center feature", data)
if uniform:
obj.set_uniform_coords(0.5, 2.0, -3.0, 7.0)
else:
icol, irow = np.arange(ncols), np.arange(nrows)
obj.set_coords(-3.0 + 0.5 * icol + 0.01 * icol**2, 7.0 + 2.0 * irow**1.2)
return obj


def __peak_position(obj: sio.ImageObj) -> tuple[float, float]:
"""Return the physical coordinates of the center of the brightest pixel."""
# pylint: disable-next=unbalanced-tuple-unpacking
row, col = np.unravel_index(np.argmax(obj.data), obj.data.shape)
if obj.is_uniform_coords:
return obj.x0 + col * obj.dx, obj.y0 + row * obj.dy
return obj.xcoords[col], obj.ycoords[row]


@pytest.mark.parametrize("uniform", [True, False], ids=["uniform", "non-uniform"])
@pytest.mark.parametrize("operation", EXACT_OPERATIONS)
def test_transformed_results_follow_the_pixels(operation: str, uniform: bool) -> None:
"""Analysis results moved by an image operation stay on the pixels they
describe."""
src = __create_feature_image(uniform)
peak = sio.GeometryResult(
"Peak", sio.KindShape.POINT, np.array([__peak_position(src)]), func_name="peak"
)
GeometryAdapter(peak).add_to(src)
dst = GeometricTransformWrapper(getattr(sipi, operation), operation)(src)
(moved,) = __get_geometries(dst)
np.testing.assert_allclose(moved.coords[0], __peak_position(dst), atol=1e-12)


@pytest.mark.parametrize("operation", EXACT_OPERATIONS)
def test_transformed_results_match_recomputed_results(operation: str) -> None:
"""Analysis results moved by an image operation are the results of the same
analysis on the transformed image."""
src = __create_feature_image(uniform=True)
GeometryAdapter(sipi.centroid(src)).add_to(src)
dst = GeometricTransformWrapper(getattr(sipi, operation), operation)(src)
(moved,) = __get_geometries(dst)
recomputed = sipi.centroid(dst)
# (the centroid is computed in a way which is sensitive to rounding noise)
np.testing.assert_allclose(moved.coords, recomputed.coords, rtol=0, atol=1e-6)


# =============================================================================
# Parameter Creators
# =============================================================================
Expand Down
33 changes: 33 additions & 0 deletions datalab/tests/features/image/offsetcorrection_app_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@
# pylint: disable=invalid-name # Allows short reference names like x, y, ...
# guitest: show

import numpy as np
import pytest
from sigima.tests.data import get_test_image
from sigima.tests.helpers import rectangle_selection

import datalab.gui.processor.image
from datalab.tests import datalab_test_app_context


Expand All @@ -26,5 +30,34 @@ def test_offset_correction():
win.imagepanel.processor.compute_offset_correction()


def test_offset_correction_calibrated_image(monkeypatch: pytest.MonkeyPatch) -> None:
"""Offset correction of an image whose pixels are much smaller than 1"""
rects = []

def accept_background_area(dlg) -> bool:
"""Accept the background dialog with its default area"""
dlg.test_compute_background()
rects.append(dlg.get_rect_coords())
return True

monkeypatch.setattr(
datalab.gui.processor.image, "exec_dialog", accept_background_area
)
with datalab_test_app_context() as win:
panel = win.imagepanel
ima = get_test_image("flower.npy")
ima.data = np.asarray(ima.data, dtype=float)
ima.set_uniform_coords(0.01, 0.01, -1.0, 2.0)
panel.add_object(ima)
panel.processor.compute_offset_correction()
dst = panel[len(panel)]
assert dst is not ima
# The removed offset is the mean of the pixels of the selected area
x0, y0, x1, y1 = rects[0]
area = ima.data[rectangle_selection(ima, x0, y0, x1 - x0, y1 - y0)]
assert area.size > 0
np.testing.assert_allclose(ima.data - dst.data, area.mean())


if __name__ == "__main__":
test_offset_correction()
21 changes: 21 additions & 0 deletions doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po
Original file line number Diff line number Diff line change
Expand Up @@ -517,3 +517,24 @@ msgstr "Correction de l'incertitude du produit de signaux, qui était perdue là

msgid "Fixed images with non-uniform coordinates losing them when transferred through the Web API (`GET` and `PUT /objects/{name}/data`): they were rebuilt with default uniform coordinates. The NPZ data format now includes these coordinates; archives written by earlier versions remain readable. Clients with their own NPZ reader, such as DataLab-Kernel, must support the new entries to take advantage of this"
msgstr "Correction des images à coordonnées non uniformes qui perdaient celles-ci lors d'un transfert par l'API Web (`GET` et `PUT /objects/{name}/data`) : elles étaient reconstruites avec des coordonnées uniformes par défaut. Le format de données NPZ inclut désormais ces coordonnées ; les archives écrites par les versions antérieures restent lisibles. Les clients disposant de leur propre lecteur NPZ, comme DataLab-Kernel, doivent prendre en charge les nouvelles entrées pour en bénéficier"

msgid "**Rectangular regions of interest:**"
msgstr "**Régions d'intérêt rectangulaires :**"

msgid "A rectangular region of interest now selects the pixels whose centers lie inside the rectangle, bounds included, like circular and polygonal ROIs and like the display: its bounds were rounded to the nearest pixel, which could shift the selection by up to half a pixel. A rectangle whose bounds lie exactly on pixel centers now includes the pixels on both bounds, i.e. one more row and one more column than before. This applies to processing and analysis in ROIs, to ROI extraction and to the background area of the offset correction"
msgstr "Une région d'intérêt rectangulaire sélectionne désormais les pixels dont le centre est situé dans le rectangle, bornes comprises, comme les ROI circulaires et polygonales et comme l'affichage : ses bornes étaient arrondies au pixel le plus proche, ce qui pouvait décaler la sélection jusqu'à un demi-pixel. Un rectangle dont les bornes sont situées exactement sur des centres de pixels inclut désormais les pixels des deux bornes, soit une ligne et une colonne de plus qu'auparavant. Cela s'applique aux traitements et analyses dans les ROI, à l'extraction de ROI et à la zone de fond de la soustraction d'offset"

msgid "A rectangular ROI defined in pixel indices now covers its pixels up to their outer edges, so that it keeps the same pixels when converted to physical coordinates and back, and the cells of ROI grids now share the pixels of the image without overlap nor gap"
msgstr "Une ROI rectangulaire définie en indices de pixels couvre désormais ses pixels jusqu'à leurs bords extérieurs, de sorte qu'elle conserve les mêmes pixels lorsqu'elle est convertie en coordonnées physiques et inversement, et les cellules des grilles de ROI se partagent désormais les pixels de l'image sans chevauchement ni lacune"

msgid "Fixed ROIs, annotations and analysis results (centroids, detected peaks, contours...) being shifted by one pixel after a flip or a 90° rotation of an image; after a 90° rotation of a non-square image, or of an image whose coordinates do not start at zero, analysis results even ended up far from the features they describe. They now move exactly with the pixels, also for images with non-uniform coordinates. ROIs defined in pixel indices are also moved correctly, and inverse ROIs (which mask the inside of their shape) no longer become normal ROIs after these operations, a translation or a distribution of images on a grid"
msgstr "Correction du décalage d'un pixel des ROI, des annotations et des résultats d'analyse (centroïdes, pics détectés, contours...) après une symétrie ou une rotation de 90° d'une image ; après une rotation de 90° d'une image non carrée, ou d'une image dont les coordonnées ne commencent pas à zéro, les résultats d'analyse se retrouvaient même loin des éléments qu'ils décrivent. Ils se déplacent désormais exactement avec les pixels, y compris pour les images à coordonnées non uniformes. Les ROI définies en indices de pixels sont aussi correctement déplacées, et les ROI inverses (qui masquent l'intérieur de leur forme) ne deviennent plus des ROI normales après ces opérations, une translation ou une distribution d'images sur une grille"

msgid "Fixed **ROI > Extract...** giving the extracted image a wrong origin when the coordinates of the source image do not start at zero, and coordinates not matching the extracted pixels for images with non-uniform coordinates"
msgstr "Correction de **ROI > Extraire...**, qui donnait à l'image extraite une origine erronée lorsque les coordonnées de l'image source ne commencent pas à zéro, et des coordonnées ne correspondant pas aux pixels extraits pour les images à coordonnées non uniformes"

msgid "Fixed the coordinates of analysis results computed in a ROI being off by up to half a pixel"
msgstr "Correction des coordonnées des résultats d'analyse calculés dans une ROI, qui pouvaient être fausses jusqu'à un demi-pixel"

msgid "Fixed **Processing > Level adjustment > Offset correction** using a background area truncated to integer coordinates: the area was wrong for images whose coordinates are not in pixels (e.g. in millimeters), and empty when smaller than one unit"
msgstr "Correction de **Traitement > Ajustement de niveau > Soustraction d'offset**, qui utilisait une zone de fond tronquée à des coordonnées entières : la zone était erronée pour les images dont les coordonnées ne sont pas en pixels (par exemple en millimètres), et vide lorsqu'elle mesurait moins d'une unité"
Loading
Loading