diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a515bb..30c5aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) - **Data locality:** the README now states precisely what stays local: processing never requires sending data anywhere, and the network services that can be enabled (AI assistant provider, capsule registry) are named. - **Image regions of interest and coordinates:** image computations now follow Sigima 1.4 (development snapshot until its release). A region of interest selects the pixels whose centers lie inside its shape, boundary included: a rectangle whose bounds lie on pixel centers now includes one more row and column, and polygon vertices are no longer rounded to whole pixels. ROI grids are made of whole pixels, the center of an image (e.g. the default center of a radial profile) is the center of its pixel grid, and flips and rotations move regions of interest, annotations and analysis results with the pixels. Regions of interest saved earlier are not converted: they select pixels according to these rules when they are used again. +- **Image profiles in physical coordinates:** line, segment, average and radial profiles are now defined in the physical coordinates of the image, like regions of interest and analysis results, instead of pixel indices (Sigima 1.4). The profile dialog places its shape on the pixel centers used by the computation, and the X axis of the resulting signals holds physical coordinates or distances with the unit of the image axes. The radial profile is circular on images with rectangular pixels or non-uniform coordinates; segment and radial profiles are refused when the X and Y units of the image differ, and coordinates outside the image are refused instead of being moved to its edge. Scripts and macros must use the new parameter names (`y`/`x`, `x1`/`y1`/`x2`/`y2`, `xc`/`yc`); profile computations recorded in earlier workspaces are reported as not verifiable instead of being replayed. On images without calibration, results are unchanged. ## [0.10.0] - 2026-10-20 diff --git a/datalab-capsule-dependency.json b/datalab-capsule-dependency.json index 2541e77..d44ab5b 100644 --- a/datalab-capsule-dependency.json +++ b/datalab-capsule-dependency.json @@ -1,4 +1,4 @@ { "publishedRequirement": "datalab-capsule==0.1.0", - "developmentRef": null + "developmentRef": "3fd6fbf9c1a2fe90674032e021db39aa8af18164" } diff --git a/sigima-dependency.json b/sigima-dependency.json index f27713b..5c04ddf 100644 --- a/sigima-dependency.json +++ b/sigima-dependency.json @@ -1,4 +1,4 @@ { "publishedRequirement": "sigima==1.3.0", - "developmentRef": "6c9255ae81a3c188981cd90f335a732d13a1a97f" + "developmentRef": "3af8228e8bb6473c5de6b7df08d9f57f397abca2" } diff --git a/src/App.tsx b/src/App.tsx index 8e5ba8b..ac33609 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2052,8 +2052,8 @@ export default function App() { const handleExtractProfile = useCallback( async (params: { direction: "horizontal" | "vertical"; - row: number; - col: number; + x: number; + y: number; }) => { if (!runtime || !currentId) return; const feature = features.find((f) => f.id === "image:line_profile"); @@ -2064,8 +2064,8 @@ export default function App() { const values: Record = { ...(schema?.values ?? {}), direction: params.direction, - row: params.row, - col: params.col, + x: params.x, + y: params.y, }; await runFeature(feature, [currentId], null, values, effectiveGroupIds()); }, diff --git a/src/components/ImagePlot.tsx b/src/components/ImagePlot.tsx index 8088bfd..5c2e57a 100644 --- a/src/components/ImagePlot.tsx +++ b/src/components/ImagePlot.tsx @@ -41,6 +41,7 @@ import { } from "../utils/imageRaster"; import { toBins, binSearchCell } from "../utils/imageCoords"; import { relayoutViewRange } from "../utils/plotlyRelayout"; +import { pixelCenters } from "../utils/pixelCenters"; import { usePersistedBool, IMAGE_GRID_PREF_KEY } from "../utils/persisted"; import type { AnalysisResult, @@ -125,13 +126,15 @@ interface ImagePlotProps { onResampleChange?: (method: ResampleMethod) => void; /** Called when the user clicks one of the "extract profile" buttons next * to a frozen cross-section preview. ``direction`` selects the horizontal - * (top preview, fixed ``row``) or vertical (right preview, fixed ``col``) - * slice. The host wires this to the Sigima ``line_profile`` feature so a - * new signal is created, mirroring DataLab desktop's "Process signal". */ + * (top preview, fixed ``y``) or vertical (right preview, fixed ``x``) + * slice; ``x``/``y`` are the physical coordinates of the center of the + * pixel under the cursor (Sigima convention). The host wires this to the + * Sigima ``line_profile`` feature so a new signal is created, mirroring + * DataLab desktop's "Process signal". */ onExtractProfile?: (params: { direction: "horizontal" | "vertical"; - row: number; - col: number; + x: number; + y: number; }) => void; } @@ -1650,8 +1653,8 @@ export function ImagePlot({ onClick={() => onExtractProfile({ direction: "horizontal", - row: profileData.row, - col: profileData.col, + x: pixelCenters(data, "x")[profileData.col], + y: pixelCenters(data, "y")[profileData.row], }) } > @@ -1715,8 +1718,8 @@ export function ImagePlot({ onClick={() => onExtractProfile({ direction: "vertical", - row: profileData.row, - col: profileData.col, + x: pixelCenters(data, "x")[profileData.col], + y: pixelCenters(data, "y")[profileData.row], }) } > diff --git a/src/components/ProfileDefinitionDialog.tsx b/src/components/ProfileDefinitionDialog.tsx index 445a99a..860d002 100644 --- a/src/components/ProfileDefinitionDialog.tsx +++ b/src/components/ProfileDefinitionDialog.tsx @@ -14,8 +14,9 @@ * * * editing values in the form moves the shape; * * dragging the shape on the plot updates the matching values - * (``row``/``col`` for ``line_profile``, ``row1``/``col1``/``row2``/ - * ``col2`` for ``segment_profile`` and ``average_profile``). + * (``y``/``x`` for ``line_profile``, ``x1``/``y1``/``x2``/``y2`` for + * ``segment_profile`` and ``average_profile``), in the physical + * coordinates of the image, as Sigima expects them. * * On ``OK`` the dialog returns the same ``Record`` of * values that :class:`DataSetDialog` would, so the surrounding @@ -38,6 +39,7 @@ import { ProcessingPreview, type ProcessingPreviewHandle, } from "./ProcessingPreview"; +import { pixelCenters } from "../utils/pixelCenters"; export type ProfileFeatureId = "line_profile" | "segment_profile" | "average_profile"; @@ -70,20 +72,38 @@ interface Props { } // --------------------------------------------------------------------------- -// Pixel ⇄ physical coordinate conversion +// Image geometry (Sigima convention: ``x0``/``y0`` are the center of the +// first pixel, ``xcoords``/``ycoords`` the pixel centers of non-uniform images) // --------------------------------------------------------------------------- -const pixToX = (img: ImageData, col: number) => img.x0 + (col + 0.5) * img.dx; -const pixToY = (img: ImageData, row: number) => img.y0 + (row + 0.5) * img.dy; -const xToPix = (img: ImageData, x: number) => - Math.max(0, Math.min(img.width - 1, Math.round((x - img.x0) / img.dx - 0.5))); -const yToPix = (img: ImageData, y: number) => - Math.max( - 0, - Math.min(img.height - 1, Math.round((y - img.y0) / img.dy - 0.5)), - ); +/** Range of the pixel centers and outer edges of the pixels along an axis. */ +function axisExtent(img: ImageData, axis: "x" | "y") { + const c = pixelCenters(img, axis); + const first = c[0]; + const last = c[c.length - 1]; + const step = axis === "x" ? img.dx : img.dy; + const h0 = c.length > 1 ? (c[1] - c[0]) / 2 : step / 2; + const h1 = c.length > 1 ? (c[c.length - 1] - c[c.length - 2]) / 2 : step / 2; + return { + cmin: Math.min(first, last), + cmax: Math.max(first, last), + emin: Math.min(first - h0, last + h1), + emax: Math.max(first - h0, last + h1), + }; +} + +/** Bring a coordinate back to the range of the pixel centers. */ +const clipToCenters = (img: ImageData, axis: "x" | "y", v: number) => { + const { cmin, cmax } = axisExtent(img, axis); + return Math.max(cmin, Math.min(cmax, v)); +}; + +/** Numeric value of an optional parameter (``None`` → *fallback*). */ +const num = (v: unknown, fallback: number) => + v === null || v === undefined || v === "" ? fallback : Number(v); + // --------------------------------------------------------------------------- -// Profile shape derivation +// Profile shape derivation (profile parameters are physical coordinates) // --------------------------------------------------------------------------- type PlotlyShape = Record; @@ -98,75 +118,64 @@ const SHAPE_RECT = { editable: true, } as const; -function buildShape( +export function buildShape( feature: ProfileFeatureId, values: Record, img: ImageData, ): PlotlyShape | null { - const xMin = img.x0; - const xMax = img.x0 + img.width * img.dx; - const yMin = img.y0; - const yMax = img.y0 + img.height * img.dy; + const xe = axisExtent(img, "x"); + const ye = axisExtent(img, "y"); + const xMid = (xe.cmin + xe.cmax) / 2; + const yMid = (ye.cmin + ye.cmax) / 2; if (feature === "line_profile") { const direction = String(values.direction ?? "horizontal"); if (direction === "horizontal") { - const row = Number(values.row ?? 0); - const y = pixToY(img, row); + const y = num(values.y, yMid); return { type: "line", xref: "x", yref: "y", - x0: xMin, - x1: xMax, + x0: xe.emin, + x1: xe.emax, y0: y, y1: y, ...SHAPE_LINE, }; } - const col = Number(values.col ?? 0); - const x = pixToX(img, col); + const x = num(values.x, xMid); return { type: "line", xref: "x", yref: "y", x0: x, x1: x, - y0: yMin, - y1: yMax, + y0: ye.emin, + y1: ye.emax, ...SHAPE_LINE, }; } if (feature === "segment_profile") { - const r1 = Number(values.row1 ?? 0); - const c1 = Number(values.col1 ?? 0); - const r2 = Number(values.row2 ?? 0); - const c2 = Number(values.col2 ?? 0); return { type: "line", xref: "x", yref: "y", - x0: pixToX(img, c1), - y0: pixToY(img, r1), - x1: pixToX(img, c2), - y1: pixToY(img, r2), + x0: num(values.x1, xe.cmin), + y0: num(values.y1, yMid), + x1: num(values.x2, xe.cmax), + y1: num(values.y2, yMid), ...SHAPE_LINE, }; } if (feature === "average_profile") { - const r1 = Number(values.row1 ?? 0); - const c1 = Number(values.col1 ?? 0); - let r2 = Number(values.row2 ?? -1); - let c2 = Number(values.col2 ?? -1); - if (r2 < 0) r2 = img.height - 1; - if (c2 < 0) c2 = img.width - 1; + // Undefined bounds (``None``) stand for the edges of the image return { type: "rect", xref: "x", yref: "y", - x0: pixToX(img, c1) - img.dx / 2, - y0: pixToY(img, r1) - img.dy / 2, - x1: pixToX(img, c2) + img.dx / 2, - y1: pixToY(img, r2) + img.dy / 2, + x0: num(values.x1, xe.emin), + y0: num(values.y1, ye.emin), + x1: num(values.x2, xe.emax), + y1: num(values.y2, ye.emax), ...SHAPE_RECT, }; } @@ -177,7 +186,7 @@ function buildShape( // Shape ⇄ values (relayout handler) // --------------------------------------------------------------------------- -function shapeToValues( +export function shapeToValues( feature: ProfileFeatureId, values: Record, img: ImageData, @@ -200,34 +209,31 @@ function shapeToValues( if (direction === "horizontal") { // Use the average of y0/y1 (Plotly returns identical values for a // horizontal line, but be defensive). - const row = yToPix(img, (y0 + y1) / 2); - if (row === values.row) return null; - return { ...values, row }; + const y = clipToCenters(img, "y", (y0 + y1) / 2); + if (y === values.y) return null; + return { ...values, y }; } - const col = xToPix(img, (x0 + x1) / 2); - if (col === values.col) return null; - return { ...values, col }; + const x = clipToCenters(img, "x", (x0 + x1) / 2); + if (x === values.x) return null; + return { ...values, x }; } if (feature === "segment_profile") { - const row1 = yToPix(img, y0); - const col1 = xToPix(img, x0); - const row2 = yToPix(img, y1); - const col2 = xToPix(img, x1); - return { ...values, row1, col1, row2, col2 }; + // Points beyond the image are brought back to the outermost pixels + return { + ...values, + x1: clipToCenters(img, "x", x0), + y1: clipToCenters(img, "y", y0), + x2: clipToCenters(img, "x", x1), + y2: clipToCenters(img, "y", y1), + }; } if (feature === "average_profile") { - // The shape is rendered with a half-pixel padding on each side; undo - // it before re-quantising back to pixel indices. - const row1 = yToPix(img, y0 + img.dy / 2); - const col1 = xToPix(img, x0 + img.dx / 2); - const row2 = yToPix(img, y1 - img.dy / 2); - const col2 = xToPix(img, x1 - img.dx / 2); return { ...values, - row1: Math.min(row1, row2), - col1: Math.min(col1, col2), - row2: Math.max(row1, row2), - col2: Math.max(col1, col2), + x1: Math.min(x0, x1), + y1: Math.min(y0, y1), + x2: Math.max(x0, x1), + y2: Math.max(y0, y1), }; } return null; @@ -296,16 +302,8 @@ export function ProfileDefinitionDialog(props: Props) { string | undefined; const traces = useMemo(() => { - const w = imageData.width; - const h = imageData.height; - const xs = Array.from( - { length: w }, - (_, i) => imageData.x0 + (i + 0.5) * imageData.dx, - ); - const ys = Array.from( - { length: h }, - (_, j) => imageData.y0 + (j + 0.5) * imageData.dy, - ); + const xs = pixelCenters(imageData, "x"); + const ys = pixelCenters(imageData, "y"); return [ { type: "heatmap" as const, diff --git a/src/runtime/bootstrap.py b/src/runtime/bootstrap.py index 9bd6364..0ea02ea 100644 --- a/src/runtime/bootstrap.py +++ b/src/runtime/bootstrap.py @@ -4852,7 +4852,7 @@ def get_feature_schema( When *source_oid* is given, the source object is bound to the parameter instance (via ``update_from_obj``) before serialisation so params that derive defaults from the source (e.g. ``RadialProfileParam`` pre-filling - ``x0``/``y0`` with the image centre) match DataLab desktop. + ``xc``/``yc`` with the image centre) match DataLab desktop. """ source_obj = _MODEL.get(source_oid) if source_oid else None return _proc.get_schema(_full_catalog_with_plugins(), feature_id, source_obj) @@ -4881,7 +4881,7 @@ def resolve_feature_callbacks( Returns the recomputed values for every parameter so the frontend can refresh read-only computed fields (e.g. ``ArithmeticParam.operation``, - or ``RadialProfileParam``'s ``x0``/``y0`` recomputed from the chosen + or ``RadialProfileParam``'s ``xc``/``yc`` recomputed from the chosen centre mode when *source_oid* is provided). """ source_obj = _MODEL.get(source_oid) if source_oid else None diff --git a/src/runtime/processor.py b/src/runtime/processor.py index a8c3f6d..8c581fe 100644 --- a/src/runtime/processor.py +++ b/src/runtime/processor.py @@ -1385,7 +1385,7 @@ def _bind_source_obj(instance: Any, source_obj: Any | None) -> None: Mirrors DataLab desktop, where the processor calls ``param.update_from_obj(obj)`` before showing the dialog so parameters that derive defaults from the source object (e.g. - ``RadialProfileParam`` pre-filling ``x0``/``y0`` with the image centre + ``RadialProfileParam`` pre-filling ``xc``/``yc`` with the image centre and recomputing them in its ``choice_callback``) behave correctly. No-op for parameter classes without ``update_from_obj``. """ @@ -1451,7 +1451,7 @@ def resolve_callbacks( Returns the post-callback values for every item so the frontend can refresh read-only computed fields (e.g. ``ArithmeticParam.operation``, - or ``RadialProfileParam``'s ``x0``/``y0`` recomputed from the chosen + or ``RadialProfileParam``'s ``xc``/``yc`` recomputed from the chosen centre mode). """ spec = catalog[feature_id] diff --git a/src/utils/pixelCenters.ts b/src/utils/pixelCenters.ts new file mode 100644 index 0000000..d7c24f4 --- /dev/null +++ b/src/utils/pixelCenters.ts @@ -0,0 +1,35 @@ +/** + * Pixel centers of an image, following the Sigima convention: ``x0``/``y0`` + * are the physical coordinates of the center of the first pixel, and images + * with non-uniform coordinates carry the centers in ``xcoords``/``ycoords``. + * + * Sigima computations (profiles, ROIs, analyses) use these coordinates. + */ + +import type { ImageData } from "../runtime/runtime"; + +/** Physical coordinates of the pixel centers along an axis. */ +export function pixelCenters( + img: Pick< + ImageData, + | "width" + | "height" + | "x0" + | "y0" + | "dx" + | "dy" + | "is_uniform_coords" + | "xcoords" + | "ycoords" + >, + axis: "x" | "y", +): number[] { + const coords = axis === "x" ? img.xcoords : img.ycoords; + const size = axis === "x" ? img.width : img.height; + if (img.is_uniform_coords === false && coords?.length === size) { + return coords; + } + const origin = axis === "x" ? img.x0 : img.y0; + const step = axis === "x" ? img.dx : img.dy; + return Array.from({ length: size }, (_, i) => origin + i * step); +} diff --git a/tests/e2e/provenance_families.spec.ts b/tests/e2e/provenance_families.spec.ts index 1751f98..20d2483 100644 --- a/tests/e2e/provenance_families.spec.ts +++ b/tests/e2e/provenance_families.spec.ts @@ -54,6 +54,8 @@ const FAMILIES = [ interface Replay { operation: string; + /** Contract version recorded by the activity. */ + contractVersion: number | null; verdict: string; eligibility: string; maxAbsError: number | null; @@ -79,7 +81,7 @@ async function runFamilies( interface Activity { activity_id: string; edition: string; - call: { operation: { id: string } | null }; + call: { operation: { id: string; contract_version: number } | null }; outputs: unknown[]; } interface Runtime { @@ -170,7 +172,7 @@ async function runFamilies( }); await runtime.applyFeature("image:line_profile", [image], null, { direction: "horizontal", - row: 1, + y: 1, }); await runtime.runImageAnalysis(image, "centroid"); const kernel = await runtime.addImageFromArray({ @@ -209,6 +211,7 @@ async function runFamilies( const report = await runtime.replayActivity(activity.activity_id); replays.push({ operation: activity.call.operation?.id ?? "", + contractVersion: activity.call.operation?.contract_version ?? null, verdict: report.verdict, eligibility: report.eligibility, maxAbsError: report.observed?.max_abs_error ?? null, @@ -295,10 +298,25 @@ test.describe.serial("cross-edition families", () => { const bytes = Array.from( readFileSync(path.join(FIXTURES, `${edition}_families.h5`)), ); + // Contract versions of this runtime, as recorded by a fresh run: an + // activity recorded before a change of the result of its operation has + // an older version, and is refused instead of being replayed. + const fresh = await runFamilies(reader, null); + requireProvenance(fresh.available); + const local = new Map( + fresh.replays.map((r) => [r.operation, r.contractVersion]), + ); const run = await runFamilies(reader, bytes); requireProvenance(run.available); expectFamilies(run, edition); for (const replay of run.replays) { + if (replay.contractVersion !== local.get(replay.operation)) { + expect(replay.eligibility, replay.operation).toBe( + "unsupported_contract", + ); + expect(replay.verdict, replay.operation).toBe("not_verified"); + continue; + } expect(replay.eligibility, replay.operation).toBe("ready"); expect(["exact", "within_tolerance"], replay.operation).toContain( replay.verdict, diff --git a/tests/fixtures/provenance/desktop_families.h5 b/tests/fixtures/provenance/desktop_families.h5 index 3c5cc17..fd172fe 100644 Binary files a/tests/fixtures/provenance/desktop_families.h5 and b/tests/fixtures/provenance/desktop_families.h5 differ diff --git a/tests/python/test_preview_runtime.py b/tests/python/test_preview_runtime.py index 4e6c95d..63974da 100644 --- a/tests/python/test_preview_runtime.py +++ b/tests/python/test_preview_runtime.py @@ -207,7 +207,7 @@ def test_image_to_signal_preview_matches_published_processing(fresh_bootstrap): source_id = bs.add_image_from_array("raw", data, width=14, height=9) image_tree_before = bs.get_panel_tree("image") signal_tree_before = bs.get_panel_tree("signal") - params = {"direction": "horizontal", "row": 4, "col": 0} + params = {"direction": "horizontal", "y": 4.0, "x": 0.0} temporary = bs.preview_feature( "image:line_profile", diff --git a/tests/python/test_processings.py b/tests/python/test_processings.py index 283a95d..2e2cfd2 100644 --- a/tests/python/test_processings.py +++ b/tests/python/test_processings.py @@ -525,20 +525,20 @@ def test_radial_profile_schema_binds_source_object(fresh_bootstrap): # Off-centre bright blob so the centroid is well-defined and non-zero. data[10:16, 4:10] = 1000.0 src = bs.add_image_from_array("img", data, width=48, height=64) - # Without binding: the callback no-ops, so x0/y0 stay at their defaults. + # Without binding: the callback no-ops, so xc/yc stay at their defaults. bare = bs.get_feature_schema("image:radial_profile") - assert bare["values"]["x0"] == 0.0 - assert bare["values"]["y0"] == 0.0 + assert bare["values"]["xc"] == 0.0 + assert bare["values"]["yc"] == 0.0 # With binding: the default "centroid" mode resolves to the blob centroid. bound = bs.get_feature_schema("image:radial_profile", src) - assert bound["values"]["x0"] > 0.0 - assert bound["values"]["y0"] > 0.0 - assert bound["values"]["x0"] < 24.0 # left of the geometric centre - assert bound["values"]["y0"] < 32.0 # above the geometric centre + assert bound["values"]["xc"] > 0.0 + assert bound["values"]["yc"] > 0.0 + assert bound["values"]["xc"] < 24.0 # left of the geometric centre + assert bound["values"]["yc"] < 32.0 # above the geometric centre def test_radial_profile_callback_recomputes_centre(fresh_bootstrap): - """Switching the centre mode recomputes ``x0``/``y0`` via the bound + """Switching the centre mode recomputes ``xc``/``yc`` via the bound source object, mirroring ``RadialProfileParam.choice_callback``. """ bs = fresh_bootstrap @@ -546,24 +546,24 @@ def test_radial_profile_callback_recomputes_centre(fresh_bootstrap): # Off-centre bright blob so the centroid differs from the geometric centre. data[10:16, 4:10] = 1000.0 src = bs.add_image_from_array("img", data, width=48, height=64) - # "center" mode resets x0/y0 to the geometric centre even from stale 0,0. + # "center" mode resets xc/yc to the geometric centre even from stale 0,0. # Pixels are centred on their coordinates (0 to 47 along X, 0 to 63 along # Y): the centre of the image lies halfway between its first and last pixel # centres, so that a radial profile of a symmetric image is symmetric. centred = bs.resolve_feature_callbacks( "image:radial_profile", "center", - {"center": "center", "x0": 0.0, "y0": 0.0}, + {"center": "center", "xc": 0.0, "yc": 0.0}, src, ) - assert centred["x0"] == pytest.approx(23.5) - assert centred["y0"] == pytest.approx(31.5) + assert centred["xc"] == pytest.approx(23.5) + assert centred["yc"] == pytest.approx(31.5) # "centroid" mode computes the intensity centroid (near the blob). centroid = bs.resolve_feature_callbacks( "image:radial_profile", "center", - {"center": "centroid", "x0": 0.0, "y0": 0.0}, + {"center": "centroid", "xc": 0.0, "yc": 0.0}, src, ) - assert centroid["x0"] < 24.0 - assert centroid["y0"] < 32.0 + assert centroid["xc"] < 24.0 + assert centroid["yc"] < 32.0 diff --git a/tests/python/test_provenance_capture.py b/tests/python/test_provenance_capture.py index 5aab83c..c8044d6 100644 --- a/tests/python/test_provenance_capture.py +++ b/tests/python/test_provenance_capture.py @@ -20,6 +20,7 @@ import sigima.objects # noqa: E402 from sigima.config import options # noqa: E402 +from sigima.proc.contracts import build_contract_registry # noqa: E402 from test_provenance_workspace import fresh_runtime # noqa: E402 X = np.array([0.0, 0.25, 0.5, 0.75]) @@ -263,7 +264,7 @@ def run_families(bs) -> None: bs.apply_feature("add_gaussian_noise", oids[:1], params={"seed": 7, "sigma": 0.5}) asyncio.run(bs.run_signal_analysis(oids[0], "stats")) image = add_image(bs) - profile = {"direction": "horizontal", "row": 1} + profile = {"direction": "horizontal", "y": 1} bs.apply_feature("image:line_profile", [image], params=profile) asyncio.run(bs.run_image_analysis(image, "centroid")) kernel = add_image(bs, "K") @@ -566,6 +567,13 @@ def test_round_trip_with_images_and_analyses(): "sigima.image.peak_detection", ] +#: Local contract versions: a reference file written before a change of the result +#: of an operation records an older version, refused instead of being replayed +LOCAL_VERSIONS = { + contract.operation_id: contract.contract_version + for contract in build_contract_registry().contracts +} + @pytest.mark.parametrize("edition", ["web", "desktop"]) def test_families_reference_files(edition: str): @@ -573,7 +581,8 @@ def test_families_reference_files(edition: str): The same scenario is written by DataLab Desktop (``desktop_families.h5``) and from Pyodide (``web_families.h5``, see ``provenance_families.spec.ts``): - every activity is identified and replayed under the declared rule. + every activity is identified and replayed under the declared rule, unless + it was recorded with an older contract version (refused). """ name = f"{edition}_families.h5" bs = fresh_runtime() @@ -584,5 +593,10 @@ def test_families_reference_files(edition: str): assert activities[-1]["outputs"] == [] # nothing detected on the ramp for activity in activities: report = bs.replay_activity(activity["activity_id"]) + operation = activity["call"]["operation"] + if operation["contract_version"] != LOCAL_VERSIONS[operation["id"]]: + assert report["eligibility"] == "unsupported_contract", report + assert report["verdict"] == "not_verified", report + continue assert report["eligibility"] == "ready", report assert report["verdict"] in ("exact", "within_tolerance"), report diff --git a/tests/python/test_provenance_processing.py b/tests/python/test_provenance_processing.py index 4e1de00..9831950 100644 --- a/tests/python/test_provenance_processing.py +++ b/tests/python/test_provenance_processing.py @@ -206,13 +206,13 @@ def test_image_to_signal_conversion(): """Conversion image -> signal: restored and applied again.""" writer = fresh_runtime() image = writer.add_image_from_array("I", np.outer(np.hanning(32), np.hanning(32))) - params = {"center": "user", "x0": 16, "y0": 16} + params = {"center": "user", "xc": 16, "yc": 16} (profile,) = writer.apply_feature("image:radial_profile", [image], params=params) reader, oid_of = reopen(writer) record = reader.get_last_processing(oid_of(profile)) - assert (record["values"]["x0"], record["values"]["y0"]) == (16, 16) + assert (record["values"]["xc"], record["values"]["yc"]) == (16, 16) reader.reapply_last_processing( - oid_of(profile), dict(record["values"], x0=10, y0=10) + oid_of(profile), dict(record["values"], xc=10, yc=10) ) activity = reader._PROVENANCE.ledger.activities[-1] assert inputs_of(reader, activity) == [("source", uuid_of(reader, oid_of(image)))] diff --git a/tests/ts/components/ProfileDefinitionDialog.test.tsx b/tests/ts/components/ProfileDefinitionDialog.test.tsx index e5d98dd..c80ca0e 100644 --- a/tests/ts/components/ProfileDefinitionDialog.test.tsx +++ b/tests/ts/components/ProfileDefinitionDialog.test.tsx @@ -1,7 +1,12 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { ProfileDefinitionDialog } from "../../../src/components/ProfileDefinitionDialog"; +import { + ProfileDefinitionDialog, + buildShape, + shapeToValues, +} from "../../../src/components/ProfileDefinitionDialog"; +import { pixelCenters } from "../../../src/utils/pixelCenters"; import type { FeatureDescriptor, ImageData, @@ -107,18 +112,16 @@ function renderDialog( payload={{ schema: { type: "object", - required: ["row"], + required: ["y"], properties: { - row: { - type: "integer", - minimum: 0, - maximum: 1, - "x-guidata-kind": "int", - "x-guidata-label": "Row", + y: { + type: "number", + "x-guidata-kind": "float", + "x-guidata-label": "Y", }, }, }, - values: { row: 0, direction: "horizontal" }, + values: { y: 0, direction: "horizontal" }, }} imageData={imageData} onSubmit={onSubmit} @@ -137,7 +140,7 @@ describe("ProfileDefinitionDialog", () => { expect( screen.getByRole("heading", { name: "Profile definition" }), ).toBeTruthy(); - expect(screen.getByRole("slider")).toBeTruthy(); + expect(screen.getByRole("spinbutton")).toBeTruthy(); await waitFor(() => expect(screen.getAllByTestId("profile-plot")).toHaveLength(2), ); @@ -152,7 +155,7 @@ describe("ProfileDefinitionDialog", () => { expect(previewFeature).toHaveBeenCalledWith( "image:line_profile", "image-1", - { row: 0, direction: "horizontal" }, + { y: 0, direction: "horizontal" }, expect.any(String), ), ); @@ -170,7 +173,7 @@ describe("ProfileDefinitionDialog", () => { renderDialog(runtime, () => undefined, false); expect(screen.getAllByTestId("profile-plot")).toHaveLength(1); - expect(screen.getByRole("slider")).toBeTruthy(); + expect(screen.getByRole("spinbutton")).toBeTruthy(); expect(screen.queryByRole("checkbox", { name: "Preview" })).toBeNull(); expect(previewFeature).not.toHaveBeenCalled(); }); @@ -186,9 +189,82 @@ describe("ProfileDefinitionDialog", () => { await waitFor(() => expect(onSubmit).toHaveBeenCalledWith( - { row: 0, direction: "horizontal" }, + { y: 0, direction: "horizontal" }, expect.any(String), ), ); }); }); + +describe("profile geometry (physical coordinates)", () => { + // Calibrated image: pixel centers at x = -10, -9.5, -9 and y = 5, 7 + const calibrated: ImageData = { + ...imageData, + width: 3, + height: 2, + data: [ + [0, 1, 2], + [3, 4, 5], + ], + x0: -10, + y0: 5, + dx: 0.5, + dy: 2, + }; + + it("uses the pixel centers of Sigima (origin at the first center)", () => { + expect(pixelCenters(calibrated, "x")).toEqual([-10, -9.5, -9]); + expect(pixelCenters(calibrated, "y")).toEqual([5, 7]); + const nonUniform: ImageData = { + ...calibrated, + is_uniform_coords: false, + xcoords: [0, 1, 4], + ycoords: [10, 30], + }; + expect(pixelCenters(nonUniform, "x")).toEqual([0, 1, 4]); + }); + + it("draws the shape from the physical parameters", () => { + const line = buildShape( + "line_profile", + { direction: "horizontal", y: 6.5 }, + calibrated, + ); + expect(line).toMatchObject({ x0: -10.25, x1: -8.75, y0: 6.5, y1: 6.5 }); + // Undefined bounds of the average profile stand for the image edges + const rect = buildShape( + "average_profile", + { direction: "vertical", x1: -9.6, y1: null, x2: null, y2: null }, + calibrated, + ); + expect(rect).toMatchObject({ x0: -9.6, y0: 4, x1: -8.75, y1: 8 }); + }); + + it("commits the drawn shape as physical parameters", () => { + expect( + shapeToValues( + "line_profile", + { direction: "vertical", x: -10 }, + calibrated, + { x0: -9.4, x1: -9.4 }, + ), + ).toEqual({ direction: "vertical", x: -9.4 }); + // Points beyond the image are brought back to the outermost pixel centers + expect( + shapeToValues( + "segment_profile", + { x1: -10, y1: 5, x2: -9, y2: 7 }, + calibrated, + { x0: -20, y0: 6, x1: 3, y1: 50 }, + ), + ).toEqual({ x1: -10, y1: 6, x2: -9, y2: 7 }); + expect( + shapeToValues( + "average_profile", + { direction: "horizontal", x1: null, y1: null, x2: null, y2: null }, + calibrated, + { x0: -9, y0: 7.5, x1: -9.8, y1: 4.5 }, + ), + ).toEqual({ direction: "horizontal", x1: -9.8, y1: 4.5, x2: -9, y2: 7.5 }); + }); +});