From fc92ef733b2d784323e3fada91d04525e63adf90 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:10:18 +0200 Subject: [PATCH 1/3] fix(image): move analysis results exactly with flipped and rotated pixels After a flip, a quarter turn or a transposition, the analysis results of the image (centroids, peaks, contours...) were moved about the center of the result image (xc, yc), with a rotation of +/-pi/2 for quarter turns: this was one pixel off (former xc/yc), wrong by up to the image size for quarter turns of non-square or offset images (44 pixels on a 40x64 image), and wrong for non-uniform coordinates. GeometricTransformWrapper now passes the source image, and apply_geometry_transform moves the results with Sigima's transformer.transform_like_image, i.e. exactly as the pixels. The expected values of the existing tests are corrected (mirror about the center of the pixel grid; quarter turns as transposition + mirror) and ROIs are now also checked for quarter turns. New tests check that a result stays on the pixel it describes (uniform anisotropic offset non-square and non-uniform images) and equals the result recomputed on the transformed image. Assisted-by: Claude Opus 5.5 --- datalab/gui/processor/geometry_postprocess.py | 27 +++-- .../image/geometry_transform_unit_test.py | 107 ++++++++++++++---- 2 files changed, 100 insertions(+), 34 deletions(-) diff --git a/datalab/gui/processor/geometry_postprocess.py b/datalab/gui/processor/geometry_postprocess.py index 02c41f884..c3be752c0 100644 --- a/datalab/gui/processor/geometry_postprocess.py +++ b/datalab/gui/processor/geometry_postprocess.py @@ -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 @@ -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 [ @@ -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" @@ -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}") @@ -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): diff --git a/datalab/tests/features/image/geometry_transform_unit_test.py b/datalab/tests/features/image/geometry_transform_unit_test.py index fb329bf39..8cd73d41d 100644 --- a/datalab/tests/features/image/geometry_transform_unit_test.py +++ b/datalab/tests/features/image/geometry_transform_unit_test.py @@ -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 @@ -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": @@ -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. @@ -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( @@ -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), @@ -245,7 +256,7 @@ 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) @@ -253,12 +264,11 @@ def test_func() -> None: # 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 @@ -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 @@ -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 # ============================================================================= From 8dc72c2fc4d98e9d241e415a319cdd42508316e7 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:10:22 +0200 Subject: [PATCH 2/3] fix(image): use the exact background area in offset correction The background area selected in the offset correction dialog was truncated to integer coordinates before being passed to the computation, a leftover of when ROI2DParam coordinates were integers. For images whose coordinates are not in pixels (e.g. in millimeters), the area was wrong, and empty when smaller than one unit. The rectangle is now passed unchanged. Assisted-by: Claude Opus 5.5 --- datalab/gui/processor/image.py | 6 +--- .../image/offsetcorrection_app_test.py | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/datalab/gui/processor/image.py b/datalab/gui/processor/image.py index 72d54bddf..ffc525a72 100644 --- a/datalab/gui/processor/image.py +++ b/datalab/gui/processor/image.py @@ -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 diff --git a/datalab/tests/features/image/offsetcorrection_app_test.py b/datalab/tests/features/image/offsetcorrection_app_test.py index 885f6484d..cace8f2d5 100644 --- a/datalab/tests/features/image/offsetcorrection_app_test.py +++ b/datalab/tests/features/image/offsetcorrection_app_test.py @@ -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 @@ -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() From 68f3039a4019cddd36eba3cd7755e09543d9e7dd Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:10:24 +0200 Subject: [PATCH 3/3] docs: release notes for the pixel center convention Describe the new selection of rectangular ROIs and the fixes of ROIs, annotations and analysis results after flips and rotations, of extracted ROI coordinates, of analysis results in ROIs and of the offset correction area. Update the French translations. Assisted-by: Claude Opus 5.5 --- .../LC_MESSAGES/release_notes/release_1.04.po | 21 +++++++++++++++++++ doc/release_notes/release_1.04.md | 9 ++++++++ 2 files changed, 30 insertions(+) diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po index a617598fe..9b834c385 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po @@ -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é" diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md index a70bdf6ca..b80902721 100644 --- a/doc/release_notes/release_1.04.md +++ b/doc/release_notes/release_1.04.md @@ -151,6 +151,11 @@ DataLab now uses stable UUID-based object references across object trees, result * Annotation identifiers, lock state, custom metadata and extension data are preserved across edits, while unknown third-party payloads are retained without modification * DataLab now requires Sigima ≥ 1.4.0, SigimaX ≥ 1.1.0 and DataLab-Capsule 0.1.x; HDF5 imports use Sigima's experimental-source API +**Rectangular regions of interest:** + +* 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 +* 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 + **HDF5 browser:** * Browsing an HDF5 file no longer reads its datasets entirely: they are listed and described from their shape and data type, and the value previews read only their first elements. Large files open faster and use less memory; data is read when a signal or an image is previewed or imported @@ -200,6 +205,10 @@ See the [HDF5 browser guide](../features/common/h5browser.rst) for scientific ob * Fixed the extraction of several regions of interest into a single image giving wrong coordinates to images with non-uniform coordinates (the X coordinates were taken from the extracted rows and the Y coordinates from the extracted columns) * The arbitrary rotation of images (**Processing > Geometry > Rotate by...**) now refuses images with non-uniform coordinates, like resizing and resampling, instead of producing an image whose coordinates did not match its pixels * Fixed the uncertainty of the product of signals being lost where one of the signals is zero: it is now propagated there too +* 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 +* 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 +* Fixed the coordinates of analysis results computed in a ROI being off by up to half a pixel +* 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 **Plugins:**