Skip to content

Commit 2b28324

Browse files
committed
Merge branch 'fix/review-2026-10-24' into develop
2 parents bd9291d + ffe7545 commit 2b28324

6 files changed

Lines changed: 164 additions & 39 deletions

File tree

‎datalab/gui/processor/geometry_postprocess.py‎

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@
2020
import importlib
2121
from typing import Literal
2222

23-
import numpy as np
2423
import sigima.proc.image as sipi
2524
from sigima.objects import ImageObj
2625
from sigima.proc.decorator import ComputationMetadata
26+
from sigima.proc.image.transformations import IMAGE_OPERATIONS
2727

2828
# Import GeometryAdapter directly from its module (not from the package __init__)
2929
# to avoid pulling in datalab.adapters_metadata.common -> datalab.config -> PlotPy/Qt
@@ -35,17 +35,22 @@ def apply_geometry_transform(
3535
operation: Literal[
3636
"translate", "scale", "rotate90", "rotate270", "fliph", "flipv", "transpose"
3737
],
38+
src: ImageObj | None = None,
3839
**kwargs,
3940
) -> None:
4041
"""Apply a geometric transformation to all geometry results in an object.
4142
4243
This uses the Sigima transformation system for proper geometric operations.
43-
For image objects, rotations are performed around the image center to match
44-
how the image data is transformed.
44+
For the exact image operations (flips, quarter turns and transposition), the
45+
results move exactly as the pixels of the source image, so that they stay on
46+
the image features they describe.
4547
4648
Args:
4749
obj: The object containing geometry results to transform
4850
operation: The transformation operation name
51+
src: Source image of the exact image operations (``rotate90``,
52+
``rotate270``, ``fliph``, ``flipv`` and ``transpose``), whose pixel grid
53+
defines how the results move (required for these operations)
4954
**kwargs: Optional parameters for the transformation (e.g., angle for rotate)
5055
"""
5156
assert operation in [
@@ -65,6 +70,8 @@ def apply_geometry_transform(
6570
if not kwargs or "sx" not in kwargs or "sy" not in kwargs:
6671
raise ValueError("scale operation requires 'sx' and 'sy' parameters")
6772
sx, sy = kwargs["sx"], kwargs["sy"]
73+
elif src is None:
74+
raise ValueError(f"{operation} operation requires the source image")
6875
for adapter in list(GeometryAdapter.iterate_from_obj(obj)):
6976
geom = adapter.result
7077
assert geom is not None, "Geometry should not be None"
@@ -73,16 +80,8 @@ def apply_geometry_transform(
7380
tr_geom = sipi.transformer.translate(geom, dx, dy)
7481
elif operation == "scale":
7582
tr_geom = sipi.transformer.scale(geom, sx, sy, (obj.xc, obj.yc))
76-
elif operation == "rotate90":
77-
tr_geom = sipi.transformer.rotate(geom, -np.pi / 2, (obj.xc, obj.yc))
78-
elif operation == "rotate270":
79-
tr_geom = sipi.transformer.rotate(geom, np.pi / 2, (obj.xc, obj.yc))
80-
elif operation == "fliph":
81-
tr_geom = sipi.transformer.fliph(geom, obj.xc)
82-
elif operation == "flipv":
83-
tr_geom = sipi.transformer.flipv(geom, obj.yc)
84-
elif operation == "transpose":
85-
tr_geom = sipi.transformer.transpose(geom)
83+
elif operation in IMAGE_OPERATIONS:
84+
tr_geom = sipi.transformer.transform_like_image(geom, src, operation)
8685
else:
8786
raise ValueError(f"Unknown operation: {operation}")
8887

@@ -132,7 +131,7 @@ def __call__(self, src_obj, param=None):
132131
dst_obj = self.func(src_obj, param)
133132
else:
134133
dst_obj = self.func(src_obj)
135-
apply_geometry_transform(dst_obj, operation=self.operation)
134+
apply_geometry_transform(dst_obj, operation=self.operation, src=src_obj)
136135
return dst_obj
137136

138137
def __getstate__(self):

‎datalab/gui/processor/image.py‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -883,11 +883,7 @@ def compute_offset_correction(self, param: ROI2DParam | None = None) -> None:
883883
if exec_dialog(dlg):
884884
x0, y0, x1, y1 = dlg.get_rect_coords()
885885
param = ROI2DParam.create(
886-
geometry="rectangle",
887-
x0=int(x0),
888-
y0=int(y0),
889-
dx=int(x1 - x0),
890-
dy=int(y1 - y0),
886+
geometry="rectangle", x0=x0, y0=y0, dx=x1 - x0, dy=y1 - y0
891887
)
892888
else:
893889
return

‎datalab/tests/features/image/geometry_transform_unit_test.py‎

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@
88
from typing import Callable
99

1010
import numpy as np
11+
import pytest
1112
import sigima.objects as sio
1213
import sigima.params as sip
1314
import sigima.proc.image as sipi
1415
from sigima.tests import data as test_data
1516

1617
from datalab.adapters_metadata.geometry_adapter import GeometryAdapter
17-
from datalab.gui.processor.geometry_postprocess import apply_geometry_transform
18+
from datalab.gui.processor.geometry_postprocess import (
19+
GeometricTransformWrapper,
20+
apply_geometry_transform,
21+
)
1822
from datalab.tests import datalab_test_app_context
1923

2024

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

4448

4549
def __get_expected_geometry_result(
46-
orig_geom: sio.GeometryResult, operation: str
50+
orig_geom: sio.GeometryResult, operation: str, src: sio.ImageObj
4751
) -> sio.GeometryResult:
4852
"""Get expected geometry result for a given operation.
4953
5054
Args:
5155
orig_geom: Original geometry result
5256
operation: Operation name (rotate90, rotate270, etc.)
57+
src: Source image of the operation (uniform coordinates)
5358
5459
Returns:
5560
Expected geometry result after transformation
5661
"""
57-
# For image transformations, rotations should be around the image center
58-
# (300, 300 for our 600x600 test image)
59-
xc, yc = (300.0, 300.0)
62+
# The image operations move the pixel centers: flips mirror the coordinates
63+
# about the center of the pixel grid (299.5, 299.5 for our 600x600 test image)
64+
nrows, ncols = src.data.shape
65+
xc = src.x0 + 0.5 * (ncols - 1) * src.dx
66+
yc = src.y0 + 0.5 * (nrows - 1) * src.dy
6067

6168
if operation == "rotate90":
62-
exp_geom = sipi.transformer.rotate(orig_geom, -np.pi / 2, (xc, yc))
69+
# Counter-clockwise: (x, y) -> (y, 2 * xc - x)
70+
exp_geom = sipi.transformer.flipv(sipi.transformer.transpose(orig_geom), xc)
6371
elif operation == "rotate270":
64-
exp_geom = sipi.transformer.rotate(orig_geom, np.pi / 2, (xc, yc))
72+
# Clockwise: (x, y) -> (2 * yc - y, x)
73+
exp_geom = sipi.transformer.fliph(sipi.transformer.transpose(orig_geom), yc)
6574
elif operation == "fliph":
6675
exp_geom = sipi.transformer.fliph(orig_geom, xc)
6776
elif operation == "flipv":
@@ -83,6 +92,7 @@ def __validate_geometry_transformation(
8392
tr_geometries: list[sio.GeometryResult],
8493
or_geometries: list[sio.GeometryResult],
8594
operation: str,
95+
src: sio.ImageObj,
8696
test_context: str = "geometry",
8797
) -> None:
8898
"""Validate that geometry transformation was applied correctly.
@@ -91,11 +101,12 @@ def __validate_geometry_transformation(
91101
tr_geometries: List of transformed geometry results
92102
or_geometries: List of original geometry results
93103
operation: Operation name (rotate90, rotate270, etc.)
104+
src: Source image of the operation
94105
test_context: Context string for error messages (e.g., "App", "Unit")
95106
"""
96107
for i, tr_geom in enumerate(tr_geometries):
97108
original_geom = or_geometries[i]
98-
expected_geom = __get_expected_geometry_result(original_geom, operation)
109+
expected_geom = __get_expected_geometry_result(original_geom, operation, src)
99110

100111
# Compare the actual transformation result with expected
101112
np.testing.assert_allclose(
@@ -160,7 +171,7 @@ def __validate_roi_transformation(
160171
sio.KindShape.RECTANGLE,
161172
coords=np.asarray([or_roi.get_physical_coords(or_obj)]),
162173
)
163-
expected_geom = __get_expected_geometry_result(temp_geom, operation)
174+
expected_geom = __get_expected_geometry_result(temp_geom, operation, or_obj)
164175
exp_coords = expected_geom.coords[0]
165176
np.testing.assert_allclose(
166177
roi.get_physical_coords(tr_obj),
@@ -245,20 +256,19 @@ def test_func() -> None:
245256
# (skip geometry transformation for resize and binning as they preserve
246257
# physical coordinates)
247258
if operation not in ["resize", "binning"]:
248-
apply_geometry_transform(tr_obj, operation)
259+
apply_geometry_transform(tr_obj, operation, src=obj)
249260

250261
# Validate basic transformation requirements
251262
__validate_basic_transformation(tr_obj, obj)
252263

253264
# Validate geometry transformation
254265
tr_geometries = __get_geometries(tr_obj)
255266
__validate_geometry_transformation(
256-
tr_geometries, or_geometries, operation, "Unit"
267+
tr_geometries, or_geometries, operation, obj, "Unit"
257268
)
258269

259-
# Validate ROI transformation (only for simple operations)
260-
simple_ops = ["fliph", "flipv", "transpose"]
261-
if operation in simple_ops:
270+
# Validate ROI transformation (only for exact image operations)
271+
if operation not in ["resize", "binning"]:
262272
__validate_roi_transformation(tr_obj, obj, operation)
263273

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

321-
# For simple transformations, validate ROI transformation
322-
# Skip ROI validation for rotation operations due to inconsistency in Sigima
323-
# library between image data rotation (around image center) and ROI coordinate
324-
# rotation (around ROI center)
325-
simple_ops = ["fliph", "flipv", "transpose"]
326-
if operation in simple_ops:
331+
# For exact image operations, validate ROI transformation
332+
if operation not in ["resize", "binning"]:
327333
__validate_roi_transformation(tr_obj, obj, operation)
328334

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

339345

346+
# =============================================================================
347+
# Results moved with the pixels
348+
# =============================================================================
349+
350+
351+
EXACT_OPERATIONS = ["rotate90", "rotate270", "fliph", "flipv", "transpose"]
352+
353+
354+
def __create_feature_image(uniform: bool) -> sio.ImageObj:
355+
"""Create an image with an off-center peak (pixel at row 11, column 17), on a
356+
non-square grid with an origin offset and anisotropic pixels (or non-uniform
357+
coordinates)."""
358+
nrows, ncols = 40, 64
359+
y, x = np.mgrid[0:nrows, 0:ncols]
360+
data = np.exp(-((x - 17.0) ** 2 / 20.0 + (y - 11.0) ** 2 / 8.0))
361+
obj = sio.create_image("Off-center feature", data)
362+
if uniform:
363+
obj.set_uniform_coords(0.5, 2.0, -3.0, 7.0)
364+
else:
365+
icol, irow = np.arange(ncols), np.arange(nrows)
366+
obj.set_coords(-3.0 + 0.5 * icol + 0.01 * icol**2, 7.0 + 2.0 * irow**1.2)
367+
return obj
368+
369+
370+
def __peak_position(obj: sio.ImageObj) -> tuple[float, float]:
371+
"""Return the physical coordinates of the center of the brightest pixel."""
372+
# pylint: disable-next=unbalanced-tuple-unpacking
373+
row, col = np.unravel_index(np.argmax(obj.data), obj.data.shape)
374+
if obj.is_uniform_coords:
375+
return obj.x0 + col * obj.dx, obj.y0 + row * obj.dy
376+
return obj.xcoords[col], obj.ycoords[row]
377+
378+
379+
@pytest.mark.parametrize("uniform", [True, False], ids=["uniform", "non-uniform"])
380+
@pytest.mark.parametrize("operation", EXACT_OPERATIONS)
381+
def test_transformed_results_follow_the_pixels(operation: str, uniform: bool) -> None:
382+
"""Analysis results moved by an image operation stay on the pixels they
383+
describe."""
384+
src = __create_feature_image(uniform)
385+
peak = sio.GeometryResult(
386+
"Peak", sio.KindShape.POINT, np.array([__peak_position(src)]), func_name="peak"
387+
)
388+
GeometryAdapter(peak).add_to(src)
389+
dst = GeometricTransformWrapper(getattr(sipi, operation), operation)(src)
390+
(moved,) = __get_geometries(dst)
391+
np.testing.assert_allclose(moved.coords[0], __peak_position(dst), atol=1e-12)
392+
393+
394+
@pytest.mark.parametrize("operation", EXACT_OPERATIONS)
395+
def test_transformed_results_match_recomputed_results(operation: str) -> None:
396+
"""Analysis results moved by an image operation are the results of the same
397+
analysis on the transformed image."""
398+
src = __create_feature_image(uniform=True)
399+
GeometryAdapter(sipi.centroid(src)).add_to(src)
400+
dst = GeometricTransformWrapper(getattr(sipi, operation), operation)(src)
401+
(moved,) = __get_geometries(dst)
402+
recomputed = sipi.centroid(dst)
403+
# (the centroid is computed in a way which is sensitive to rounding noise)
404+
np.testing.assert_allclose(moved.coords, recomputed.coords, rtol=0, atol=1e-6)
405+
406+
340407
# =============================================================================
341408
# Parameter Creators
342409
# =============================================================================

‎datalab/tests/features/image/offsetcorrection_app_test.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@
1414
# pylint: disable=invalid-name # Allows short reference names like x, y, ...
1515
# guitest: show
1616

17+
import numpy as np
18+
import pytest
1719
from sigima.tests.data import get_test_image
20+
from sigima.tests.helpers import rectangle_selection
1821

22+
import datalab.gui.processor.image
1923
from datalab.tests import datalab_test_app_context
2024

2125

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

2832

33+
def test_offset_correction_calibrated_image(monkeypatch: pytest.MonkeyPatch) -> None:
34+
"""Offset correction of an image whose pixels are much smaller than 1"""
35+
rects = []
36+
37+
def accept_background_area(dlg) -> bool:
38+
"""Accept the background dialog with its default area"""
39+
dlg.test_compute_background()
40+
rects.append(dlg.get_rect_coords())
41+
return True
42+
43+
monkeypatch.setattr(
44+
datalab.gui.processor.image, "exec_dialog", accept_background_area
45+
)
46+
with datalab_test_app_context() as win:
47+
panel = win.imagepanel
48+
ima = get_test_image("flower.npy")
49+
ima.data = np.asarray(ima.data, dtype=float)
50+
ima.set_uniform_coords(0.01, 0.01, -1.0, 2.0)
51+
panel.add_object(ima)
52+
panel.processor.compute_offset_correction()
53+
dst = panel[len(panel)]
54+
assert dst is not ima
55+
# The removed offset is the mean of the pixels of the selected area
56+
x0, y0, x1, y1 = rects[0]
57+
area = ima.data[rectangle_selection(ima, x0, y0, x1 - x0, y1 - y0)]
58+
assert area.size > 0
59+
np.testing.assert_allclose(ima.data - dst.data, area.mean())
60+
61+
2962
if __name__ == "__main__":
3063
test_offset_correction()

‎doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,3 +517,24 @@ msgstr "Correction de l'incertitude du produit de signaux, qui était perdue là
517517

518518
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"
519519
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"
520+
521+
msgid "**Rectangular regions of interest:**"
522+
msgstr "**Régions d'intérêt rectangulaires :**"
523+
524+
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"
525+
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"
526+
527+
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"
528+
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"
529+
530+
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"
531+
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"
532+
533+
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"
534+
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"
535+
536+
msgid "Fixed the coordinates of analysis results computed in a ROI being off by up to half a pixel"
537+
msgstr "Correction des coordonnées des résultats d'analyse calculés dans une ROI, qui pouvaient être fausses jusqu'à un demi-pixel"
538+
539+
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"
540+
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é"

0 commit comments

Comments
 (0)