From da06beac9930c49967366ba9fee2c2d11fcf062b Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:47:22 +0100 Subject: [PATCH 1/3] Center image pixels on their Sigima coordinates in the viewers Sigima and DataLab desktop define x0/y0 as the center of the first pixel, but the image viewers drew it from x0/y0 as its upper-left corner, so everything shown in physical coordinates (ROIs, analysis results, crosshair, stats rectangle) was half a pixel off the pixels used by the computations. - shared axis helpers (pixelCenters.ts) mirroring Sigima's axis_to_float_indices / axis_to_physical, with pixel edges, image extent and center, pixel lookup and covered pixel ranges, for uniform and non-uniform images; - the LOD raster geometry follows the pixel-center convention; the bitmap placement is named after Plotly's layout-image x/y and shared by the single-image, spatial and processing preview plots; - image viewer: extent, hover value, cross-sections and stats use the helpers (cross-sections and stats now also work on non-uniform images; stats keep the covered-pixel rule of DataLab desktop); - spatial and thumbnail views: extent and hover read-out likewise; - default ROIs, erase area and ROI panel bounds use the image extent and the center of its pixel grid; - decimated image payloads keep the exact physical extent of the image. Refs #69 Assisted-by: Claude Opus 5.5 --- src/App.tsx | 80 +++---- src/components/ImagePlot.tsx | 203 +++++++--------- src/components/MultiImagePlot.tsx | 18 +- src/components/MultiImageSpatialPlot.tsx | 67 +----- src/components/ProcessingPreviewPlot.tsx | 33 +-- src/runtime/bootstrap.py | 18 +- src/runtime/runtime.ts | 4 +- src/utils/imageCoords.ts | 30 --- src/utils/imageLod.ts | 32 +-- src/utils/imageRaster.ts | 42 +++- src/utils/pixelCenters.ts | 221 ++++++++++++++++-- tests/e2e/image-lod.spec.ts | 11 +- tests/e2e/multi-image-spatial.spec.ts | 10 +- tests/python/test_image_creation.py | 23 +- tests/ts/components/ImagePlot.test.tsx | 174 ++++++++++++++ .../components/MultiImageSpatialPlot.test.tsx | 71 ++++-- tests/ts/utils/imageCoords.test.ts | 40 +--- tests/ts/utils/imageLod.test.ts | 101 ++++++-- tests/ts/utils/pixelCenters.test.ts | 204 ++++++++++++++++ 19 files changed, 954 insertions(+), 428 deletions(-) create mode 100644 tests/ts/utils/pixelCenters.test.ts diff --git a/src/App.tsx b/src/App.tsx index ac33609..ddc2d5e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -164,6 +164,7 @@ import { writeBytesToHandle, } from "./utils/saveFile"; import { formatBytes } from "./utils/memory"; +import { imageCenter, imageExtent } from "./utils/pixelCenters"; import { listRecent } from "./storage/recentStore"; import type { AnalysisResult, @@ -256,6 +257,39 @@ interface PendingPlotResults { createdIds: string[]; } +/** Default image rectangle (new ROI or area to erase): a quarter of the + * image extent, centered on its pixel grid. */ +function defaultImageRectangle(img: ImageData): ImageRoiSegment { + const { xMin, xMax, yMin, yMax } = imageExtent(img); + const center = imageCenter(img); + const sx = (xMax - xMin) / 4 || 1; + const sy = (yMax - yMin) / 4 || 1; + return { + geometry: "rectangle", + title: "", + inverse: false, + x0: center.x - sx / 2, + y0: center.y - sy / 2, + dx: sx, + dy: sy, + }; +} + +/** Default image circle ROI, centered on the pixel grid of the image. */ +function defaultImageCircle(img: ImageData): ImageRoiSegment { + const { xMin, xMax, yMin, yMax } = imageExtent(img); + const center = imageCenter(img); + const r = Math.min(xMax - xMin, yMax - yMin) / 4 || 1; + return { + geometry: "circle", + title: "", + inverse: false, + xc: center.x, + yc: center.y, + r, + }; +} + /** Build the (hand-made, fully localised) DataSet schema for one * "Plot results" category dialog. Mirrors DataLab desktop's * ``__create_plot_result_param_class``: a plot-kind choice plus X / Y @@ -3143,21 +3177,9 @@ export default function App() { const handleImageAddRectangle = useCallback(async () => { if (!runtime || !currentId || !imageData) return; - const sx = (imageData.width * imageData.dx) / 4 || 1; - const sy = (imageData.height * imageData.dy) / 4 || 1; - const x0 = imageData.x0 + (imageData.width * imageData.dx) / 2 - sx / 2; - const y0 = imageData.y0 + (imageData.height * imageData.dy) / 2 - sy / 2; const next: ImageRoiSegment[] = [ ...imageRoi, - { - geometry: "rectangle", - title: "", - inverse: false, - x0, - y0, - dx: sx, - dy: sy, - }, + defaultImageRectangle(imageData), ]; setImageRoi(next); setRoiDrawGeometry(null); @@ -3168,16 +3190,9 @@ export default function App() { const handleImageAddCircle = useCallback(async () => { if (!runtime || !currentId || !imageData) return; - const r = - Math.min( - imageData.width * imageData.dx, - imageData.height * imageData.dy, - ) / 4 || 1; - const xc = imageData.x0 + (imageData.width * imageData.dx) / 2; - const yc = imageData.y0 + (imageData.height * imageData.dy) / 2; const next: ImageRoiSegment[] = [ ...imageRoi, - { geometry: "circle", title: "", inverse: false, xc, yc, r }, + defaultImageCircle(imageData), ]; setImageRoi(next); setRoiDrawGeometry(null); @@ -3375,21 +3390,7 @@ export default function App() { * Mirrors DataLab desktop's ``compute_erase``. */ const handleOpenErase = useCallback(() => { if (!runtime || !currentId || !imageData) return; - const sx = (imageData.width * imageData.dx) / 4 || 1; - const sy = (imageData.height * imageData.dy) / 4 || 1; - const x0 = imageData.x0 + (imageData.width * imageData.dx) / 2 - sx / 2; - const y0 = imageData.y0 + (imageData.height * imageData.dy) / 2 - sy / 2; - setEraseRegions([ - { - geometry: "rectangle", - title: "", - inverse: false, - x0, - y0, - dx: sx, - dy: sy, - }, - ]); + setEraseRegions([defaultImageRectangle(imageData)]); setSelectedRoiIndex(0); setActiveRoiVertex(null); setRoiDrawGeometry("rectangle"); @@ -5218,12 +5219,7 @@ export default function App() { imageRoi={imageEraseMode ? eraseRegions : imageRoi} bounds={ treeKind === "image" && imageData - ? { - xMin: imageData.x0, - xMax: imageData.x0 + imageData.width * imageData.dx, - yMin: imageData.y0, - yMax: imageData.y0 + imageData.height * imageData.dy, - } + ? imageExtent(imageData) : { xMin: data?.x[0] ?? 0, xMax: data?.x[(data?.x.length ?? 1) - 1] ?? 1, diff --git a/src/components/ImagePlot.tsx b/src/components/ImagePlot.tsx index 5c2e57a..baa9518 100644 --- a/src/components/ImagePlot.tsx +++ b/src/components/ImagePlot.tsx @@ -34,14 +34,20 @@ import { shouldUseLod, } from "../utils/imageLod"; import { - type ImageRasterPlacement, + type ImageRasterResult, RASTER_DEBOUNCE_MS, normalizeResampleMethod, + rasterLayoutImage, rasterizeImage, } from "../utils/imageRaster"; -import { toBins, binSearchCell } from "../utils/imageCoords"; +import { toBins } from "../utils/imageCoords"; import { relayoutViewRange } from "../utils/plotlyRelayout"; -import { pixelCenters } from "../utils/pixelCenters"; +import { + imageExtent, + pixelCenters, + pixelIndexAt, + pixelRangeOverlapping, +} from "../utils/pixelCenters"; import { usePersistedBool, IMAGE_GRID_PREF_KEY } from "../utils/persisted"; import type { AnalysisResult, @@ -316,34 +322,34 @@ export function ImagePlot({ () => (isUniform || !data.ycoords?.length ? null : toBins(data.ycoords)), [isUniform, data.ycoords], ); - // Physical extent of the image, used for axis ranges and crosshairs. - // For non-uniform images it spans the first/last bin edges; for uniform - // images it is the regular ``x0 + width·dx`` rectangle. - const extent = useMemo(() => { - if (xEdges && yEdges && xEdges.length > 1 && yEdges.length > 1) { - return { - xMin: xEdges[0], - xMax: xEdges[xEdges.length - 1], - yMin: yEdges[0], - yMax: yEdges[yEdges.length - 1], - }; - } - return { - xMin: data.x0, - xMax: data.x0 + data.width * data.dx, - yMin: data.y0, - yMax: data.y0 + data.height * data.dy, - }; - }, [ - xEdges, - yEdges, - data.x0, - data.y0, - data.width, - data.height, - data.dx, - data.dy, - ]); + // Physical extent of the image up to the outer edges of its pixels (``x0``/ + // ``y0`` and ``xcoords``/``ycoords`` are pixel centers), used for axis + // ranges and crosshairs. + const extent = useMemo( + () => + imageExtent({ + width: data.width, + height: data.height, + x0: data.x0, + y0: data.y0, + dx: data.dx, + dy: data.dy, + is_uniform_coords: data.is_uniform_coords, + xcoords: data.xcoords, + ycoords: data.ycoords, + }), + [ + data.width, + data.height, + data.x0, + data.y0, + data.dx, + data.dy, + data.is_uniform_coords, + data.xcoords, + data.ycoords, + ], + ); // ------------------------------------------------------------------ // Canvas rasterisation pipeline. @@ -394,11 +400,9 @@ export function ImagePlot({ h: 1024, }); const plotPxRef = useRef(plotPx); - // Physical placement of the (possibly windowed) bitmap, plus its pixel - // dimensions (``cw``/``ch``) so it can be sized as a ``layout.images`` - // background image. ``null`` falls back to the full-image geometry. - const [bitmapPlacement, setBitmapPlacement] = - useState(null); + // Rasterised (possibly windowed) bitmap with its physical placement and + // pixel dimensions, drawn as a ``layout.images`` background. + const [bitmap, setBitmap] = useState(null); // Reset the view whenever the underlying image changes so a new image // opens at full extent. useEffect(() => { @@ -422,13 +426,11 @@ export function ImagePlot({ const scaleratio = isUniform ? data.dy / data.dx : 1; return aspectFitRanges(winX, winY, plotPx, scaleratio); }, [viewRange, extent, isUniform, data.dx, data.dy, plotPx]); - const [bitmapUrl, setBitmapUrl] = useState(null); useEffect(() => { // Non-uniform images are rendered by a ``heatmap`` trace (see below), // not the canvas bitmap — skip the rasterisation entirely. if (data.is_uniform_coords === false) { - setBitmapUrl(null); - setBitmapPlacement(null); + setBitmap(null); return; } const w = data.width; @@ -457,8 +459,7 @@ export function ImagePlot({ inverted: colormapInverted, resampleMethod, }); - setBitmapUrl(raster?.source ?? null); - setBitmapPlacement(raster?.placement ?? null); + setBitmap(raster); }, RASTER_DEBOUNCE_MS); return () => window.clearTimeout(handle); }, [ @@ -529,10 +530,10 @@ export function ImagePlot({ }, []); const traces = useMemo(() => { - // Background image trace — rasterised by Plotly with a single - // ``drawImage`` from the blob URL. When ``bitmapUrl`` is still - // null (first paint or in-flight repaint) we omit the trace so - // only the axes/shapes layer is shown. + // Background image — rasterised by Plotly with a single ``drawImage`` + // from the data URL (see ``layoutImages``). While the bitmap is still + // null (first paint or in-flight repaint) only the axes/shapes layer is + // shown. const out: unknown[] = []; // ------------------------------------------------------------------ // Non-uniform images: exact ``heatmap`` rendering with bin edges. @@ -759,45 +760,16 @@ export function ImagePlot({ // Uniform-image bitmap drawn as a ``layout.images`` background. Unlike the // ``image`` trace it replaces, a layout image imposes no axis constraint, // so the axes stay free and both directions can be panned (square pixels - // are kept via the aspect-fitted ``displayRange``). ``bitmapPlacement`` - // carries the windowed sub-bitmap origin / per-cell spacing / pixel size so + // are kept via the aspect-fitted ``displayRange``). The raster placement + // carries the windowed sub-bitmap corner / per-cell spacing / pixel size so // the (possibly decimated) LOD bitmap lands on the right physical extent. - const layoutImages = useMemo(() => { - if (!bitmapUrl || data.is_uniform_coords === false) return []; - const p = bitmapPlacement ?? { - x0: data.x0, - dx: data.dx, - y0: data.y0, - dy: data.dy, - cw: data.width, - ch: data.height, - }; - return [ - { - source: bitmapUrl, - xref: "x" as const, - yref: "y" as const, - x: p.x0, - y: p.y0, - sizex: p.cw * p.dx, - sizey: p.ch * p.dy, - xanchor: "left" as const, - yanchor: "top" as const, - sizing: "stretch" as const, - layer: "below" as const, - }, - ]; - }, [ - bitmapUrl, - bitmapPlacement, - data.is_uniform_coords, - data.x0, - data.y0, - data.dx, - data.dy, - data.width, - data.height, - ]); + const layoutImages = useMemo( + () => + bitmap && data.is_uniform_coords !== false + ? [rasterLayoutImage(bitmap)] + : [], + [bitmap, data.is_uniform_coords], + ); const layout = useMemo(() => { const xtitle = data.xunit ? `${data.xlabel} (${data.xunit})` : data.xlabel; @@ -1024,16 +996,11 @@ export function ImagePlot({ dragEl.style.setProperty("cursor", cur, cur ? "important" : ""); } } - // Map data coords → cell indices. Uniform images use the regular - // grid; non-uniform images binary-search the bin edges so the - // reported ``z`` is exact (no quantisation). - const i = xEdges - ? binSearchCell(xEdges, xData) - : Math.floor((xData - data.x0) / data.dx); - const j = yEdges - ? binSearchCell(yEdges, yData) - : Math.floor((yData - data.y0) / data.dy); - if (i < 0 || i >= data.width || j < 0 || j >= data.height) { + // Map data coords → pixel indices: the pixel whose center is the + // closest (exact for non-uniform images too, no quantisation). + const i = pixelIndexAt(data, "x", xData); + const j = pixelIndexAt(data, "y", yData); + if (i < 0 || j < 0) { hoverOverlayRef.current?.setHover(null); return; } @@ -1042,7 +1009,7 @@ export function ImagePlot({ hoverOverlayRef.current?.setHover({ x: xData, y: yData, z, px, py }); if (tool === "profiles" && !frozen) setCursor({ x: xData, y: yData }); }, - [data, tool, xEdges, yEdges, frozen, roiEditMode, roi], + [data, tool, frozen, roiEditMode, roi], ); const handleWrapperMouseLeave = useCallback(() => { @@ -1438,43 +1405,33 @@ export function ImagePlot({ // ------------------------------------------------------------------ const profileData = useMemo(() => { if (tool !== "profiles" || !cursor) return null; - const col = Math.round((cursor.x - data.x0) / data.dx - 0.5); - const row = Math.round((cursor.y - data.y0) / data.dy - 0.5); - if (col < 0 || col >= data.width || row < 0 || row >= data.height) - return null; - const xs = Array.from( - { length: data.width }, - (_, i) => data.x0 + (i + 0.5) * data.dx, - ); - const ys = Array.from( - { length: data.height }, - (_, j) => data.y0 + (j + 0.5) * data.dy, - ); + const col = pixelIndexAt(data, "x", cursor.x); + const row = pixelIndexAt(data, "y", cursor.y); + if (col < 0 || row < 0) return null; + const xs = pixelCenters(data, "x"); + const ys = pixelCenters(data, "y"); const xProfile = data.data[row].slice(); const yProfile = data.data.map((r) => r[col]); return { xs, ys, xProfile, yProfile, row, col }; }, [tool, cursor, data]); // ------------------------------------------------------------------ - // Stats over the user-drawn rectangle (pure JS over data.data). + // Stats over the user-drawn rectangle (pure JS over data.data): every + // pixel the rectangle covers, even partly, as in DataLab desktop. // ------------------------------------------------------------------ const statsValues = useMemo(() => { if (tool !== "stats" || !statsRect) return null; - const i0 = Math.max( - 0, - Math.floor((Math.min(statsRect.x0, statsRect.x1) - data.x0) / data.dx), - ); - const i1 = Math.min( - data.width, - Math.ceil((Math.max(statsRect.x0, statsRect.x1) - data.x0) / data.dx), - ); - const j0 = Math.max( - 0, - Math.floor((Math.min(statsRect.y0, statsRect.y1) - data.y0) / data.dy), + const [i0, i1] = pixelRangeOverlapping( + data, + "x", + statsRect.x0, + statsRect.x1, ); - const j1 = Math.min( - data.height, - Math.ceil((Math.max(statsRect.y0, statsRect.y1) - data.y0) / data.dy), + const [j0, j1] = pixelRangeOverlapping( + data, + "y", + statsRect.y0, + statsRect.y1, ); if (i1 <= i0 || j1 <= j0) return null; let sum = 0; @@ -1625,7 +1582,7 @@ export function ImagePlot({ margin: { l: 60, r: 30, t: 5, b: 5 }, xaxis: { ...plotlyTheme.xaxis, - range: [data.x0, data.x0 + data.width * data.dx], + range: [extent.xMin, extent.xMax], showticklabels: false, }, yaxis: { @@ -1699,7 +1656,7 @@ export function ImagePlot({ }, yaxis: { ...plotlyTheme.yaxis, - range: [data.y0 + data.height * data.dy, data.y0], + range: [extent.yMax, extent.yMin], showticklabels: false, }, showlegend: false, diff --git a/src/components/MultiImagePlot.tsx b/src/components/MultiImagePlot.tsx index d46fbf2..1822482 100644 --- a/src/components/MultiImagePlot.tsx +++ b/src/components/MultiImagePlot.tsx @@ -23,6 +23,7 @@ import { memo, useEffect, useRef, useState } from "react"; import { paintImageData } from "../utils/colormap"; +import { axisToPhysical } from "../utils/pixelCenters"; import type { ImageData } from "../runtime/runtime"; /** Maximum number of images rendered in the grid. Each image's pixels @@ -160,19 +161,10 @@ function MiniImageImpl({ image }: { image: ImageData }) { setHover(null); return; } - // Non-uniform images carry explicit pixel-center coordinates; report - // those exactly instead of the default ``x0 + (i+0.5)·dx`` grid (which - // would otherwise collapse to raw pixel indices). - const xc = image.xcoords; - const yc = image.ycoords; - const x = - image.is_uniform_coords === false && xc?.length - ? xc[i] - : image.x0 + (i + 0.5) * image.dx; - const y = - image.is_uniform_coords === false && yc?.length - ? yc[j] - : image.y0 + (j + 0.5) * image.dy; + // Physical coordinates of the hovered pixel center (explicit coordinates + // for non-uniform images). + const x = axisToPhysical(image, "x", i); + const y = axisToPhysical(image, "y", j); const data = image.data; let z: number; if (Array.isArray(data) && data[0] instanceof Float32Array) { diff --git a/src/components/MultiImageSpatialPlot.tsx b/src/components/MultiImageSpatialPlot.tsx index 442aa2f..34e8a3b 100644 --- a/src/components/MultiImageSpatialPlot.tsx +++ b/src/components/MultiImageSpatialPlot.tsx @@ -45,9 +45,11 @@ import { type ImageRasterResult, RASTER_DEBOUNCE_MS, normalizeResampleMethod, + rasterLayoutImage, rasterizeImage, } from "../utils/imageRaster"; import { relayoutViewRange } from "../utils/plotlyRelayout"; +import { imageExtent, pixelIndexAt } from "../utils/pixelCenters"; import { usePersistedBool, IMAGE_GRID_PREF_KEY } from "../utils/persisted"; import { t } from "../i18n/translate"; import type { ImageData } from "../runtime/runtime"; @@ -91,35 +93,6 @@ const SpatialHoverOverlay = memo( }), ); -/** Physical extent of an image in data coordinates. */ -interface Extent { - xLo: number; - xHi: number; - yLo: number; - yHi: number; -} - -function imageExtent(img: ImageData): Extent { - if (img.is_uniform_coords === false && img.xcoords?.length) { - const xc = img.xcoords; - const yc = img.ycoords ?? []; - return { - xLo: Math.min(xc[0], xc[xc.length - 1]), - xHi: Math.max(xc[0], xc[xc.length - 1]), - yLo: Math.min(yc[0] ?? 0, yc[yc.length - 1] ?? 0), - yHi: Math.max(yc[0] ?? 0, yc[yc.length - 1] ?? 0), - }; - } - const x1 = img.x0 + img.width * img.dx; - const y1 = img.y0 + img.height * img.dy; - return { - xLo: Math.min(img.x0, x1), - xHi: Math.max(img.x0, x1), - yLo: Math.min(img.y0, y1), - yHi: Math.max(img.y0, y1), - }; -} - function MultiImageSpatialPlotImpl({ images, totalSelected }: Props) { const plotlyTheme = usePlotlyTheme(); @@ -142,10 +115,10 @@ function MultiImageSpatialPlotImpl({ images, totalSelected }: Props) { let yMax = -Infinity; for (const img of uniqueImages) { const e = imageExtent(img); - xMin = Math.min(xMin, e.xLo); - xMax = Math.max(xMax, e.xHi); - yMin = Math.min(yMin, e.yLo); - yMax = Math.max(yMax, e.yHi); + xMin = Math.min(xMin, e.xMin); + xMax = Math.max(xMax, e.xMax); + yMin = Math.min(yMin, e.yMin); + yMax = Math.max(yMax, e.yMax); } const mx = (xMax - xMin) * 0.02 || 1; const my = (yMax - yMin) * 0.02 || 1; @@ -269,27 +242,15 @@ function MultiImageSpatialPlotImpl({ images, totalSelected }: Props) { }, [uniqueImages]); // Uniform-image bitmaps drawn as ``layout.images`` backgrounds, positioned - // by each image's physical ``x0``/``y0``/``dx``/``dy`` in data coordinates. + // by each image's physical ``x0``/``y0``/``dx``/``dy`` in data coordinates + // (``x0``/``y0`` being the center of the first pixel). const layoutImages = useMemo(() => { const out: unknown[] = []; for (const img of uniqueImages) { if (img.is_uniform_coords === false) continue; const raster = rasters[img.id]; if (!raster) continue; - const placement = raster.placement; - out.push({ - source: raster.source, - xref: "x" as const, - yref: "y" as const, - x: placement.x0, - y: placement.y0, - sizex: placement.cw * placement.dx, - sizey: placement.ch * placement.dy, - xanchor: "left" as const, - yanchor: "top" as const, - sizing: "stretch" as const, - layer: "below" as const, - }); + out.push(rasterLayoutImage(raster)); } return out; }, [uniqueImages, rasters]); @@ -479,13 +440,9 @@ function MultiImageSpatialPlotImpl({ images, totalSelected }: Props) { // Topmost image (last drawn) wins. for (let k = uniqueImages.length - 1; k >= 0; k -= 1) { const img = uniqueImages[k]; - const e = imageExtent(img); - if (dataX < e.xLo || dataX > e.xHi || dataY < e.yLo || dataY > e.yHi) { - continue; - } - const i = Math.floor((dataX - img.x0) / img.dx); - const j = Math.floor((dataY - img.y0) / img.dy); - if (i < 0 || i >= img.width || j < 0 || j >= img.height) continue; + const i = pixelIndexAt(img, "x", dataX); + const j = pixelIndexAt(img, "y", dataY); + if (i < 0 || j < 0) continue; const d = img.data; let z: number; if (Array.isArray(d) && d[0] instanceof Float32Array) { diff --git a/src/components/ProcessingPreviewPlot.tsx b/src/components/ProcessingPreviewPlot.tsx index fd202d1..72150d7 100644 --- a/src/components/ProcessingPreviewPlot.tsx +++ b/src/components/ProcessingPreviewPlot.tsx @@ -6,7 +6,12 @@ import type { ProcessingPreviewResult } from "../runtime/runtime"; import { buildCurveTrace, getCurveStyle } from "../runtime/plotStyles"; import { buildColorscale } from "../utils/colormap"; import { toBins } from "../utils/imageCoords"; -import { rasterizeImage, normalizeResampleMethod } from "../utils/imageRaster"; +import { + normalizeResampleMethod, + rasterLayoutImage, + rasterizeImage, +} from "../utils/imageRaster"; +import { imageExtent } from "../utils/pixelCenters"; import { usePlotlyTheme } from "../utils/plotlyTheme"; import { reduceSignalForPlot } from "../utils/signalLod"; @@ -142,26 +147,8 @@ function ImagePreviewPlot({ result }: { result: ImagePreviewResult }) { }, }, ]; - const xEnd = data.x0 + data.width * data.dx; - const yEnd = data.y0 + data.height * data.dy; - const images = - raster && !nonUniform - ? [ - { - source: raster.source, - xref: "x" as const, - yref: "y" as const, - x: raster.placement.x0, - y: raster.placement.y0, - sizex: raster.placement.cw * raster.placement.dx, - sizey: raster.placement.ch * raster.placement.dy, - xanchor: "left" as const, - yanchor: "top" as const, - sizing: "stretch" as const, - layer: "below" as const, - }, - ] - : []; + const extent = imageExtent(data); + const images = raster && !nonUniform ? [rasterLayoutImage(raster)] : []; return ( ): number[] { edges[n] = centers[n - 1] + (centers[n - 1] - centers[n - 2]) / 2; return edges; } - -/** - * Find the index of the cell containing value ``v`` given monotonically - * increasing bin ``edges`` (length ``n + 1`` for ``n`` cells). - * - * Returns the cell index in ``[0, n - 1]``, or ``-1`` when ``v`` falls - * outside ``[edges[0], edges[n]]``. Cells are half-open ``[edge[i], - * edge[i + 1])`` except the last, which is closed on both ends so the exact - * right boundary maps to the final cell. - * - * @param edges Bin edges in increasing order (length ``n + 1``). - * @param v Value to locate. - * @returns Cell index, or ``-1`` if out of range. - */ -export function binSearchCell(edges: ArrayLike, v: number): number { - const n = edges.length - 1; - if (n < 1) return -1; - if (v < edges[0] || v > edges[n]) return -1; - // Binary search for the last edge ``<= v``. - let lo = 0; - let hi = n; - while (lo < hi) { - const mid = (lo + hi + 1) >> 1; - if (edges[mid] <= v) lo = mid; - else hi = mid - 1; - } - // ``lo`` is the largest index with ``edges[lo] <= v``; clamp to last cell - // so the closed right boundary belongs to cell ``n - 1``. - return Math.min(lo, n - 1); -} diff --git a/src/utils/imageLod.ts b/src/utils/imageLod.ts index 35338b4..0a22b7a 100644 --- a/src/utils/imageLod.ts +++ b/src/utils/imageLod.ts @@ -19,10 +19,10 @@ * ``data.data`` and are never affected by the LOD path. */ -/** Uniform-image geometry: pixel (i, j) covers physical X in - * ``[x0 + i*dx, x0 + (i+1)*dx)`` and Y in ``[y0 + j*dy, y0 + (j+1)*dy)``. - * ``x0``/``y0`` are the upper-left corner of the first pixel (the Plotly - * ``image``-trace convention, with no half-pixel offset). */ +/** Uniform-image geometry, following the Sigima convention: ``x0``/``y0`` are + * the physical coordinates of the **center** of the first pixel, so pixel + * (i, j) is centered on ``(x0 + i*dx, y0 + j*dy)`` and covers physical X in + * ``[x0 + (i - ½)*dx, x0 + (i + ½)*dx)`` (same for Y). */ export interface ImageGeometry { width: number; height: number; @@ -100,10 +100,12 @@ export function visibleWindow( if (view === null) { return { i0: 0, i1: width, j0: 0, j1: height }; } - const xa = (view.x[0] - x0) / dx; - const xb = (view.x[1] - x0) / dx; - const ya = (view.y[0] - y0) / dy; - const yb = (view.y[1] - y0) / dy; + // Fractional indices measured from the outer edge of the first pixel, so + // that pixel ``i`` spans ``[i, i + 1)``. + const xa = (view.x[0] - x0) / dx + 0.5; + const xb = (view.x[1] - x0) / dx + 0.5; + const ya = (view.y[0] - y0) / dy + 0.5; + const yb = (view.y[1] - y0) / dy + 0.5; let i0 = Math.floor(Math.min(xa, xb)); let i1 = Math.ceil(Math.max(xa, xb)); let j0 = Math.floor(Math.min(ya, yb)); @@ -157,12 +159,12 @@ export function rasterPlan( } /** - * Physical placement of the rasterised bitmap as a Plotly ``image`` trace. + * Physical placement of the rasterised bitmap as a Plotly layout image. * * Each output cell spans ``strideX × strideY`` source pixels, so the cell - * size is ``dx*strideX`` / ``dy*strideY`` and the origin is the upper-left - * corner of the first source pixel of the window — no half-pixel offset, as - * the Plotly ``image`` trace expects. + * size is ``dx*strideX`` / ``dy*strideY``, and the bitmap's upper-left corner + * ``(x, y)`` is the outer edge of the first source pixel of the window — half a + * pixel before its center. * * @param plan Rasterisation plan from :func:`rasterPlan`. * @param geom Uniform-image geometry. @@ -170,11 +172,11 @@ export function rasterPlan( export function windowPlacement( plan: RasterPlan, geom: ImageGeometry, -): { x0: number; dx: number; y0: number; dy: number } { +): { x: number; dx: number; y: number; dy: number } { return { - x0: geom.x0 + plan.i0 * geom.dx, + x: geom.x0 + (plan.i0 - 0.5) * geom.dx, dx: geom.dx * plan.strideX, - y0: geom.y0 + plan.j0 * geom.dy, + y: geom.y0 + (plan.j0 - 0.5) * geom.dy, dy: geom.dy * plan.strideY, }; } diff --git a/src/utils/imageRaster.ts b/src/utils/imageRaster.ts index 724c959..9559ec8 100644 --- a/src/utils/imageRaster.ts +++ b/src/utils/imageRaster.ts @@ -16,10 +16,13 @@ import { /** Delay before encoding a new bitmap after a range or LUT change. */ export const RASTER_DEBOUNCE_MS = 80; +/** Placement of a rasterised bitmap in physical coordinates: ``(x, y)`` is its + * upper-left corner (the outer edge of its first pixel), ``dx``/``dy`` the + * physical size of one bitmap cell and ``cw``/``ch`` its size in cells. */ export interface ImageRasterPlacement { - x0: number; + x: number; dx: number; - y0: number; + y: number; dy: number; cw: number; ch: number; @@ -144,17 +147,40 @@ export function rasterizeImage( }; } +/** Plotly ``layout.images`` entry drawing a rasterised bitmap below the data + * at its physical placement. A layout image (unlike an ``image`` trace) + * imposes no axis constraint, so both axes stay free to pan. */ +export function rasterLayoutImage(raster: ImageRasterResult) { + const { placement } = raster; + return { + source: raster.source, + xref: "x" as const, + yref: "y" as const, + x: placement.x, + y: placement.y, + sizex: placement.cw * placement.dx, + sizey: placement.ch * placement.dy, + xanchor: "left" as const, + yanchor: "top" as const, + sizing: "stretch" as const, + layer: "below" as const, + }; +} + function intersectsView( geometry: ImageGeometry, view: ViewRange | null, ): boolean { if (view === null) return true; - const x1 = geometry.x0 + geometry.width * geometry.dx; - const y1 = geometry.y0 + geometry.height * geometry.dy; - const imageX0 = Math.min(geometry.x0, x1); - const imageX1 = Math.max(geometry.x0, x1); - const imageY0 = Math.min(geometry.y0, y1); - const imageY1 = Math.max(geometry.y0, y1); + // Outer edges of the first and last pixels (``x0``/``y0`` are pixel centers). + const xa = geometry.x0 - 0.5 * geometry.dx; + const xb = xa + geometry.width * geometry.dx; + const ya = geometry.y0 - 0.5 * geometry.dy; + const yb = ya + geometry.height * geometry.dy; + const imageX0 = Math.min(xa, xb); + const imageX1 = Math.max(xa, xb); + const imageY0 = Math.min(ya, yb); + const imageY1 = Math.max(ya, yb); const viewX0 = Math.min(...view.x); const viewX1 = Math.max(...view.x); const viewY0 = Math.min(...view.y); diff --git a/src/utils/pixelCenters.ts b/src/utils/pixelCenters.ts index d7c24f4..5dbfd69 100644 --- a/src/utils/pixelCenters.ts +++ b/src/utils/pixelCenters.ts @@ -1,35 +1,210 @@ /** - * 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``. + * Image axes 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``. + * Pixel ``i`` is centered on fractional index ``i`` and spans the indices + * ``[i - ½, i + ½]``, so the image spans ``[x0 - dx/2, x0 + (width - ½)·dx]``. * - * Sigima computations (profiles, ROIs, analyses) use these coordinates. + * Sigima computations (profiles, ROIs, analyses) use these coordinates: every + * conversion between physical coordinates and pixels in the viewers goes + * through these helpers, which mirror ``ImageObj.axis_to_float_indices`` and + * ``ImageObj.axis_to_physical``. */ 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[] { +/** Image fields describing its axes. */ +export type ImageAxes = Pick< + ImageData, + | "width" + | "height" + | "x0" + | "y0" + | "dx" + | "dy" + | "is_uniform_coords" + | "xcoords" + | "ycoords" +>; + +export type Axis = "x" | "y"; + +/** Tolerance (in pixel index units) under which a coordinate is considered to + * lie on a pixel boundary, so that rounding noise never adds or removes a + * pixel (Sigima's ``PIXEL_INDEX_TOLERANCE``). */ +export const PIXEL_INDEX_TOLERANCE = 1e-9; + +/** Number of pixels along an axis. */ +function axisSize(img: ImageAxes, axis: Axis): number { + return axis === "x" ? img.width : img.height; +} + +/** Pixel-center coordinates of a non-uniform axis, or ``null`` when the axis + * is uniform (regular ``x0``/``dx`` grid). */ +function nonUniformCenters(img: ImageAxes, axis: Axis): number[] | null { 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) { + if (img.is_uniform_coords === false && coords?.length === axisSize(img, axis)) return coords; + return null; +} + +/** Fractional index of ``v`` among monotonic (increasing or decreasing) + * pixel centers, extrapolated from the first and last intervals beyond the + * ends (slope 1 with a single center). Mirrors Sigima's + * ``_extrapolated_interp`` with the indices as sample values. */ +function floatIndexFromCenters(centers: ArrayLike, v: number): number { + const n = centers.length; + if (n === 0) return NaN; + if (n === 1) return v - centers[0]; + const ascending = centers[n - 1] >= centers[0]; + // Largest interval start ``k`` in ``[0, n - 2]`` whose center precedes ``v``. + let lo = 0; + let hi = n - 2; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (ascending ? centers[mid] <= v : centers[mid] >= v) lo = mid; + else hi = mid - 1; } + return lo + (v - centers[lo]) / (centers[lo + 1] - centers[lo]); +} + +/** Physical coordinate of fractional index ``index`` among pixel centers, + * extrapolated from the first and last intervals (inverse of + * :func:`floatIndexFromCenters`). */ +function physicalFromCenters( + centers: ArrayLike, + index: number, +): number { + const n = centers.length; + if (n === 0) return NaN; + if (n === 1) return centers[0] + index; + const k = Math.min(n - 2, Math.max(0, Math.floor(index))); + return centers[k] + (index - k) * (centers[k + 1] - centers[k]); +} + +/** Physical coordinates of the pixel centers along an axis. */ +export function pixelCenters(img: ImageAxes, axis: Axis): number[] { + const centers = nonUniformCenters(img, axis); + if (centers) return centers; 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); + return Array.from( + { length: axisSize(img, axis) }, + (_, i) => origin + i * step, + ); +} + +/** + * Convert a physical coordinate along one axis to a fractional pixel index. + * + * Pixel ``i`` is centered on index ``i``. Values outside the image are + * extrapolated linearly (first / last pixel spacing for non-uniform axes). + */ +export function axisToFloatIndex( + img: ImageAxes, + axis: Axis, + v: number, +): number { + const centers = nonUniformCenters(img, axis); + if (centers) return floatIndexFromCenters(centers, v); + const origin = axis === "x" ? img.x0 : img.y0; + const step = axis === "x" ? img.dx : img.dy; + return (v - origin) / step; +} + +/** + * Convert a fractional pixel index along one axis to a physical coordinate. + * + * Inverse of :func:`axisToFloatIndex`: index ``i - ½`` is the boundary between + * pixels ``i - 1`` and ``i``. + */ +export function axisToPhysical( + img: ImageAxes, + axis: Axis, + index: number, +): number { + const centers = nonUniformCenters(img, axis); + if (centers) return physicalFromCenters(centers, index); + const origin = axis === "x" ? img.x0 : img.y0; + const step = axis === "x" ? img.dx : img.dy; + return origin + index * step; +} + +/** Physical coordinates of the ``n + 1`` pixel boundaries along an axis, from + * the outer edge of the first pixel to the outer edge of the last one (the + * bin edges of PlotPy's ``to_bins`` for non-uniform axes). */ +export function pixelEdges(img: ImageAxes, axis: Axis): number[] { + return Array.from({ length: axisSize(img, axis) + 1 }, (_, i) => + axisToPhysical(img, axis, i - 0.5), + ); +} + +/** Physical extent of an image, up to the outer edges of its pixels. */ +export interface ImageExtent { + xMin: number; + xMax: number; + yMin: number; + yMax: number; +} + +/** Physical extent of an image, up to the outer edges of its pixels. */ +export function imageExtent(img: ImageAxes): ImageExtent { + const bounds = (axis: Axis) => { + const a = axisToPhysical(img, axis, -0.5); + const b = axisToPhysical(img, axis, axisSize(img, axis) - 0.5); + return [Math.min(a, b), Math.max(a, b)]; + }; + const [xMin, xMax] = bounds("x"); + const [yMin, yMax] = bounds("y"); + return { xMin, xMax, yMin, yMax }; +} + +/** Center of the pixel grid, midway between the centers of the first and last + * columns / rows (Sigima's ``ImageObj.xc``/``yc``). */ +export function imageCenter(img: ImageAxes): { x: number; y: number } { + const center = (axis: Axis) => + (axisToPhysical(img, axis, 0) + + axisToPhysical(img, axis, axisSize(img, axis) - 1)) / + 2; + return { x: center("x"), y: center("y") }; +} + +/** + * Index of the pixel containing a physical coordinate along one axis. + * + * The pixel is the one whose center is the closest (``floor(f + ½)``, as in + * Sigima's ``physical_to_indices``; a boundary belongs to the next pixel). + * Returns ``-1`` outside the outer edges of the image (edges included). + */ +export function pixelIndexAt(img: ImageAxes, axis: Axis, v: number): number { + const n = axisSize(img, axis); + const f = axisToFloatIndex(img, axis, v); + if ( + !Number.isFinite(f) || + f < -0.5 - PIXEL_INDEX_TOLERANCE || + f > n - 0.5 + PIXEL_INDEX_TOLERANCE + ) + return -1; + return Math.min(n - 1, Math.max(0, Math.floor(f + 0.5))); +} + +/** + * Pixels covered, even partly, by the physical interval ``[a, b]`` along one + * axis (bounds in any order), clipped to the image. + * + * @returns ``[i0, i1]``: pixels ``i0`` to ``i1 - 1`` (empty when + * ``i1 <= i0``). + */ +export function pixelRangeOverlapping( + img: ImageAxes, + axis: Axis, + a: number, + b: number, +): [number, number] { + const n = axisSize(img, axis); + const fa = axisToFloatIndex(img, axis, a) + 0.5; + const fb = axisToFloatIndex(img, axis, b) + 0.5; + const i0 = Math.floor(Math.min(fa, fb) + PIXEL_INDEX_TOLERANCE); + const i1 = Math.ceil(Math.max(fa, fb) - PIXEL_INDEX_TOLERANCE); + return [Math.max(0, i0), Math.min(n, i1)]; } diff --git a/tests/e2e/image-lod.spec.ts b/tests/e2e/image-lod.spec.ts index e49c839..141a155 100644 --- a/tests/e2e/image-lod.spec.ts +++ b/tests/e2e/image-lod.spec.ts @@ -142,8 +142,10 @@ test.describe("Image LOD display", () => { // The PNG is decimated to ~viewport size, far below the 2048² source. expect(zoomedOut.pngWidth).toBeGreaterThan(0); expect(zoomedOut.pngWidth).toBeLessThan(SIDE); - expect(zoomedOut.x).toBe(0); - expect(zoomedOut.y).toBe(0); + // ``x0 = y0 = 0`` is the center of the first pixel: the bitmap starts at + // its outer edge, half a pixel before. + expect(zoomedOut.x).toBe(-0.5); + expect(zoomedOut.y).toBe(-0.5); expect(zoomedOut.sizex).toBeGreaterThanOrEqual(SIDE); expect(zoomedOut.sizey).toBeGreaterThanOrEqual(SIDE); // Stride > 1 → each display cell spans several native pixels. @@ -195,7 +197,8 @@ test.describe("Image LOD display", () => { expect(zoomedIn.dx).toBeLessThanOrEqual(1.5); expect(zoomedIn.pngWidth).toBeGreaterThan(0); expect(zoomedIn.pngWidth).toBeLessThan(zoomedOut.pngWidth); - expect(Number.isInteger(zoomedIn.x)).toBe(true); - expect(Number.isInteger(zoomedIn.y)).toBe(true); + // The windowed bitmap starts on a pixel edge (center ± 0.5). + expect(Number.isInteger(zoomedIn.x + 0.5)).toBe(true); + expect(Number.isInteger(zoomedIn.y + 0.5)).toBe(true); }); }); diff --git a/tests/e2e/multi-image-spatial.spec.ts b/tests/e2e/multi-image-spatial.spec.ts index 417f32f..77dd644 100644 --- a/tests/e2e/multi-image-spatial.spec.ts +++ b/tests/e2e/multi-image-spatial.spec.ts @@ -259,11 +259,13 @@ test("spatial multi-image: four 2048² images use viewport LOD and exact zoom", const initial = await readSpatialBitmaps(page); // Primary image first, then the remaining selected images. Layout-image // array order is the z-order used by Plotly (later entries draw on top). + // Each bitmap starts at the outer edge of its first pixel, half a pixel + // before ``x0``/``y0`` (pixel centers), decimated previews included. expect(initial.map(({ x, y }) => [x, y])).toEqual([ - [LOD_SIDE, LOD_SIDE], - [0, 0], - [LOD_SIDE, 0], - [0, LOD_SIDE], + [LOD_SIDE - 0.5, LOD_SIDE - 0.5], + [-0.5, -0.5], + [LOD_SIDE - 0.5, -0.5], + [-0.5, LOD_SIDE - 0.5], ]); expect( initial.every(({ width, height }) => width <= 512 && height <= 512), diff --git a/tests/python/test_image_creation.py b/tests/python/test_image_creation.py index 273edc4..b04ae74 100644 --- a/tests/python/test_image_creation.py +++ b/tests/python/test_image_creation.py @@ -156,21 +156,26 @@ def test_get_image_data_bytes_encoding_round_trips(fresh_bootstrap): def test_get_image_data_max_size_decimates_and_adjusts_spacing(fresh_bootstrap): """``max_size`` decimates the image and rescales ``dx``/``dy``. - The downsampled view must keep the same physical extent so the - multi-image grid renders at the correct world coordinates. LUT - extrema are computed on the *full* image so the colour scale stays - representative. + The downsampled view must keep the same physical extent, up to the outer + edges of the pixels (``x0``/``y0`` are pixel centers), so the multi-image + views render it at the correct world coordinates. LUT extrema are + computed on the *full* image so the colour scale stays representative. """ bs = fresh_bootstrap - arr = np.arange(40000, dtype=float).reshape(200, 200) + arr = np.arange(201 * 199, dtype=float).reshape(201, 199) oid = bs.add_image_from_array("big", arr) + obj = bs._MODEL.get(oid) + obj.set_uniform_coords(0.5, 2.0, x0=10.0, y0=-3.0) full = bs.get_image_data(oid) small = bs.get_image_data(oid, max_size=50) assert max(small["width"], small["height"]) <= 50 - # Physical extent preserved within one stride. - assert small["width"] * small["dx"] == pytest.approx( - full["width"] * full["dx"], rel=0.05 - ) + # Physical extent preserved exactly, from the outer edge of the first + # pixel to the outer edge of the last one. + for x0, dx, size in (("x0", "dx", "width"), ("y0", "dy", "height")): + assert small[x0] - small[dx] / 2 == pytest.approx(full[x0] - full[dx] / 2) + assert small[x0] + (small[size] - 0.5) * small[dx] == pytest.approx( + full[x0] + (full[size] - 0.5) * full[dx] + ) # LUT extrema unchanged (computed before decimation). assert small["data_min"] == full["data_min"] assert small["data_max"] == full["data_max"] diff --git a/tests/ts/components/ImagePlot.test.tsx b/tests/ts/components/ImagePlot.test.tsx index 9ed32c5..2b7e77a 100644 --- a/tests/ts/components/ImagePlot.test.tsx +++ b/tests/ts/components/ImagePlot.test.tsx @@ -13,12 +13,14 @@ import { ThemeProvider } from "../../../src/utils/theme"; const plotState = vi.hoisted(() => ({ props: null as Record | null, + all: [] as Record[], renderCount: 0, })); vi.mock("react-plotly.js", () => ({ default: (props: Record) => { plotState.props = props; + plotState.all.push(props); plotState.renderCount += 1; return
; }, @@ -41,7 +43,9 @@ beforeEach(() => { afterEach(() => { cleanup(); vi.restoreAllMocks(); + vi.useRealTimers(); plotState.props = null; + plotState.all = []; plotState.renderCount = 0; }); @@ -197,3 +201,173 @@ describe("ImagePlot title editing", () => { expect(plotState.renderCount).toBe(renderCount); }); }); + +// --------------------------------------------------------------------------- +// Pixel-center convention: ``x0``/``y0`` are the centers of the first pixel +// (Sigima), so pixel ``i`` covers ``[x0 + (i - ½)·dx, x0 + (i + ½)·dx)``. +// --------------------------------------------------------------------------- + +/** 4×4 ramp ``z = 4·j + i`` with pixel centers at 0, 1, 2, 3 (web#69). */ +const RAMP: ImageData = { + ...IMAGE, + id: "ramp", + width: 4, + height: 4, + data: [ + [0, 1, 2, 3], + [4, 5, 6, 7], + [8, 9, 10, 11], + [12, 13, 14, 15], + ], + data_max: 15, +}; + +/** 4×3 non-uniform image (pixel centers in ``xcoords``/``ycoords``). */ +const NON_UNIFORM: ImageData = { + ...RAMP, + id: "non-uniform", + height: 3, + data: RAMP.data.slice(0, 3), + data_max: 11, + is_uniform_coords: false, + xcoords: [0, 1.5, 4, 9], + ycoords: [0, 2, 5], +}; + +/** Main image plot props (the cross-section plots have no + * ``onInitialized`` handler). */ +function mainPlot() { + const main = plotState.all.filter( + (props) => typeof props.onInitialized === "function", + ); + return main.at(-1) as ReturnType; +} + +/** Connect a fake Plotly graph div whose data coordinates are the client + * coordinates divided by 10, then hover the image at ``(x, y)``. */ +function hoverAt(x: number, y: number) { + const graphDiv = Object.assign(document.createElement("div"), { + _fullLayout: { + xaxis: { p2c: (v: number) => v / 10, _offset: 0, _length: 100 }, + yaxis: { p2c: (v: number) => v / 10, _offset: 0, _length: 100 }, + }, + on: vi.fn(), + }); + vi.spyOn(graphDiv, "getBoundingClientRect").mockReturnValue({ + left: 0, + top: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }); + act(() => mainPlot().onInitialized({}, graphDiv)); + fireEvent.mouseMove(document.querySelector(".image-plot-host")!, { + clientX: x * 10, + clientY: y * 10, + buttons: 0, + }); +} + +function hoverText(): string { + return document.querySelector(".image-hover-tooltip")?.textContent ?? ""; +} + +describe("ImagePlot pixel-center convention", () => { + it("centers the pixels of the bitmap on x0 + i·dx", () => { + vi.useFakeTimers(); + render(plotElement({ data: RAMP })); + act(() => vi.advanceTimersByTime(200)); + const layout = currentPlot().layout as unknown as { + images: Array>; + }; + expect(layout.images).toHaveLength(1); + expect(layout.images[0]).toMatchObject({ + x: -0.5, + y: -0.5, + sizex: 4, + sizey: 4, + }); + }); + + it("places a calibrated image from the outer edge of its first pixel", () => { + vi.useFakeTimers(); + const calibrated = { ...RAMP, x0: 10, y0: -3, dx: 0.5, dy: 2 }; + render(plotElement({ data: calibrated })); + act(() => vi.advanceTimersByTime(200)); + const layout = currentPlot().layout as unknown as { + images: Array>; + }; + expect(layout.images[0]).toMatchObject({ + x: 9.75, + y: -4, + sizex: 2, + sizey: 8, + }); + }); + + it("reads the value of the pixel whose center is the closest", () => { + renderPlot({ data: RAMP }); + hoverAt(0.4, 0.4); + expect(hoverText()).toMatch(/z: 0(\.0+)?$/); + hoverAt(0.6, 0.4); + expect(hoverText()).toMatch(/z: 1(\.0+)?$/); + hoverAt(0.6, 0.6); + expect(hoverText()).toMatch(/z: 5(\.0+)?$/); + // The outer half of the first pixel belongs to the image. + hoverAt(-0.4, -0.4); + expect(hoverText()).toMatch(/z: 0(\.0+)?$/); + }); + + it("computes stats over the pixels the rectangle covers", () => { + renderPlot({ data: RAMP }); + fireEvent.click(screen.getByRole("button", { name: "Stats area" })); + // [0.6, 1.4]² lies inside pixel (1, 1), which spans [0.5, 1.5]². + act(() => + currentPlot().onRelayout({ + shapes: [{ type: "rect", x0: 0.6, x1: 1.4, y0: 0.6, y1: 1.4 }], + }), + ); + const stats = document.querySelector(".image-stats-overlay-body"); + expect(stats?.textContent).toMatch(/n: 1(?!\d)/); + expect(stats?.textContent).toMatch(/mean: 5/); + // [0.2, 1.7] × [0.6, 1.4] covers columns 0 to 2 of row 1. + act(() => + currentPlot().onRelayout({ + shapes: [{ type: "rect", x0: 0.2, x1: 1.7, y0: 0.6, y1: 1.4 }], + }), + ); + expect( + document.querySelector(".image-stats-overlay-body")?.textContent, + ).toMatch(/n: 3(?!\d)/); + }); + + it("draws cross-sections of non-uniform images on their pixel centers", () => { + renderPlot({ data: NON_UNIFORM }); + fireEvent.click(screen.getByRole("button", { name: "Cross profiles" })); + // Pixel (2, 1): x in [2.75, 6.5), y in [1, 3.5). + hoverAt(4.2, 2.1); + // The horizontal cross-section is the plot whose X axis is aligned with + // the image (fixed range, no tick labels). + type ProfileProps = { + data: Array>; + layout: { xaxis: { range?: number[]; showticklabels?: boolean } }; + }; + const xProfile = (plotState.all as ProfileProps[]) + .filter( + (props) => + !("onInitialized" in props) && + props.layout.xaxis.range !== undefined && + props.layout.xaxis.showticklabels === false, + ) + .at(-1); + expect(xProfile).toBeDefined(); + const trace = xProfile!.data[0]; + expect(trace.x).toEqual([0, 1.5, 4, 9]); + expect(trace.y).toEqual([4, 5, 6, 7]); + expect(xProfile!.layout.xaxis.range).toEqual([-0.75, 11.5]); + }); +}); diff --git a/tests/ts/components/MultiImageSpatialPlot.test.tsx b/tests/ts/components/MultiImageSpatialPlot.test.tsx index 9d23651..d545a73 100644 --- a/tests/ts/components/MultiImageSpatialPlot.test.tsx +++ b/tests/ts/components/MultiImageSpatialPlot.test.tsx @@ -145,21 +145,61 @@ describe("MultiImageSpatialPlot", () => { await waitFor(() => expect(readLayout(container).images).toHaveLength(2)); const imgs = readLayout(container).images ?? []; expect(imgs).toHaveLength(2); - expect(imgs.map((m) => m.x).sort((p, q) => p - q)).toEqual([0, 10]); - expect(imgs.map((m) => m.y).sort((p, q) => p - q)).toEqual([0, 5]); + // ``x0``/``y0`` are the centers of the first pixel: each bitmap starts + // half a pixel before them. + expect(imgs.map((m) => m.x).sort((p, q) => p - q)).toEqual([-0.5, 9.5]); + expect(imgs.map((m) => m.y).sort((p, q) => p - q)).toEqual([-0.5, 4.5]); }); it("spans the axis range across all images (Y reversed)", () => { const images = [makeImage("a", 0, 0), makeImage("b", 10, 5)]; const { container } = renderSpatial(images, 2); const { xaxis, yaxis } = readLayout(container); - // X spans 0 .. 12 (10 + width*dx) plus margin. - expect(xaxis.range[0]).toBeLessThanOrEqual(0); - expect(xaxis.range[1]).toBeGreaterThanOrEqual(12); + // X spans -0.5 .. 11.5 (outer pixel edges) plus margin. + expect(xaxis.range[0]).toBeLessThanOrEqual(-0.5); + expect(xaxis.range[1]).toBeGreaterThanOrEqual(11.5); // Y axis is reversed for image orientation: range[0] > range[1]. expect(yaxis.range[0]).toBeGreaterThan(yaxis.range[1]); }); + it("reads the value of the pixel whose center is the closest", () => { + const images = [makeImage("a", 0, 0)]; + const { container } = renderSpatial(images, 1); + const graphDiv = Object.assign(document.createElement("div"), { + _fullLayout: { + xaxis: { p2c: (v: number) => v / 10, _offset: 0, _length: 100 }, + yaxis: { p2c: (v: number) => v / 10, _offset: 0, _length: 100 }, + }, + }); + vi.spyOn(graphDiv, "getBoundingClientRect").mockReturnValue({ + left: 0, + top: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }); + const plot = plotState.props as { + onInitialized: (figure: unknown, graphDiv: unknown) => void; + }; + act(() => plot.onInitialized({}, graphDiv)); + const hoverAt = (x: number, y: number) => { + fireEvent.mouseMove( + container.querySelector(".multi-image-spatial-wrap")!, + { clientX: x * 10, clientY: y * 10, buttons: 0 }, + ); + return container.querySelector(".multi-image-hover")?.textContent ?? ""; + }; + // Pixel (1, 0) spans [0.5, 1.5) × [-0.5, 0.5). + expect(hoverAt(0.6, 0.4)).toMatch(/z: 1(\.0+)?$/); + expect(hoverAt(0.4, 0.6)).toMatch(/z: 2(\.0+)?$/); + // The outer half of the first pixel belongs to the image. + expect(hoverAt(-0.4, -0.4)).toMatch(/z: 0(\.0+)?$/); + }); + it("shows the '+N more' banner when the selection exceeds the shown set", () => { const images = [makeImage("a", 0, 0), makeImage("b", 10, 0)]; const { container } = renderSpatial(images, 5); @@ -247,10 +287,10 @@ describe("MultiImageSpatialPlot", () => { const initial = readLayout(container).images!; expect(initial.map(({ x, y }) => [x, y])).toEqual([ - [0, 0], - [2048, 0], - [0, 2048], - [2048, 2048], + [-0.5, -0.5], + [2047.5, -0.5], + [-0.5, 2047.5], + [2047.5, 2047.5], ]); expect(initial.map(({ source }) => source)).toEqual([ "data:image/png;base64,STUB1", @@ -272,18 +312,19 @@ describe("MultiImageSpatialPlot", () => { const plot = plotState.props as { onRelayout: (event: Record) => void; }; + // Pixels 10…25 of the top-right image (centers 2058…2073), rows 0…15. act(() => plot.onRelayout({ - "xaxis.range[0]": 2058, - "xaxis.range[1]": 2074, - "yaxis.range[0]": 16, - "yaxis.range[1]": 0, + "xaxis.range[0]": 2057.5, + "xaxis.range[1]": 2073.5, + "yaxis.range[0]": 15.5, + "yaxis.range[1]": -0.5, }), ); await waitFor(() => expect(readLayout(container).images).toHaveLength(1)); const zoomed = readLayout(container).images![0]; - expect(zoomed.x).toBe(2058); - expect(zoomed.y).toBe(0); + expect(zoomed.x).toBe(2057.5); + expect(zoomed.y).toBe(-0.5); expect(zoomed.sizex).toBe(16); expect(zoomed.sizey).toBe(16); expect(rasterSizes.at(-1)).toEqual({ width: 16, height: 16 }); diff --git a/tests/ts/utils/imageCoords.test.ts b/tests/ts/utils/imageCoords.test.ts index 674a421..ba864b4 100644 --- a/tests/ts/utils/imageCoords.test.ts +++ b/tests/ts/utils/imageCoords.test.ts @@ -2,12 +2,11 @@ * Unit tests for the non-uniform image coordinate helpers. * * ``toBins`` must mirror PlotPy's ``to_bins`` bit-for-bit so DataLab-Web and - * DataLab desktop place cell boundaries identically; ``binSearchCell`` must - * locate the cell containing a hovered data coordinate (used for the exact - * ``z`` tooltip readout). + * DataLab desktop place cell boundaries identically. Locating the pixel under + * a coordinate is covered by ``pixelCenters.test.ts``. */ import { describe, it, expect } from "vitest"; -import { toBins, binSearchCell } from "../../../src/utils/imageCoords"; +import { toBins } from "../../../src/utils/imageCoords"; describe("toBins", () => { it("matches the PlotPy reference edges for the X axis", () => { @@ -37,36 +36,3 @@ describe("toBins", () => { expect(toBins([0, 2, 5]).length).toBe(4); }); }); - -describe("binSearchCell", () => { - const xEdges = [-0.75, 0.75, 2.75, 6.5, 11.5]; - - it("locates interior cells", () => { - expect(binSearchCell(xEdges, 0)).toBe(0); - expect(binSearchCell(xEdges, 1.5)).toBe(1); - expect(binSearchCell(xEdges, 4)).toBe(2); - expect(binSearchCell(xEdges, 9)).toBe(3); - }); - - it("treats cells as half-open on the left boundary", () => { - // Each interior edge belongs to the cell on its right. - expect(binSearchCell(xEdges, 0.75)).toBe(1); - expect(binSearchCell(xEdges, 2.75)).toBe(2); - expect(binSearchCell(xEdges, 6.5)).toBe(3); - }); - - it("includes both outer boundaries", () => { - expect(binSearchCell(xEdges, -0.75)).toBe(0); - expect(binSearchCell(xEdges, 11.5)).toBe(3); - }); - - it("returns -1 outside the extent", () => { - expect(binSearchCell(xEdges, -1)).toBe(-1); - expect(binSearchCell(xEdges, 12)).toBe(-1); - }); - - it("returns -1 for degenerate edge arrays", () => { - expect(binSearchCell([], 0)).toBe(-1); - expect(binSearchCell([1], 1)).toBe(-1); - }); -}); diff --git a/tests/ts/utils/imageLod.test.ts b/tests/ts/utils/imageLod.test.ts index d3005f7..863d86c 100644 --- a/tests/ts/utils/imageLod.test.ts +++ b/tests/ts/utils/imageLod.test.ts @@ -3,7 +3,8 @@ * * These pure functions decide which source pixels are visible, at what * stride they are rasterised, and where the resulting bitmap is placed. - * Pixel-coordinate exactness matters: an off-by-half-pixel here would shift + * Pixel-coordinate exactness matters: ``x0``/``y0`` are the centers of the + * first pixel (Sigima convention), and an off-by-half-pixel here would shift * the displayed image relative to overlays, ROIs and the (full-resolution) * hover read-out. The windowed paint helper is also checked to confirm the * three resampling methods aggregate the **data** (not colours) correctly. @@ -57,13 +58,20 @@ describe("visibleWindow", () => { }); it("maps a zoom rectangle to pixel indices (Y reversed)", () => { - // Plotly reports Y reversed; the helper normalises with min/max. - const win = visibleWindow(GEOM, { x: [100, 200], y: [300, 200] }); + // Plotly reports Y reversed; the helper normalises with min/max. Pixel + // ``i`` is centered on ``i`` and covers ``[i - 0.5, i + 0.5)``. + const win = visibleWindow(GEOM, { x: [99.5, 199.5], y: [299.5, 199.5] }); expect(win).toEqual({ i0: 100, i1: 200, j0: 200, j1: 300 }); }); + it("includes the pixels whose half lies in the view", () => { + // [100, 200] covers the right half of pixel 100 and the left half of 200. + const win = visibleWindow(GEOM, { x: [100, 200], y: [300, 200] }); + expect(win).toEqual({ i0: 100, i1: 201, j0: 200, j1: 301 }); + }); + it("clamps a partially out-of-bounds view to the image", () => { - const win = visibleWindow(GEOM, { x: [-50, 50], y: [50, -50] }); + const win = visibleWindow(GEOM, { x: [-50, 49.5], y: [49.5, -50] }); expect(win).toEqual({ i0: 0, i1: 50, j0: 0, j1: 50 }); }); @@ -75,7 +83,7 @@ describe("visibleWindow", () => { }); it("covers a sub-pixel view with at least one pixel", () => { - const win = visibleWindow(GEOM, { x: [100.3, 100.7], y: [100.7, 100.3] }); + const win = visibleWindow(GEOM, { x: [99.8, 100.2], y: [100.2, 99.8] }); expect(win).toEqual({ i0: 100, i1: 101, j0: 100, j1: 101 }); }); @@ -88,8 +96,9 @@ describe("visibleWindow", () => { dx: 0.5, dy: 2, }; - // x: [20, 30] → (x-10)/0.5 = [20, 40]; y: [60, 40] → (y-20)/2 = [20, 10]. - const win = visibleWindow(geom, { x: [20, 30], y: [60, 40] }); + // x: [19.75, 29.75] → (x-10)/0.5 + 0.5 = [20, 40]; + // y: [59, 39] → (y-20)/2 + 0.5 = [20, 10]. + const win = visibleWindow(geom, { x: [19.75, 29.75], y: [59, 39] }); expect(win).toEqual({ i0: 20, i1: 40, j0: 10, j1: 20 }); }); }); @@ -136,12 +145,12 @@ describe("rasterPlan", () => { }); describe("windowPlacement", () => { - it("places a 1:1 window at the exact pixel origin", () => { + it("places a 1:1 window at the outer edge of its first pixel", () => { const plan = { i0: 100, j0: 200, cw: 10, ch: 15, strideX: 1, strideY: 1 }; expect(windowPlacement(plan, GEOM)).toEqual({ - x0: 100, + x: 99.5, dx: 1, - y0: 200, + y: 199.5, dy: 1, }); }); @@ -149,9 +158,9 @@ describe("windowPlacement", () => { it("scales the cell size by the stride when decimating", () => { const plan = { i0: 0, j0: 0, cw: 820, ch: 820, strideX: 5, strideY: 5 }; expect(windowPlacement(plan, GEOM)).toEqual({ - x0: 0, + x: -0.5, dx: 5, - y0: 0, + y: -0.5, dy: 5, }); }); @@ -167,19 +176,65 @@ describe("windowPlacement", () => { }; const plan = { i0: 100, j0: 10, cw: 5, ch: 5, strideX: 2, strideY: 3 }; expect(windowPlacement(plan, geom)).toEqual({ - x0: 10 + 100 * 0.5, + x: 10 + (100 - 0.5) * 0.5, dx: 0.5 * 2, - y0: 20 + 10 * 2, + y: 20 + (10 - 0.5) * 2, dy: 2 * 3, }); }); }); +describe("pixel-center convention (x0/y0 = center of the first pixel)", () => { + it("draws a 4×4 image with x0 = y0 = 0 over [-0.5, 3.5]", () => { + // Sigima pixel centers are at 0, 1, 2, 3: the image spans [-0.5, 3.5]. + const geometry = { width: 4, height: 4, x0: 0, y0: 0, dx: 1, dy: 1 }; + const raster = planImageRaster(geometry, null, { w: 400, h: 400 }, 1); + expect(raster?.placement).toEqual({ + x: -0.5, + dx: 1, + y: -0.5, + dy: 1, + cw: 4, + ch: 4, + }); + }); + + it("centers the pixels of a calibrated image on x0 + i·dx", () => { + const geometry = { width: 8, height: 6, x0: 10, y0: -3, dx: 0.5, dy: 2 }; + const raster = planImageRaster(geometry, null, { w: 400, h: 400 }, 1); + const p = raster!.placement; + // Center of output cell ``i`` = x + (i + 0.5)·dx = x0 + i·dx. + expect(p.x + 0.5 * p.dx).toBe(10); + expect(p.y + 0.5 * p.dy).toBe(-3); + expect(p.x + p.cw * p.dx).toBe(10 + 7.5 * 0.5); + expect(p.y + p.ch * p.dy).toBe(-3 + 5.5 * 2); + }); + + it("keeps an image whose outer half-pixel only is in view", () => { + const geometry = { width: 4, height: 4, x0: 0, y0: 0, dx: 1, dy: 1 }; + const view = { + x: [-2, -0.25] as [number, number], + y: [3.25, 2] as [number, number], + }; + expect(planImageRaster(geometry, view, { w: 400, h: 400 }, 1)).not.toBe( + null, + ); + const outside = { + x: [-2, -0.6] as [number, number], + y: [3.25, 2] as [number, number], + }; + expect(planImageRaster(geometry, outside, { w: 400, h: 400 }, 1)).toBe( + null, + ); + }); +}); + describe("shared spatial raster planning", () => { it("assigns four 2048² images their on-screen share of the viewport", () => { + // The four images tile [-0.5, 4095.5]² (pixel centers 0…4095). const view = { - x: [0, 4096] as [number, number], - y: [4096, 0] as [number, number], + x: [-0.5, 4095.5] as [number, number], + y: [4095.5, -0.5] as [number, number], }; const origins = [ [0, 0], @@ -197,15 +252,17 @@ describe("shared spatial raster planning", () => { expect(raster).not.toBeNull(); expect(raster!.plan.cw).toBeLessThanOrEqual(500); expect(raster!.plan.ch).toBeLessThanOrEqual(400); - expect(raster!.placement.x0).toBe(x0); - expect(raster!.placement.y0).toBe(y0); + // Upper-left corner = outer edge of the first pixel. + expect(raster!.placement.x).toBe(x0 - 0.5); + expect(raster!.placement.y).toBe(y0 - 0.5); } }); it("skips off-screen images and restores exact pixels after zoom", () => { + // Pixels 10…25 of the visible image (centers 2058…2073) and rows 0…15. const zoom = { - x: [2058, 2074] as [number, number], - y: [16, 0] as [number, number], + x: [2057.5, 2073.5] as [number, number], + y: [15.5, -0.5] as [number, number], }; const visible = { ...GEOM, width: 2048, height: 2048, x0: 2048 }; const hidden = { ...GEOM, width: 2048, height: 2048, x0: 0 }; @@ -220,9 +277,9 @@ describe("shared spatial raster planning", () => { strideY: 1, }); expect(raster?.placement).toEqual({ - x0: 2058, + x: 2057.5, dx: 1, - y0: 0, + y: -0.5, dy: 1, cw: 16, ch: 16, diff --git a/tests/ts/utils/pixelCenters.test.ts b/tests/ts/utils/pixelCenters.test.ts new file mode 100644 index 0000000..ae8fff4 --- /dev/null +++ b/tests/ts/utils/pixelCenters.test.ts @@ -0,0 +1,204 @@ +/** + * Unit tests for the image axis helpers (Sigima pixel-center convention). + * + * ``x0``/``y0`` are the centers of the first pixel and ``xcoords``/``ycoords`` + * the pixel centers of non-uniform images: every conversion between physical + * coordinates and pixels in the viewers relies on these helpers, which mirror + * Sigima's ``axis_to_float_indices`` / ``axis_to_physical``. + */ +import { describe, it, expect } from "vitest"; +import { + type ImageAxes, + axisToFloatIndex, + axisToPhysical, + imageCenter, + imageExtent, + pixelCenters, + pixelEdges, + pixelIndexAt, + pixelRangeOverlapping, +} from "../../../src/utils/pixelCenters"; +import { toBins } from "../../../src/utils/imageCoords"; + +/** Example of DataLab-Platform/web#69: 4×4 image, ``x0 = y0 = 0``. */ +const SQUARE: ImageAxes = { width: 4, height: 4, x0: 0, y0: 0, dx: 1, dy: 1 }; + +/** Calibrated image with rectangular pixels. */ +const CALIBRATED: ImageAxes = { + width: 8, + height: 6, + x0: 10, + y0: -3, + dx: 0.5, + dy: 2, +}; + +/** Non-uniform image (PlotPy ``to_bins`` reference centers). */ +const NON_UNIFORM: ImageAxes = { + width: 4, + height: 3, + x0: 0, + y0: 0, + dx: 1, + dy: 1, + is_uniform_coords: false, + xcoords: [0, 1.5, 4, 9], + ycoords: [0, 2, 5], +}; + +describe("pixel centers and edges", () => { + it("centers the pixels on x0 + i·dx", () => { + expect(pixelCenters(SQUARE, "x")).toEqual([0, 1, 2, 3]); + expect(pixelCenters(CALIBRATED, "x")).toEqual([ + 10, 10.5, 11, 11.5, 12, 12.5, 13, 13.5, + ]); + expect(pixelCenters(CALIBRATED, "y")).toEqual([-3, -1, 1, 3, 5, 7]); + }); + + it("puts the outer edges half a pixel before the first center", () => { + expect(pixelEdges(SQUARE, "x")).toEqual([-0.5, 0.5, 1.5, 2.5, 3.5]); + expect(pixelEdges(CALIBRATED, "y")).toEqual([-4, -2, 0, 2, 4, 6, 8]); + }); + + it("uses the PlotPy bin edges of non-uniform images", () => { + expect(pixelCenters(NON_UNIFORM, "x")).toEqual([0, 1.5, 4, 9]); + expect(pixelEdges(NON_UNIFORM, "x")).toEqual(toBins([0, 1.5, 4, 9])); + expect(pixelEdges(NON_UNIFORM, "y")).toEqual(toBins([0, 2, 5])); + }); + + it("ignores coordinate arrays of uniform images", () => { + const img = { ...SQUARE, xcoords: [5, 6, 7, 8], is_uniform_coords: true }; + expect(pixelCenters(img, "x")).toEqual([0, 1, 2, 3]); + }); +}); + +describe("axisToFloatIndex / axisToPhysical", () => { + it("converts uniform coordinates both ways", () => { + expect(axisToFloatIndex(CALIBRATED, "x", 11.25)).toBe(2.5); + expect(axisToPhysical(CALIBRATED, "x", 2.5)).toBe(11.25); + expect(axisToFloatIndex(CALIBRATED, "y", -4)).toBe(-0.5); + }); + + it("interpolates between non-uniform centers", () => { + expect(axisToFloatIndex(NON_UNIFORM, "x", 2.75)).toBe(1.5); + expect(axisToPhysical(NON_UNIFORM, "x", 1.5)).toBe(2.75); + }); + + it("extrapolates beyond non-uniform ends with the end spacing", () => { + // First spacing 1.5, last spacing 5 (Sigima ``_extrapolated_interp``). + expect(axisToPhysical(NON_UNIFORM, "x", -1)).toBe(-1.5); + expect(axisToPhysical(NON_UNIFORM, "x", 4)).toBe(14); + expect(axisToFloatIndex(NON_UNIFORM, "x", -1.5)).toBe(-1); + expect(axisToFloatIndex(NON_UNIFORM, "x", 14)).toBe(4); + }); + + it("supports decreasing non-uniform centers", () => { + const img = { ...NON_UNIFORM, xcoords: [9, 4, 1.5, 0] }; + expect(axisToFloatIndex(img, "x", 2.75)).toBe(1.5); + expect(axisToPhysical(img, "x", 1.5)).toBe(2.75); + expect(axisToFloatIndex(img, "x", 14)).toBe(-1); + }); + + it("uses a unit slope for a single non-uniform center", () => { + const img: ImageAxes = { + ...NON_UNIFORM, + width: 1, + xcoords: [3], + }; + expect(axisToPhysical(img, "x", -0.5)).toBe(2.5); + expect(axisToFloatIndex(img, "x", 3.5)).toBe(0.5); + }); +}); + +describe("imageExtent / imageCenter", () => { + it("spans the outer edges of the pixels", () => { + expect(imageExtent(SQUARE)).toEqual({ + xMin: -0.5, + xMax: 3.5, + yMin: -0.5, + yMax: 3.5, + }); + expect(imageExtent(CALIBRATED)).toEqual({ + xMin: 9.75, + xMax: 13.75, + yMin: -4, + yMax: 8, + }); + expect(imageExtent(NON_UNIFORM)).toEqual({ + xMin: -0.75, + xMax: 11.5, + yMin: -1, + yMax: 6.5, + }); + }); + + it("returns the center of the pixel grid (Sigima ImageObj.xc/yc)", () => { + expect(imageCenter(SQUARE)).toEqual({ x: 1.5, y: 1.5 }); + expect(imageCenter(CALIBRATED)).toEqual({ x: 11.75, y: 2 }); + expect(imageCenter(NON_UNIFORM)).toEqual({ x: 4.5, y: 2.5 }); + }); +}); + +describe("pixelIndexAt", () => { + it("returns the pixel whose center is the closest", () => { + expect(pixelIndexAt(SQUARE, "x", 0)).toBe(0); + expect(pixelIndexAt(SQUARE, "x", 0.49)).toBe(0); + expect(pixelIndexAt(SQUARE, "x", 0.51)).toBe(1); + expect(pixelIndexAt(CALIBRATED, "y", 2.1)).toBe(3); + }); + + it("assigns a boundary to the next pixel", () => { + expect(pixelIndexAt(SQUARE, "x", 0.5)).toBe(1); + expect(pixelIndexAt(NON_UNIFORM, "x", 0.75)).toBe(1); + expect(pixelIndexAt(NON_UNIFORM, "x", 2.75)).toBe(2); + expect(pixelIndexAt(NON_UNIFORM, "x", 6.5)).toBe(3); + }); + + it("locates non-uniform pixels", () => { + expect(pixelIndexAt(NON_UNIFORM, "x", 0)).toBe(0); + expect(pixelIndexAt(NON_UNIFORM, "x", 1.5)).toBe(1); + expect(pixelIndexAt(NON_UNIFORM, "x", 4)).toBe(2); + expect(pixelIndexAt(NON_UNIFORM, "x", 9)).toBe(3); + }); + + it("includes both outer edges of the image", () => { + expect(pixelIndexAt(SQUARE, "x", -0.5)).toBe(0); + expect(pixelIndexAt(SQUARE, "x", 3.5)).toBe(3); + expect(pixelIndexAt(NON_UNIFORM, "x", -0.75)).toBe(0); + expect(pixelIndexAt(NON_UNIFORM, "x", 11.5)).toBe(3); + }); + + it("returns -1 outside the image", () => { + expect(pixelIndexAt(SQUARE, "x", -0.51)).toBe(-1); + expect(pixelIndexAt(SQUARE, "y", 3.51)).toBe(-1); + expect(pixelIndexAt(NON_UNIFORM, "x", -1)).toBe(-1); + expect(pixelIndexAt(NON_UNIFORM, "x", 12)).toBe(-1); + expect(pixelIndexAt(SQUARE, "x", NaN)).toBe(-1); + }); +}); + +describe("pixelRangeOverlapping", () => { + it("selects every pixel the interval covers, even partly", () => { + // [0.2, 1.7] covers pixel 0 ([-0.5, 0.5]) to pixel 2 ([1.5, 2.5]). + expect(pixelRangeOverlapping(SQUARE, "x", 0.2, 1.7)).toEqual([0, 3]); + expect(pixelRangeOverlapping(SQUARE, "x", 1.7, 0.2)).toEqual([0, 3]); + }); + + it("does not add a pixel merely touched by a bound", () => { + expect(pixelRangeOverlapping(SQUARE, "x", 0.5, 2.5)).toEqual([1, 3]); + expect( + pixelRangeOverlapping(CALIBRATED, "x", 9.75 + 0.5, 9.75 + 1.5), + ).toEqual([1, 3]); + }); + + it("clips the range to the image", () => { + expect(pixelRangeOverlapping(SQUARE, "x", -10, 10)).toEqual([0, 4]); + const [i0, i1] = pixelRangeOverlapping(SQUARE, "x", 5, 6); + expect(i1).toBeLessThanOrEqual(i0); + }); + + it("uses the bin edges of non-uniform images", () => { + expect(pixelRangeOverlapping(NON_UNIFORM, "x", 1, 3)).toEqual([1, 3]); + expect(pixelRangeOverlapping(NON_UNIFORM, "x", 0.75, 2.75)).toEqual([1, 2]); + }); +}); From fd168cae1e6bdc7a086f7b008e7eefbd30aab6d5 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:47:37 +0100 Subject: [PATCH 2/3] Follow Sigima 1.4 in the ROI grid preview The live preview of "Create ROI grid..." replicated the former generate_image_grid_roi, laid out from x0 (the first pixel center) and not snapped to pixels, so it no longer matched the regions of interest created by Sigima 1.4. The replica now lays the grid over the outer edges of the pixels (bin edges for non-uniform images), makes each cell of the pixels whose centers lie in it and draws each rectangle to the outer edges of its pixels; empty cells are kept, as in Sigima, and a zero translation is no longer read as the default. The new test checks the preview against reference values computed with Sigima. Refs #69 Assisted-by: Claude Opus 5.5 --- src/components/RoiGridDialog.tsx | 103 +++++++++--- tests/ts/components/RoiGridDialog.test.ts | 193 ++++++++++++++++++++++ 2 files changed, 270 insertions(+), 26 deletions(-) create mode 100644 tests/ts/components/RoiGridDialog.test.ts diff --git a/src/components/RoiGridDialog.tsx b/src/components/RoiGridDialog.tsx index 07113f3..44f2cd6 100644 --- a/src/components/RoiGridDialog.tsx +++ b/src/components/RoiGridDialog.tsx @@ -12,6 +12,12 @@ import { useMemo, useState } from "react"; import { DataSetForm } from "./DataSetForm"; import { ImagePlot } from "./ImagePlot"; +import { + type Axis, + PIXEL_INDEX_TOLERANCE, + axisToFloatIndex, + axisToPhysical, +} from "../utils/pixelCenters"; import type { ImageData, ImageRoiSegment, @@ -26,29 +32,73 @@ interface Props { onCancel: () => void; } +/** Pixels of a grid cell along an axis: those whose centers lie in + * ``[start, end[`` (Sigima's ``_cell_pixel_range``), so that adjacent cells + * never share a pixel. Returns ``[i0, i1]``, not clipped to the image. */ +function cellPixelRange( + img: ImageData, + axis: Axis, + start: number, + end: number, +): [number, number] { + const i0 = Math.ceil( + axisToFloatIndex(img, axis, start) - PIXEL_INDEX_TOLERANCE, + ); + const i1 = Math.ceil( + axisToFloatIndex(img, axis, end) - PIXEL_INDEX_TOLERANCE, + ); + return [i0, Math.max(i0, i1)]; +} + +/** Physical interval covering pixels ``i0`` to ``i1 - 1`` along an axis, up to + * their outer edges (Sigima's ``pixel_range_to_physical``). */ +function pixelRangeToPhysical( + img: ImageData, + axis: Axis, + i0: number, + i1: number, +): [number, number] { + const a = axisToPhysical(img, axis, i0 - 0.5); + const b = axisToPhysical(img, axis, i1 - 0.5); + return [Math.min(a, b), Math.max(a, b)]; +} + +/** Numeric grid parameter, or its default when missing (``0`` is kept). */ +function gridNumber(value: unknown, fallback: number): number { + if (value === null || value === undefined || value === "") return fallback; + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + /** TypeScript replica of ``sigima.proc.image.generate_image_grid_roi``. * * Kept in lockstep with the Python implementation in * :mod:`sigima.proc.image.extraction` (function * ``generate_image_grid_roi``) so the live preview matches what the - * backend will produce on submit. + * backend will produce on submit: the grid spans the image up to the outer + * edges of its pixels, each cell is made of the pixels whose centers lie in + * it, and each rectangle goes to the outer edges of its pixels. */ export function computeRoiGridPreview( img: ImageData, values: Record, ): ImageRoiSegment[] { - const physW = img.width * img.dx; - const physH = img.height * img.dy; - const nx = Math.max(1, Math.trunc(Number(values.nx) || 1)); - const ny = Math.max(1, Math.trunc(Number(values.ny) || 1)); + const left = axisToPhysical(img, "x", -0.5); + const right = axisToPhysical(img, "x", img.width - 0.5); + const top = axisToPhysical(img, "y", -0.5); + const bottom = axisToPhysical(img, "y", img.height - 0.5); + const physW = right - left; + const physH = bottom - top; + const nx = Math.max(1, Math.trunc(gridNumber(values.nx, 1))); + const ny = Math.max(1, Math.trunc(gridNumber(values.ny, 1))); const dxCell = physW / nx; const dyCell = physH / ny; - const dx = (dxCell * (Number(values.xsize) || 0)) / 100; - const dy = (dyCell * (Number(values.ysize) || 0)) / 100; - const dxStep = (dxCell * (Number(values.xstep) || 100)) / 100; - const dyStep = (dyCell * (Number(values.ystep) || 100)) / 100; - const xtrans = (physW * ((Number(values.xtranslation) || 50) - 50)) / 100; - const ytrans = (physH * ((Number(values.ytranslation) || 50) - 50)) / 100; + const dx = (dxCell * gridNumber(values.xsize, 0)) / 100; + const dy = (dyCell * gridNumber(values.ysize, 0)) / 100; + const dxStep = (dxCell * gridNumber(values.xstep, 100)) / 100; + const dyStep = (dyCell * gridNumber(values.ystep, 100)) / 100; + const xtrans = (physW * (gridNumber(values.xtranslation, 50) - 50)) / 100; + const ytrans = (physH * (gridNumber(values.ytranslation, 50) - 50)) / 100; const xdir = String(values.xdirection || "increasing"); const ydir = String(values.ydirection || "increasing"); const baseName = String(values.base_name ?? "ROI"); @@ -57,8 +107,12 @@ export function computeRoiGridPreview( const out: ImageRoiSegment[] = []; for (let ir = 0; ir < ny; ir++) { for (let ic = 0; ic < nx; ic++) { - const x0 = img.x0 + (ic + 0.5) * dxStep + xtrans - 0.5 * dx; - const y0 = img.y0 + (ir + 0.5) * dyStep + ytrans - 0.5 * dy; + const x0 = left + (ic + 0.5) * dxStep + xtrans - 0.5 * dx; + const y0 = top + (ir + 0.5) * dyStep + ytrans - 0.5 * dy; + const [ix0, ix1] = cellPixelRange(img, "x", x0, x0 + dx); + const [iy0, iy1] = cellPixelRange(img, "y", y0, y0 + dy); + const [rx0, rx1] = pixelRangeToPhysical(img, "x", ix0, ix1); + const [ry0, ry1] = pixelRangeToPhysical(img, "y", iy0, iy1); const r = (ydir === "decreasing" ? ny - 1 - ir : ir) + 1; const c = (xdir === "decreasing" ? nx - 1 - ic : ic) + 1; let title: string; @@ -70,19 +124,16 @@ export function computeRoiGridPreview( } catch { title = `ROI(${r},${c})`; } - // Skip degenerate rectangles (size = 0) so the preview matches what - // the backend will accept (it raises on dx<=0/dy<=0). - if (dx > 0 && dy > 0) { - out.push({ - geometry: "rectangle", - title, - inverse: false, - x0, - y0, - dx, - dy, - }); - } + // Cells holding no pixel are kept as empty rectangles, like Sigima. + out.push({ + geometry: "rectangle", + title, + inverse: false, + x0: rx0, + y0: ry0, + dx: rx1 - rx0, + dy: ry1 - ry0, + }); } } return out; diff --git a/tests/ts/components/RoiGridDialog.test.ts b/tests/ts/components/RoiGridDialog.test.ts new file mode 100644 index 0000000..e515c21 --- /dev/null +++ b/tests/ts/components/RoiGridDialog.test.ts @@ -0,0 +1,193 @@ +/** + * The ROI grid preview is a TypeScript replica of Sigima's + * ``generate_image_grid_roi``: it must give the rectangles the backend creates + * on submit. The reference values below were computed with Sigima 1.4 + * (pixel-center convention: the grid spans the outer edges of the pixels and + * every cell is made of whole pixels). + */ +import { describe, expect, it, vi } from "vitest"; +import { computeRoiGridPreview } from "../../../src/components/RoiGridDialog"; +import type { ImageData, ImageRoiSegment } from "../../../src/runtime/runtime"; + +// The dialog module imports the image viewer, whose Plotly bundle cannot load +// under jsdom; only the pure grid computation is exercised here. +vi.mock("react-plotly.js", () => ({ default: () => null })); + +type Rect = [title: string, x0: number, y0: number, dx: number, dy: number]; + +function makeImage(fields: Partial): ImageData { + return { + id: "img", + title: "img", + width: 1, + height: 1, + data: [], + dtype: "float64", + x0: 0, + y0: 0, + dx: 1, + dy: 1, + data_min: 0, + data_max: 1, + xlabel: "", + ylabel: "", + zlabel: "", + xunit: "", + yunit: "", + zunit: "", + ...fields, + } as ImageData; +} + +/** 8×6 image, pixel centers at 10 + 0.5·i and -3 + 2·j. */ +const CALIBRATED = makeImage({ + width: 8, + height: 6, + x0: 10, + y0: -3, + dx: 0.5, + dy: 2, +}); + +/** 4×3 non-uniform image. */ +const NON_UNIFORM = makeImage({ + width: 4, + height: 3, + is_uniform_coords: false, + xcoords: [0, 1.5, 4, 9], + ycoords: [0, 2, 5], +}); + +const DEFAULTS = { + nx: 3, + ny: 3, + xtranslation: 50, + ytranslation: 50, + xsize: 50, + ysize: 50, + xstep: 100, + ystep: 100, + base_name: "ROI", + name_pattern: "{base}({r},{c})", + xdirection: "increasing", + ydirection: "increasing", +}; + +function rects(rois: ImageRoiSegment[]): Rect[] { + return rois.map((roi) => { + if (roi.geometry !== "rectangle") throw new Error("not a rectangle"); + return [roi.title ?? "", roi.x0, roi.y0, roi.dx, roi.dy]; + }); +} + +function expectRects(actual: Rect[], expected: Rect[]): void { + expect(actual).toHaveLength(expected.length); + actual.forEach((rect, k) => { + expect(rect[0]).toBe(expected[k][0]); + for (let i = 1; i < 5; i++) { + expect(rect[i] as number).toBeCloseTo(expected[k][i] as number, 9); + } + }); +} + +describe("computeRoiGridPreview (Sigima generate_image_grid_roi)", () => { + it("matches Sigima with the default parameters", () => { + expectRects(rects(computeRoiGridPreview(CALIBRATED, DEFAULTS)), [ + ["ROI(1,1)", 10.25, -4, 0.5, 2], + ["ROI(1,2)", 11.25, -4, 1, 2], + ["ROI(1,3)", 12.75, -4, 0.5, 2], + ["ROI(2,1)", 10.25, 0, 0.5, 2], + ["ROI(2,2)", 11.25, 0, 1, 2], + ["ROI(2,3)", 12.75, 0, 0.5, 2], + ["ROI(3,1)", 10.25, 4, 0.5, 2], + ["ROI(3,2)", 11.25, 4, 1, 2], + ["ROI(3,3)", 12.75, 4, 0.5, 2], + ]); + }); + + it("tiles the pixels of the image exactly with full cells", () => { + const values = { ...DEFAULTS, nx: 4, ny: 2, xsize: 100, ysize: 100 }; + expectRects(rects(computeRoiGridPreview(CALIBRATED, values)), [ + ["ROI(1,1)", 9.75, -4, 1, 6], + ["ROI(1,2)", 10.75, -4, 1, 6], + ["ROI(1,3)", 11.75, -4, 1, 6], + ["ROI(1,4)", 12.75, -4, 1, 6], + ["ROI(2,1)", 9.75, 2, 1, 6], + ["ROI(2,2)", 10.75, 2, 1, 6], + ["ROI(2,3)", 11.75, 2, 1, 6], + ["ROI(2,4)", 12.75, 2, 1, 6], + ]); + }); + + it("applies translations, steps and decreasing directions", () => { + const values = { + ...DEFAULTS, + nx: 2, + ny: 2, + xsize: 40, + ysize: 60, + xtranslation: 30, + ytranslation: 70, + xstep: 150, + ystep: 80, + xdirection: "decreasing", + ydirection: "decreasing", + }; + expectRects(rects(computeRoiGridPreview(CALIBRATED, values)), [ + ["ROI(2,2)", 10.25, -2, 0.5, 4], + ["ROI(2,1)", 13.25, -2, 0.5, 4], + ["ROI(1,2)", 10.25, 4, 0.5, 4], + ["ROI(1,1)", 13.25, 4, 0.5, 4], + ]); + }); + + it("keeps a zero translation", () => { + const values = { ...DEFAULTS, nx: 2, ny: 1, ysize: 100, xtranslation: 0 }; + expectRects(rects(computeRoiGridPreview(CALIBRATED, values)), [ + ["ROI(1,1)", 8.25, -4, 1, 12], + ["ROI(1,2)", 10.25, -4, 1, 12], + ]); + }); + + it("lays the grid over the bin edges of non-uniform images", () => { + const full = { ...DEFAULTS, nx: 2, ny: 1, xsize: 100, ysize: 100 }; + expectRects(rects(computeRoiGridPreview(NON_UNIFORM, full)), [ + ["ROI(1,1)", -0.75, -1, 7.25, 7.5], + ["ROI(1,2)", 6.5, -1, 5, 7.5], + ]); + // Cells holding no pixel center stay as empty rectangles, like Sigima. + expectRects(rects(computeRoiGridPreview(NON_UNIFORM, DEFAULTS)), [ + ["ROI(1,1)", 0.75, -1, 2, 2], + ["ROI(1,2)", 6.5, -1, 0, 2], + ["ROI(1,3)", 6.5, -1, 5, 2], + ["ROI(2,1)", 0.75, 3.5, 2, 0], + ["ROI(2,2)", 6.5, 3.5, 0, 0], + ["ROI(2,3)", 6.5, 3.5, 5, 0], + ["ROI(3,1)", 0.75, 3.5, 2, 3], + ["ROI(3,2)", 6.5, 3.5, 0, 3], + ["ROI(3,3)", 6.5, 3.5, 5, 3], + ]); + }); + + it("keeps empty cells and zero sizes like Sigima", () => { + const small = makeImage({ width: 4, height: 4 }); + const narrow = { ...DEFAULTS, nx: 8, ny: 1, xsize: 100, ysize: 100 }; + expectRects(rects(computeRoiGridPreview(small, narrow)), [ + ["ROI(1,1)", -0.5, -0.5, 0, 4], + ["ROI(1,2)", -0.5, -0.5, 1, 4], + ["ROI(1,3)", 0.5, -0.5, 0, 4], + ["ROI(1,4)", 0.5, -0.5, 1, 4], + ["ROI(1,5)", 1.5, -0.5, 0, 4], + ["ROI(1,6)", 1.5, -0.5, 1, 4], + ["ROI(1,7)", 2.5, -0.5, 0, 4], + ["ROI(1,8)", 2.5, -0.5, 1, 4], + ]); + const flat = { ...DEFAULTS, nx: 2, ny: 2, xsize: 0, ysize: 50 }; + expectRects(rects(computeRoiGridPreview(small, flat)), [ + ["ROI(1,1)", 0.5, -0.5, 0, 1], + ["ROI(1,2)", 2.5, -0.5, 0, 1], + ["ROI(2,1)", 0.5, 1.5, 0, 1], + ["ROI(2,2)", 2.5, 1.5, 0, 1], + ]); + }); +}); From 61be1b92a7d01059eec6680420337f978fab5ae2 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:47:40 +0100 Subject: [PATCH 3/3] docs: changelog entry for the image pixel positions Refs #69 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30c5aac..c354e4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) - **HDF5 browser — link cycles:** files containing a group linked into itself or into one of its subgroups were browsed down to a hundred phantom levels, or took too long to open. Such links back to a parent group are now skipped; other aliases are still shown at each of their paths. - **HDF5 browser — large files:** opening an HDF5 file no longer reads every dataset entirely, including those that cannot be imported (e.g. 3D cubes): datasets are described from a small sample, and their data is read only when previewed or imported. Large files open faster and with much less memory. - **Embedding — message origins:** the JavaScript SDK accepted responses and events from any document loaded in the DataLab-Web iframe, and DataLab-Web sent its events to host windows whatever document they showed. The SDK now only accepts messages from the configured `targetOrigin`, and DataLab-Web sends its responses and events to the origin from which each host window made its requests. +- **Image viewer — pixel positions:** images were drawn half a pixel off their coordinates: the first pixel started at the image origin instead of being centered on it, as in Sigima and DataLab desktop. Regions of interest, analysis results, the profile crosshair and the statistics rectangle therefore appeared shifted by half a pixel relative to the computed pixels, and the value shown when hovering the image could be that of the neighbouring pixel. Each pixel is now centered on its coordinates in the image viewer, the multi-image views and the processing preview, so overlays, read-outs, cross-sections and statistics match the computations exactly. The default regions of interest and areas to erase are centered on the image, and the preview of _Create ROI grid…_ now shows the same whole-pixel cells as the created regions of interest. Cross-sections and statistics also work on images with non-uniform coordinates. ### Changed