From 107acf0afd31dc03cda77c58834b4a6a2d607b42 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:20:30 +0100 Subject: [PATCH 1/3] Follow Sigima: image profiles in physical coordinates Sigima now defines line, segment, average and radial profiles in physical coordinates (renamed parameters, contracts version 2): - the profile dialog exchanges physical coordinates with its shape and draws its preview on the Sigima pixel centers (non-uniform included); - the cross-section "Extract" buttons pass the physical coordinates of the pixel under the cursor; - tests use the new parameter names; the families reference checks are data-driven and the Desktop reference file is updated; - changelog. Refs #68 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/App.tsx | 8 +- src/components/ImagePlot.tsx | 21 +-- src/components/ProfileDefinitionDialog.tsx | 150 +++++++++--------- src/runtime/bootstrap.py | 4 +- src/runtime/processor.py | 4 +- src/utils/pixelCenters.ts | 35 ++++ tests/e2e/provenance_families.spec.ts | 2 +- tests/fixtures/provenance/desktop_families.h5 | Bin 139128 -> 139272 bytes tests/python/test_preview_runtime.py | 2 +- tests/python/test_processings.py | 30 ++-- tests/python/test_provenance_capture.py | 18 ++- tests/python/test_provenance_processing.py | 6 +- .../ProfileDefinitionDialog.test.tsx | 102 ++++++++++-- 14 files changed, 255 insertions(+), 128 deletions(-) create mode 100644 src/utils/pixelCenters.ts 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/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..c8686fe 100644 --- a/tests/e2e/provenance_families.spec.ts +++ b/tests/e2e/provenance_families.spec.ts @@ -170,7 +170,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({ diff --git a/tests/fixtures/provenance/desktop_families.h5 b/tests/fixtures/provenance/desktop_families.h5 index 3c5cc17a08f034e699f8ac571b3e1f6cf8941855..fd172fe24624924a7f7161fd82fe125b668135e2 100644 GIT binary patch literal 139272 zcmeHwZEz&Vb=ZI(5=fEOQKF>N_X-p#P`UxDv3ona01n_`2ZibBp6=J(uV24@{od=@Z_VBR;LSI_<30yGxlN6qn*|iDrr)m2s{T70+<~#hv0}p7Bm*I0r#WdqO`83H1 z<8V)5VD5o?4`PR3q+ZWcc1DE%fa5IHBbH)=O&lUg%gOqavzv9!^w z)mJoa9}twJsS4$jfSH~rwCjN?;i7~I%FXYu1WQ z9Cw+`I-bl&)~}ZZINuqH@1qP7`lvno)`OwpDeyh)_itA18~|P55x=MJapgVkBB)%VgS!CnqHbYaYRitx2IlZ~IEYjaZa2Ri`M!5PMq`g^5 z$9^MDZtau)7E0&#n|iCR1HrtN(uW<-w^2DBx5JOf1g3W$mqYcXT7rX3`QJ_EsIOC6#Z|4UJ@1T(Jb;yzlAE0zD9|WOeJ6@ufvmn)-#0_C8MUid?ZfbgAY6y8UnI$!Qr@;#u$A5`KGDe*Za{;(4NIK@j8{^ZBg zywn^h@gzA=;$gY-6O@nf6v9p-Tu3!yOYQY%1^FHj+oP19$CrT*TE`w!%5hT$&DY13 za@_Di^mI@u=SP{H`ktwKZsx;!TQ2K?>lk|E1xeX7630)()cPcqQ|a&tN>}Od5T*B{ z!^8BR@q1h1S-ur$Pg1%{ho7SK>nMzm%XReAebSFmItQQO2MRw+p-P8ODfvF9#Gh8; zpI72lB|fjjLyGhIpJlB!A>Ik^B6_d3E2eawhfq$W$k~|7Lt-5UdQ2(*u=JQwIi~05 zr{bls*{m+tleMLEYPK}htS!`*!0h9b`!R@jfAZtIjT!Sm>Ce6jpT8_UZV)SWipWIBDG$_-1WU!-!}|GsoukDf{6 zR@z{$|*NZ6Chk4hXH_b{1#lAj}+rxGz!rReJteN@qIYb^QdTGySM?@R#X5)8TDF z?0Qb5eTC9hdj2Y$`qwF)gKzKyh2Nx50pZIE96Vf}a# z!gfJhPFtaTl5d#KdA;NOgF?x#t|KcY(Du6(coaWE?O=X6O&eOS_aMbrX(Kz$71M@? z^G#EHjy5*j-d9Kf=6o6nwxtscXm^LaejK3zJWm_!oX-r$uaHMEpTVVQqg`2Ic;Xs~ zF_tGWFJ5Y#?asH9?xC@+XwDln=cm-p-0my^=62_kQxalD2JQQL`)TJN&fg>3aDl>$ z6l&+X+jfc4_f@*n`<36Pl-HDN{NsP%m{rN|3 zrt&8~3gux;DmX6Z;2*9N@O+{o9^1E#@Zr_`lTZE?_7}oG$MDQ={^nmJ9JthWxBFcY zuiNjL4sNCN{S>~7LT-~RrD+U@%Q2q#y-FWN$DiSHd2T<~)31F@hf~zv_fvQWg&JMk z1)uAmrTQMB@RQx~bDx*-6GvrujzTS#@iVLR{#r;@kf{odEXsP4N4 zo}}GbJdAH+42&_bCov%9)@$0{aOX+!EIEdzy-M-@GZH<#uUdcKo$T! z#8jQ1bu57zqpz)!24I$haIHO9yu%@n64(Dl<|}F{wB>3PZMDI zLVw$8wV8eal5p=M=WK=EQvoGlxYJ}zU%cuzf-MB4bL$89n_uY{=hd-`&QPL zqqM=z=+{o>LwWg0YDaT1Oj51iK8(vT{F}5n@i?K~Oy%RX#<8^7Ixf^+1NBwd2i`}5 z2-TtYdIP~j+v`%>5o-8$-9T_MG=Z@hZD6~Qt#G?gMS7DaDP{tm?vQk_SbugYj8bVy z-46v2H&{mEXi|Id-bW5S0BMJxcoGBgLdD+%c;aE2%HLjcM2WUNV32<92r)K>BTJ9( zdj$i`-;Pj+CD#JFqIHk)fW8Z88)MyEqh71lR+3sQQZ+4XwHo-I>2#V4_6en&b1m^g zIL|;8bNN>&{;DMbln9RTsC?jjM<~AjxJ)N<)=m@OmjZ>2De2W>bK6FBs$`1nC`Y} zo#XkS?)&rkP|Qxn-y_P)72EG{e|Ud$f%F~RzuX>lqp-VVYb$XTmCl5aVfJHhhBE#3 zRi~7C$McmbZXEW)K)?AK5FK$Drf;TaP9HVo`Ksy%^LfYf6>KnIo$RM);QAS!YF8N+ z9?RvZ=fFOzdW%bJharTF=NFH_IP8Uie)H=z(Gi!~uKA_f54Un1x)mCq>ldY;#3~p{ z)5gj`{bHvc_bmdB6cA9~g6D&p^|eNvlJf_=AGAKcAM&tX_^kpS%hkpB234EJhyCO?(MX$XORcJKb*Qe^n>9Q=Jg}Vzj@$V*c<#q%0`lWxemmwfay-WO z-Wce|XSBoJ`L~+SF!aM=w3JrGSEH(O#g=cX)eye3b-Y7T$3V}tk~ z+FO{fpvILJ4-J@4&;BkB71 z2<<6#!IAAbUa&n-wB4Tf2{=+xK)OUf%y6w$Z^of?Hih@Y`eE`qk;^?2?z#ZFRA0ab zz%?vfSqjIqoBkzS{+i<8@jy5{9#in#2OfQeaX5Il33%Q3Jibl+mT*VwH_E(fzX{$* z{k|RG^*a}T@$>K!u6c1EY_C5;8Ig~Vuf2x>RUR!73QiFnF-ErfTs-gJjGC-rE6;P^ z0U&qoJon4U=HRM`dwyHbA9vtMJu{9vvs(W%qQ|a zf7fQL2N+Y#Zk2czpXKG=mf35WOcgZ6KodJQ~c zoYRAZ!+ZGR`}Yb!!F6tQy~Y73zf*dR>!EJmKk1?!6}V%6c2=*^*)CLptK+oYdX0So z3%OoH{XSB@-`c!h<1W#^il~w4XcWDM(FI4+^C)@^P@ZUa7dqh15t_n@8K{oagT1CI*z(`%fo!%Mj4 z#eJ||!}x;C$Z{1G8wYy?1FC#oqJA$D9WffVORq6WAJX1Ry#`u%c22J`iRxCkD&n5s zc9&jb9gSyj@xJK{#m}Mf050YSrBxXp-z5h6={15i`F^a|7*k3S45)mU=i3FEe++}? zr<58wb@Y(+8tQyk<>XQH8hFG%-`@w+`7UgN$lIHUV(+}{OfbiKv{0uJ+|y{Ff}Pjlq-Cg`!etM0$FT+c?=EZ%mUgM>&$c!vkQL%BbM=+qu*CpzAF+CNxU`i4wr{&kl4pG+O! zrQgUz-|xp6-|EKS7-M@21AX~F%`3)6wTeIdnZoZwt}Jw(=-~M%cQu@Qa5#uAFMdzL z;CVe5;yB0EgaMTUVQ4DeKN@ninrA?I>Kq9?8h^ z(vefL!>pH8_d&yc524ea=Mw-r*Yk{i4i%5Q_dAbi{6MyUnh0y`hbi$fzW2s}%C~s^ zIz{UlmtsD}^Mcby4OPB1{T&IZT@S@qm3E9;-_=@Jj;SgM<7J-w%l5 zecq$++!GEsU=0K3v3_vC0c#jIj|(^k+$H}j@_o;1Mef{BPF)Xk{r_<1?-}p>L)5&l z+la5>e-{GhSZ*&H&fZ39f{yY#Z*Tjg=ubtA#BQ>;Jt5j9IoekLE<{cTQvW{6`RGGk zcpKf`c38lXJD#09AB~@?$miS6osa%h7hZSP-iGs)`R%8BjMH}8+irn6@oMw4FUff6 zgbZ`}ck}kPBLFg=7o*$TKGOwfbbH%pyWos&Z+of>&gk~G&j~opkM^Fu?P<~fIk@Bc zzIl5a9^q8|vUj7s4UZV+^Gx=8GxoN5fxq6r8$S7~@Di?haUX1NTmM@!Bkv2S*f`iD z7*O|zCF=K!ztW9>?XtJc{g$j}v-Yc-0SvFRAv|y1esx0FcHVjozy)~ZW-A+enR~Bu zTXY1x?VH=Lc(1aT`CVCIW zZxM9C`d`*7o*=v}5?Th?6VCp z2V8H|7OH%B53&x@;~*bl{u{w_KR<>`zai^oKZddXTH<4T?~MUU=SFR7=d3`p@a5-Lf&v!9!ACc=jcHjAtXS(n234+Y&?pr;&V6Zvyb)Y_h$6j&jP%D=SNPTfR}L1i~C@G_S}zU zM&2h-v2n0RFre-iOVsZZzuk?1t=4BV^rm5$v!z4eK_f4STM)N8;x@$blS1Nx_s(o- z+P9)O^%K7o1fgR)UQ&u3FDTn_a%VfAJ6p=KRy(VF0p-2fQkW#wg>VhLp~IExO1;)h z^K8b9Su$qaygq*xexw@81+%5)`Z4sM4v(dca3P%zr5)U!aVRms`U@3b z>V9%qy*TPeSnqHgp7VYF=z8(z1e`h1@U75`Ki>s!G`%?Py}6%X6!2J2H=15tm6vk7 zjjk7eL9}Z$z4+^(EMB>OZRhpk=sKF?W%uaCe^#_ha=6{-=Y66JZ&Kej%J-s?)#mgd z;p~K7{41gzJm0RYUL5BI&-<@-;dN*A;+^gKT90wsZoRnb=UsUb8Ufcl={@pwW%GLR zuLIni4-BUl$2Rf!eWMGGr01e~ag;Y0&NsW@$o7n)7eCnrN494az4*5T9Og%RPcQy$ z(SMc~Hm?`Qqi?STz4&)~^m{XU@$Uh=39xh_t?!3_yx4#1(jUwCybQEKdW`u$l^J=T zK*h$v9>IXRUo26-PZJ$68n#+5&h#4mds#z;Hj4TBA^B{p))K!FILIE_$@jkxAh%90 z{9aA^%9OOXO=@pLnG&!5df_?J3ztN`{I;Gy?!c3(7e4#1Wg`gf+Ifm!`ZqG2`N6?I z*@4FH;wt-nuWRwoWTV+{cC4q{#e=vDnx^tyo^Shzj<|_DKc&>jsiTLiUsC6L&UZ)G zYy1G3mG47`^Y=slA-wYPJFnL`CE8QLX2E*0d-NJV2kpt_-reW#_w%BC6}ThbXD9R;zaZLSV0VOEaV3A>8~tE;Kl>wi&gbdQ>NPss^^2litk0C=wB34* zeNZP}EJv>YXBj_>j&X2prC#Hg1iT7@h4CLTpWT^n;a!|OHH{~UXQ zebKas?tAvme;f!D4pvy^;TU6XOtT$orH6ZU0U=s3NzjDZCfvAaim*L7)9mS zOMKJAB5$B_c$NUK=cnSOu-UAF56s$9IyGCGYStEN%i#=uc5nuNBXR0LiSn$3%Oc04 z!kgw^=}dDlY=q00X9WsF|EAEJ2H`yMz=MxGaNpr6v<~3-%8T1;8AfbaW~#fEpX!cb zhk8(k_Qp=)Tb|)~wkIaw%~X!Z;gQ-(ivF04FCNd^7i7jaQhvtUq54uS!9tYI^Nh#s zHb28Z&_2kE<(9|lhmJ2t^(C}3;l*^Z&zF{^e`-2R=J#}N#J0S+o+sMuM9_}*V`$7KYG1`-nZ#B zilfA`v=30aO0UzD&U6_qt%-5Fvrqa5DV>84@dJeiDCBl8qmw>d%kZj@z_ z8(V^pxCH0J6vp-C2uLNV0=ruYmzrN(LFNp!kpgKTY2E!m7%!IO+ftN5{gbN=IY3uNSub0eSvC^S zi~})#WrD-y*nbkZd;s$+aYHB)MYfVryo-J%g@W+>%<|6+QapSaoV=0V+WGOI`R0Y4~g}o-dGNoV3mZ#3`Qiy zHHFiv%V}$|o?!k*?>+MHqfb6c>&i!z{yC|WWF}xS@LlL%XgSa|E7G0RbR64qV#`dh z$lX+q=hd-Vqn6Z~+D9qo} zPRm<6b*23zALS5 zJQ)5UIjX6Y|a+nXgef6^~z6>fv?h z8loIDC&j*fgSIGL{BmYX)u>Qv~ z%=HUWPce+Y1M3%%=5jsd-tl+f{$DHlKPM2nneW;$Je)%+K6leOKyK&tJ^DS0zXQWV zp49Ig;D!Gp(5ubMV4%Ol!T%~VvfM<)#=#!JfNFOuQNK?T9WffV%irOpzm_#@_Pchd zPvH51YvI=UJKRIxqpN7n8#L#q$i(34@9(fkOL#@(%Wta0lE1kUPpZGe>Hj8cWqJM_ z#V`C1na=#6@|Rblb+4T|`uRIt{43dqZhwdVzu&a~e|6*ewS$3iOSb|8{rIkSsT=xM zpO@?Ry}9}SE$bbBZ>}5v<5+JDsPxP0#kv34ouAj2gWbs^$JFo59ijQc^_-yi8KoT~ zS9p~cwnhSF0?JEu8!fntqacRdglEC&Ox~2{MdVXX7qK+>236Lbv!Qb z5b(P3xf}J&@9fd<&FGoY2dtl-`P4O1;+}^*cnvyNr(GxWvs@udjqiH}1L}UUL><2b z6xMldwVqk-7q4&IL*{;2*2cA}-$i9D?fw_L^Qq^=&r+ievJ)~q)ow^&v=Xg{oKIaJ zFA%(w|F65=7b$*T!4qY_VgdV=FTP3kV6*yqTzi?nz8ju*PG5iaHKDJcyI$g*>1UPV zr(Q2pn6F+UzI??9W0zOdPhWrfW};&hoZ%X@e!YBSH=bpw@qMpgpx^rSBF#U>!-9i!+=V^yk4vlU*ocps&ao?P9HT?_4SjJvIeflp!h+h9i!HFwU%SO zii$6FpF6C+9(BYlhrI`$b3MlB`g%O$lebIidz$sNMx0jVkEX!;LEo=Hp^V#A>Vh|# zzW&P{@ZKlj8E{9rb2NRuDu3m88(m*NCE7Kbz8-BPc;$M(o!8gjCfZZMW&xgekG>vh zYd7obZx`*Wz#Z{6y1xDnD4)~A>tvAlkw6?aF=!55;@tBh#WC6}Tf_caOgQPH2xh zPFGrAuj(08{vEEY)aAi8c~W2y|3(}$-ajb%SHUE~Z*K*Uy7i?nN|y$&16YRPdz4m|*Aho5*71Mxz><2=~DZn*0L=u&+_6c`>Z6$7|);*sZ(gu7f@ zp$x;&$6ZjS54@1|al^nvPNVjFH|p!n9{t{ozTN_O6XGxb4j#*m^KX@uabmv=2kARr zdM8sE03Ba@4g>0baUWse4ABoGWT7U0$c1>4MqGj44j)dy7ZH&1(1wW@>z%cCOL{yj zlLzYS7E3b!D&-#sdkX_hzb{g^Pf&R9UP%}I?3xE3lJ868=Z}6(l(@JlbPZi>?Ty7k8V}RE!6=2u6k>nOwGwXY=ULdG;L30omP z!}lyge#ZEET(38hrgoe{E;Wdr*gigqK;Sw{>nxW)Pw`9dl__&Fn|A7>GS2y4qWCKn znR1Zwo&7?0zVj5n)RgJm-*eyS&UcaGli!l*ypF&88{PRXQGDN7nNHPS@qp!&NmsVx zW!!MWmGc?7UoFb7@cSvuuUvQ@WFM#Hs{K8wjqy=`*X<&*JhGnE0 zn+Nk`>hj~ikJ`BS{%-m?Me!H!km)7Lciz7|Jf2qQl{Zs(h z>Inx7Ps8ZB%KhMg!D$#cvjPru0xsl#cy6y+g?>RC?V6HyEBIRQMhtl$%Xm`P!<@g5 z<`?ussDt@C?zuO6j#u4JZp6RTG!BC|{6=Z+A28fGUY?ICo~l)G0(3cC1>kTEY7c%> zhw+5pKD|jhg2iB*-lU~KRzz4ls3fQ6T)&eNp^8W6FX9>TRHdgCP;_JG7=>@;F zg;!pl{h*{rMZe#*XFSQ$m+r~O?G~zEl&_-v(EbP`WbQ||5>6+cZXrA!P1DsXe95ue z2v-(T&Nr-mpUdUrdV|noSF0I*^R~tB2aRi|f6ParP;NKh>T}FI?~D1{;hB28ST4F$ zmLqfq8w^+8@6Del#c%Vf^jvFJ*H&tXOQ2|>Sryd|P0vhMIXzx0#%W!mLn}s)*goXu zINxOXtjqoss~_h>cl`-{aC7+b(%!O8t|Gu% z@-X#lm5ut_{c=1fm_p=u@cmlY8Q`0K{p$O&_~;%6mTy#hOTG?` ze$KrDRpoGo^P9s~^16R-7o5@k=04U1N4BS^-&|*V?(2dh+ws^uivG_bN`QB5{@ndT0^Y$kJd`tcqaPpkIG0Cczc=H@_ppFh zY4;ob6P^=(o4J2wzjdK|HQ9eIpZBs|1gGs+Sq#oJtY5wT9JG(g8s|l82m3j!-Y4@< zQ~q(Vw=kgUt4NI1UIhy4yx6y4tNa`owscoif4ybJ?)>SN%l&c-l(%L}4{H$Gv!zd9 z=**V*EBpM_d=1mx*%E)ra4Ccpd*ZJa3f0%50l47l~3nxmwld>LHkY{l6L&cKB)c#^TgNswPGK=m?u9zrrwwCmrt)eD8mxv zyZ8*}=nawi;j2e4pRaf6-IF@V&N}(1?4R6C?w)p!TXJPE!2JJ3s_r0#r!z?x%=hLN zWc(z(->8?B`tU*eH|`a~U5q8%)i&IUc;er&tkMzHL3025p%nAePr!4oUl>k5OKxHv z@*|&<99=*Ah-l9d>epC5J1HAE8Iesx>$b7Cu z<@bF~LT2~@#gBZxn?8-lyYn5P_`0%Q&(l0$cqb`-_OqNaV#9Y?G1hCSe4z4a+0XOT z-mzcZE*rRA)*O`&@Op5D)(37b&mLZ1IDOPm^{WdG$r`wxd5WJ<+A(TgkM z_vzh!b%X36fA>2x|McRo|KAQjh>r5$PdBLiPhS7tFJAf={|Cy$q5)S?|2NhTaDP<( zJ}f+@qkiY?OC%qjB*GZ;NkYUp=!t=TeDY;Qu3);`Dn7~cL6u|j`M`c=m)8@rw@hBH zm|rUQhxZvJ+Fx@2RD1BS=MHea;e8Uq?6;l=PT%p!0~2~UZ3Hq`k!IppV!k!G zEHStp=VG3OdUE@?^dlIbixKFj+xT2eu2180HRE$JofIKGu*kDPPy*WU2tT3M*00m^sP|aQ}kR+XL~-=1xL1Llyfnk z?SeDhxtLD-{8Ivslx+9n?@@kE^q==Tn?D!xbPs&)#&a>B@6qqgoQtUncon##+}L%V zrTyLUL3S8X7;gDI>w+`nxtKdD{m#WKwxnD(PZi4j+(h5+xV*9^^G|aF$KjsBfV!WP z+6<8MiH;ZidZRoeDSZ3SnVfx1oHKb@8DAb}4)@VR=~+2G z@($lwr}*M=nNs2cCpZTubcf7e_I>&C`MOK(zF5ESR$d@{bbq&U{Qa!mH-fvPMJnH& zq-LEYI^tIHY+?N@r;i%)zDAwLxm?2jCBuI&D1=7k`|{z=qp)5)w;OE!JW3?mgS#7C z560(FB#l)FA0lYqa(&zGKaUbat8+Wa)}BWhRxf%ZVUXpS(pTh+-44AdI;iL4G}enE z>X|>vVHoR0bN)Nli%Oc<+qVataQ3cV^r-0n9Ncq#9{rABTMswLzXHp$e+B%UWQRN= zzrVB_^`gt7o!$N3j9wIfb1|PEUHZ&jrx!gzzvnU!ZN_VmUi8wprDQQJvybn43Aa}c}7zB_UlC_?~pi~mqGi!-hSGN2le|q z7nJc;+r)a&;Jb2sc>gg^@e|*dDJ3d6`FA*n3Q5M9zwAGA`Fy=g@1E3tdeMugWxu-h zqW$erFa4a%Ke>ClHg3rk#DL0oOH}t6npfPEt^Ylr-oJ~X{ZP{5i&XI-xncDe$Mnl% zv=>8_exh5SX|(l5#Jz5sdf8vFH2JdE$XF`)7-mcRDX zdd8)gpYXik^if0gy8|!Mddu~^Lh)CB#3`fJbaj_wyRM3_oX@abSDs5Aq~GfN&R_#j z*Aot$TNnlop2x`TYoncGP}i{>Ua@lwY-eVCsr$B#{T$v7j^|5qJIru)@au?7^Yv!) zcJMXPo+ASC*bd(N_nrvtFm8;e-ERj!CdO5AVm!|%e-Ej-Sbvs&i~!z`{`)=#(Mz#; zrT%Wt^sh;Ny&ZP&XGOnxoW^!=9H{Qqacl?A`R~{cE++COZ@kZ7LK~kC+9xo2@f&jH zO_JVNl^;g2gRcYR9M0%=aQvyP9M0%=@aMYV$o7n42YoqoG2mjd~_}qTuo|XJ+UbL3=LjCPAul^&Mf12`-gS~|TRWBrW1@Ikhq9ew}cGc7(c<$6u3|4QLmt=FRZ zw|jj1)oWAz+eKG{>UF68t)}Oq^}1C5R?VYf>-DJq&CcIZ>-DMrDGG<>8=I{+*x`E1 zRDV%ExmEq;dS|KrLHv2U>isa)-)~*m?s`8$^{eaAm8$n{s-M@vD_!qLE244h0ZJd4FO2-sSW4F1>qF`}x6Xza#tA?FTnL zAG~`;Zg;gr-FHn=^Ul(|;@0wPImii{4B0NK&f{D!!FJ@~?V?|Ww&i;H;q0QJnDDpdb1RKF-+MgJwy0GYAC_XinIyWcMQb?DP>Js({8eK}*d z!!G&_(Qh87v0W5NoB1O?YuanwE;>`m`6t^ojH zbvxh~A7Wy)Z!AAt`VC1S`)D7LuR~IP7{xC7O+mjEQ5SyC`c_y^*Vk5R=x;U14-E^k z{`Vw2tL+)(yNTcGf-{_5w6i_m?t(MAUGzI$aE7yscDCoc0*;g@_u_XGzbE=XhbRGF zC+%>Xv5S6R!0X26ZnTU3K(w>F-srQR7^Nf%QOKGx@Hmc7w>-aSZ zOfFNElT=1wR8(1K1otRKu^jXzlpJ_@mPu@wu9uH=O@q zqkb%130GqLI1OCfo~@eC8}|>BUG=b^K|lHSdXRy!P1jr_n>I<`ZNGM6kHKjQK1n;oHErjJ)jwv8_iilnm&wU%dS}?9sSn-vxcFuI%10yu7>jSG`14cp7uTm| zr_gl=4h-n4ZBp0eteiSPnW~@>#aT2pYaW<-w%$0}Tn*#2T1%j`=|y&C`ibruo})WS zzN@T)D&sadFfF|=H7)^NSDmpFzS8h#KvspFbu zKMhSkbz;l6T;Gb)EcGoj^aG>prcshWjx>r)fNxl31eGS@Cy}rxY4d2SzKYe!w7lU{ z=8Q9Abi8S#$7-@qD{D(j2c}k!w-&L7&rj8sSC>+3sL;>?*!vF3cL%20c$l(hOmoJY zdI1W8CO4}$mLcP_wH4cfI+nwDv9^*z%Ds=??>UgNdc2<05=f!S!uV*okV;I{7HZ2O zc`D9?(N%7-hX|WoFq(Jg6*%wjQmmVD4sZ0e-Ou;#qA4mVng;D8@xA;zhtdWMI0K z8TDf(HT&pU}$^-%GMMDm$i?M84sLiJwGSVrQ`zB#DzIt{QKZ{}o0?u2`B@e~AGUBr zV62TGj@&qiqSOpSGjP(tGP2lp{Ur39%=gMh*|F>}_Omi1xRz(8aTJ9vOl`y2i4iej zM4)j|76iU-8)>3DY3S>rorFNZc49bD*)$A88IeLo)Dek+Etc&#(P7dBFc%X`4>QZt z%So7eG2o)?A_v*Z5lKDUv!gH!oXB!A3kKddTsN~FV9rsPg?1QZi51uuG{!H(&>68? z4vipyUT44=kY2Cw9Ia}u~#xvU4SW9Si74#S=}euC@9R*uMPV2(@$<;00xU>H{FBa5;CEdiiEbUa`1 z({hFjPtMnD7_->3)5L)lzz8!~0bt_mffsms=G#D7Hi!a-%GU}NQO67fg(Nd!eF=OQ zW>E-hTRE{Jn75|m*cQkRW|EH?EUyc=@C;gLM82Nu3~;B&vs_q3{lv2z)6eX3+4nuy zOjFY;hjC(95wyYso@&`f7^XO#P1APLvJu!>?>PjE7_K}|*$14b^QA_t{Sfb3X`uDE z{zLE_27v0m>&=v5_@?bUp`K=CKnctupk>q1vm{H*6!>l!X81wt)Y{mJZ2l25VnL=K{7&-BI?M+z-+9*0C6$Ik@J&O54||nt;Dhts0K)@ zW8{XG5s-H(GJ+GEL13C*78-_`0eJ*&8P+}94_(s(X)<-3EHD5oL1M&S2xA$Q%Vy%o zuyVO!9CeOeTu0e(-GtQ{MxdyeF!vnf7Q=Vham)O`buBZ{L7a90HDRhpnPuyl4GoPP zqa0h^GtLEYMwFSE5yLb!9VisTTn)@5(j%ZiH*zDm1BrpWoy&HGim2n3l)cb*ZIB-f z2WK2Mu56fk0<${K$^p!G7kascTrxE*&o*tVTuxyk!Kft$j7^dTAyfuy6-=}&gGCfp zt{BEONj=|+!*bwfX&MEF-8pXCCELM1BQD#%83Av!Vpu$4R}TZ**Aw3kK=!dBSVRFW z`CRj0%5S9%qPGi^0K&*>kYz&yvo*SURnb{VIA7vPxung=w zGg3QpfUgE+*qx+7=9D*#nb=Mho@gV<<35Z?WW%}zd;&(q1MUZV37C2y64*f9Y3O9J znR?2I6e^;Qh-DbDVVSA!T0Y3*2Fxeue5A*)W?CL>X>D(d23mR9qerkJ&fa2w`|W!vLNzdE2@lHp(5&- zr3px#v5RVr6voUf+j;~W8a)QF5x9>Xg8-S&qRq=14$Ox#h`6y~!tSsP+ajwR`9b8C zjT8ve4MI?dcz!tsHt%_vp9DeXXRw9zVL&}U>^)*b$QsMb&>dW^!~@D3aeWQdHh}cL zQircn?4t3}Bz-r=|=7$~#L7-EJ{WyVbRbZHgZ=_`qWn!SsC~`m( zlEfjfd&3Gc+e{qTV#S-?<+ewtz`#ranzJ(QYBJFG0B>^=*xCA_4tv1Vc1+iV^#`z% zFHxq@Py{Jt0JWY20<7-1sS7wwVLxoa=-Gjl0f$${4ip%Ji>PB~_#sGIcB-3^4VC#G zj-Bo5iI*CI3F|^6mH-;N@$8a1BokE@mhHn*YQ#E_IV|tUqC&$qVKMOoVD=e^Y?d-a zg^H*{6r@&chOqsHT`_EseFsF$#Eo=62%Ic4BhN5V!@Ffem8SHF8VmDz=62w+!!wTiZFc<9%IZ|1ek>#JaY5%%yr7ZTvDJ!%LN6q4>~{40f!(Q!d}ct z64dQWfNo>^VJOdB=mA$Uk1 z1KR>>(Vu#rqsQg$x1fFmc8ts6N_uc0rE`V#8Aq%F4|;fo)C!pa09Azn7Sdu51F(=1_JtRo#_lV2o#z2T z!VH6mkVcqJ3lAHNq+zJK0;p_foB<7g+#ZHTt+fbvuEp&Uf!gY8Efw31w7Is_LLQKB z#tUgVUw9gb0){wU7Z#F6y~bvR<*<2_pV#4N0N7N}a)ra#Sd&96dd6+-$TQPi20BZd zt?ELfmQGF!V{HCsWO) zEq!gH9*jb6=&LuNe_*zosyBds!^Uw!?;6lK;0%Yn1Db_ zrN^+j0Er%y(H`oai!ubs7n;>py~-TC0A~Qy)jF)5!sK;eY6VQ1Ku^yB(P!aOGwn#J z0$T;zWn|W4=F(*}-UjBfVRQlO1-VQiCHt7m2rWY|cY>|rGQd1szZjRX z9lIBoDX1jw;xeY|gK9K|oq=zG01x|8y=<026omabS}Od=Ks{hlE|X=rh81Bf&BYqz zoozMN(#voN0JbUVV>{ROtiA@!u`z>vEZeqQ?Dwf+|#&&{&6Cr?gdE;_h zQ1jk35wgey^*WfGKqmy7AlOgpuqARqvlrN9@LRCsG#X+egh^6e2-lj;TDVeOsn?ol zWGplUIL|Vj(OS4tRwj&+kz3B$*9HLqF4#*oCR$ zg62I0&k>Nez^(>%H}HuYW|2W%0V-$;tShh8r6UbnYoHsMkzE}y1HwkDmVv4ZRUD*p zZiNx3I#i8kQE$`~O=%i!)Mzwl46b17G1iJ{euZC^i{gv*)^1(2&(N z8NDp>!Qd9_z5yO94w%y}Wb%%F~}3aUTg-n~UV)aZ9< zU4e=kuuKWx`Kd+NMm`5>JmK{WRA`bg9|L+`p# zGhhmLs6PwYd-wRf@dB6*MHVs87xr4jd-)MCh@WEu?kOt_n4F_u-17ZYW|odx)S^xh4G;HE^OfTq^_u38K^m z6CpU{gV_$eSHTM$92>x01KiQT>jeC{(CN?eGt-1KJyvXoz72xRUSHIFr(AGydTy`D zS}|3(mtBX`#&+P0sqtvHH@QSfh4&e&&Wm zVf%!0Lj81ug`7>cb@5vc!3eV2oB@v3#@~SX-m&l%^28W%;R8u{6^9eezEj~EScBTe zoa)iEv66yyS=e;065Cxc1Lrh~&B8LSr5lxrAsoxGBJjgay)t;J0rLYDAvixJ4!Yxl zry976t4kytRrDOowa_ibkRBD~r0j#6Jsg+-7xEbVTd~X_W4@e)t7&+&nt-iR3X6p% z;#Ay#ty5Zsr4@#ntaK2mwigl9lfn2?H-G8d2$p);&@x4=sKcazokH z8Q4bGjDo$cY526n>t4GaVo-v$4_9n>a!xREP2|6#medp5GUz6w3_vkV5aks}9 z7-L|JfiVX57zX(JA^Zb4{QZ!7Unddqe8H24CDziH+8=xwfx5&!O7F zT?H{Ek;e zErr}$N9ze7uT2sq{?J8rkPOg zJZ_3w3z>gM!)!2H>b6Qx^GVV>am#4K+a%2m@TG#s{yQ*GcoG6b0M!?`hk-+`9Gm8f zUFvXHeE>eSQ<85=Q3`p}W+^Oz534{@A$Rl9kOb`EkR>ZAa_*_KlES?Q8vBH(qL3ec zXBEZi7%+TDQdA+Ywb3d8mH-}f?db}WllPgtte(DspT^-B17i$~F)+r!7z1Mrj4?3A Jz)oP`{{t#@L}&m2 literal 139128 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 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 }); + }); +}); From 361a18444b090e6ae9fc406916604846232567b9 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:46:24 +0100 Subject: [PATCH 2/3] Pin the Sigima snapshot providing physical image profiles Refs #68 Assisted-by: Claude Opus 5.5 --- sigima-dependency.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" } From b808ad5ebd8ef8b9c706a0a832b8c43a86c1e606 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:59:44 +0100 Subject: [PATCH 3/3] Replay the families reference files under the contract rule The Sigima snapshot declares the ROI pixel convention in the execution context of image computations: pin DataLab-Capsule to the development commit recording it (DataLab-Platform/DataLab-Capsule#4), since 0.1.0 does not and refuses such replays. The e2e families test now expects activities recorded with an older contract version to be refused. Refs #68 Assisted-by: Claude Opus 5.5 --- datalab-capsule-dependency.json | 2 +- tests/e2e/provenance_families.spec.ts | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) 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/tests/e2e/provenance_families.spec.ts b/tests/e2e/provenance_families.spec.ts index c8686fe..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 { @@ -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,