Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion datalab-capsule-dependency.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"publishedRequirement": "datalab-capsule==0.1.0",
"developmentRef": null
"developmentRef": "3fd6fbf9c1a2fe90674032e021db39aa8af18164"
}
2 changes: 1 addition & 1 deletion sigima-dependency.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"publishedRequirement": "sigima==1.3.0",
"developmentRef": "6c9255ae81a3c188981cd90f335a732d13a1a97f"
"developmentRef": "3af8228e8bb6473c5de6b7df08d9f57f397abca2"
}
8 changes: 4 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -2064,8 +2064,8 @@ export default function App() {
const values: Record<string, unknown> = {
...(schema?.values ?? {}),
direction: params.direction,
row: params.row,
col: params.col,
x: params.x,
y: params.y,
};
await runFeature(feature, [currentId], null, values, effectiveGroupIds());
},
Expand Down
21 changes: 12 additions & 9 deletions src/components/ImagePlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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],
})
}
>
Expand Down Expand Up @@ -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],
})
}
>
Expand Down
150 changes: 74 additions & 76 deletions src/components/ProfileDefinitionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>`` of
* values that :class:`DataSetDialog` would, so the surrounding
Expand All @@ -38,6 +39,7 @@ import {
ProcessingPreview,
type ProcessingPreviewHandle,
} from "./ProcessingPreview";
import { pixelCenters } from "../utils/pixelCenters";

export type ProfileFeatureId =
"line_profile" | "segment_profile" | "average_profile";
Expand Down Expand Up @@ -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<string, unknown>;
Expand All @@ -98,75 +118,64 @@ const SHAPE_RECT = {
editable: true,
} as const;

function buildShape(
export function buildShape(
feature: ProfileFeatureId,
values: Record<string, unknown>,
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,
};
}
Expand All @@ -177,7 +186,7 @@ function buildShape(
// Shape ⇄ values (relayout handler)
// ---------------------------------------------------------------------------

function shapeToValues(
export function shapeToValues(
feature: ProfileFeatureId,
values: Record<string, unknown>,
img: ImageData,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
"""
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading