From 23c44e514430f93478de036dd5ffde740b62cd10 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:42:00 +0100 Subject: [PATCH 1/2] Draw zoomed-in image pixels as sharp blocks Since the uniform-image bitmap became a Plotly layout image (v0.6.1), the browser smoothed it whenever it was enlarged: zoomed-in pixels faded into each other, unlike DataLab desktop (nearest pixel by default) and unlike the former image trace (pixelated style, magnified canvas on Safari and in exports). - the raster plan magnifies each painted cell by integer factors when zoomed in (from two device pixels per image pixel, within the bitmap size cap); the window is painted 1:1 as before, then copied onto the magnified canvas without smoothing, so pixels are sharp blocks in every browser and in exported PNG images; small images are magnified whole when they fit, so panning never uncovers unpainted areas; - the views set image-rendering: pixelated on their layout images only while the bitmap shows undecimated pixels enlarged on screen, so the remaining enlargement stays sharp without aliasing reduced or decimated bitmaps (spatial view: when every visible bitmap qualifies; processing preview, whose plot size is not tracked: when the bitmap is magnified); - the placement of the bitmap is unchanged (physical cell size). Refs #74 Assisted-by: Claude Opus 5.5 --- src/components/ImagePlot.tsx | 18 +- src/components/MultiImageSpatialPlot.tsx | 19 +- src/components/ProcessingPreviewPlot.tsx | 7 + src/styles.css | 13 ++ src/utils/imageLod.ts | 57 +++-- src/utils/imageRaster.ts | 108 +++++++-- tests/e2e/image-lod.spec.ts | 113 +++++++++- tests/ts/components/ImagePlot.test.tsx | 40 ++++ .../components/MultiImageSpatialPlot.test.tsx | 13 ++ tests/ts/utils/imageLod.test.ts | 102 ++++++++- tests/ts/utils/imageRaster.test.ts | 209 ++++++++++++++++++ 11 files changed, 656 insertions(+), 43 deletions(-) create mode 100644 tests/ts/utils/imageRaster.test.ts diff --git a/src/components/ImagePlot.tsx b/src/components/ImagePlot.tsx index baa9518..1bb1fec 100644 --- a/src/components/ImagePlot.tsx +++ b/src/components/ImagePlot.tsx @@ -34,8 +34,10 @@ import { shouldUseLod, } from "../utils/imageLod"; import { + CRISP_PIXELS_CLASS, type ImageRasterResult, RASTER_DEBOUNCE_MS, + showsCrispPixels, normalizeResampleMethod, rasterLayoutImage, rasterizeImage, @@ -770,6 +772,20 @@ export function ImagePlot({ : [], [bitmap, data.is_uniform_coords], ); + // Zoomed in, the bitmap is magnified into sharp pixel blocks; the browser + // must not smooth what remains of its enlargement on screen. + const crispPixels = useMemo( + () => + bitmap !== null && + data.is_uniform_coords !== false && + showsCrispPixels( + bitmap, + displayRange, + plotPx, + window.devicePixelRatio || 1, + ), + [bitmap, data.is_uniform_coords, displayRange, plotPx], + ); const layout = useMemo(() => { const xtitle = data.xunit ? `${data.xlabel} (${data.xunit})` : data.xlabel; @@ -1522,7 +1538,7 @@ export function ImagePlot({ ? " profiles-frozen" : " profiles-live" : "" - }`} + }${crispPixels ? ` ${CRISP_PIXELS_CLASS}` : ""}`} style={{ position: "relative", width: "100%", height: "100%" }} onMouseDownCapture={handleHostMouseDownCapture} onMouseMove={handleWrapperMouseMove} diff --git a/src/components/MultiImageSpatialPlot.tsx b/src/components/MultiImageSpatialPlot.tsx index 34e8a3b..6181028 100644 --- a/src/components/MultiImageSpatialPlot.tsx +++ b/src/components/MultiImageSpatialPlot.tsx @@ -42,8 +42,10 @@ import { usePlotlyTheme } from "../utils/plotlyTheme"; import { buildColorscale } from "../utils/colormap"; import { aspectFitRanges, type ViewRange } from "../utils/imageLod"; import { + CRISP_PIXELS_CLASS, type ImageRasterResult, RASTER_DEBOUNCE_MS, + showsCrispPixels, normalizeResampleMethod, rasterLayoutImage, rasterizeImage, @@ -254,6 +256,19 @@ function MultiImageSpatialPlotImpl({ images, totalSelected }: Props) { } return out; }, [uniqueImages, rasters]); + // Nearest-pixel scaling only when every visible bitmap is enlarged on + // screen (typically when zoomed into one image): a reduced bitmap would be + // aliased by it. + const crispPixels = useMemo(() => { + const shown = Object.values(rasters); + const dpr = window.devicePixelRatio || 1; + return ( + shown.length > 0 && + shown.every((raster) => + showsCrispPixels(raster, displayRange, plotPx, dpr), + ) + ); + }, [rasters, displayRange, plotPx]); // ------------------------------------------------------------------ // Custom hover: ``image`` traces emit no ``plotly_hover`` events, so @@ -520,7 +535,9 @@ function MultiImageSpatialPlotImpl({ images, totalSelected }: Props) { )}
, +): boolean { + return plan.zoomX > 1 || plan.zoomY > 1; } /** How source pixels are aggregated into one output cell when downsampling @@ -126,13 +140,16 @@ export function visibleWindow( } /** - * Decide the rasterisation stride and output bitmap size for a window. + * Decide the rasterisation stride, magnification and bitmap size for a window. * - * When the window is smaller than the available display resolution (the user - * is zoomed in) the stride is 1 and the output matches the window 1:1 — every - * source pixel is shown. When the window is larger (zoomed out) the stride is - * the smallest integer that brings the output at or below the display - * resolution, capped at ``maxDim``. + * When the window is larger than the available display resolution (zoomed + * out) the stride is the smallest integer that brings the output at or below + * the display resolution, capped at ``maxDim``. Otherwise the stride is 1 — + * every source pixel is painted — and when each source pixel covers at least + * two device pixels (zoomed in), the painted cells are magnified by the + * largest integer factor that keeps the bitmap within the display + * resolution, so each pixel is drawn as a sharp block (nearest pixel, like + * DataLab desktop) instead of being upscaled — and smoothed — by the browser. * * @param win Visible pixel window. * @param plotW On-screen plot-area width in CSS pixels. @@ -155,22 +172,26 @@ export function rasterPlan( const strideY = ny > availY ? Math.ceil(ny / availY) : 1; const cw = Math.ceil(nx / strideX); const ch = Math.ceil(ny / strideY); - return { i0: win.i0, j0: win.j0, cw, ch, strideX, strideY }; + // ``avail`` is capped at ``maxDim``, so ``cw * zoomX <= maxDim``. + const zoomX = strideX === 1 ? Math.max(1, Math.floor(availX / cw)) : 1; + const zoomY = strideY === 1 ? Math.max(1, Math.floor(availY / ch)) : 1; + return { i0: win.i0, j0: win.j0, cw, ch, strideX, strideY, zoomX, zoomY }; } /** * 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 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. + * Each painted cell spans ``strideX × strideY`` source pixels, so the cell + * size is ``dx*strideX`` / ``dy*strideY`` (magnification only splits a cell + * into more bitmap pixels, the cell keeps its physical size), 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. */ export function windowPlacement( - plan: RasterPlan, + plan: Pick, geom: ImageGeometry, ): { x: number; dx: number; y: number; dy: number } { return { diff --git a/src/utils/imageRaster.ts b/src/utils/imageRaster.ts index 9559ec8..d1d8017 100644 --- a/src/utils/imageRaster.ts +++ b/src/utils/imageRaster.ts @@ -2,11 +2,13 @@ import { paintImageWindow } from "./colormap"; import { + DEFAULT_MAX_RASTER_DIM, type ImageGeometry, type PixelWindow, type RasterPlan, type ResampleMethod, type ViewRange, + isMagnified, rasterPlan, shouldUseLod, visibleWindow, @@ -18,7 +20,9 @@ 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. */ + * physical size of one painted cell and ``cw``/``ch`` its size in cells (a + * magnified bitmap draws each cell as ``zoomX × zoomY`` bitmap pixels, see + * :func:`rasterPlan`). */ export interface ImageRasterPlacement { x: number; dx: number; @@ -87,7 +91,15 @@ export function visibleRasterBudget( }; } -/** Plan the visible window, output stride and physical bitmap placement. */ +/** Plan the visible window, output stride, magnification and physical bitmap + * placement. + * + * Large images (LOD) rasterise their visible window at display resolution. + * Smaller images are painted whole and 1:1. When zoomed in, the pixels are + * magnified so that each stays a sharp block instead of being upscaled — and + * smoothed — by the browser: the whole image when it fits within the bitmap + * size cap once magnified (so panning never uncovers unpainted areas), else + * only its visible window. */ export function planImageRaster( geometry: ImageGeometry, view: ViewRange | null, @@ -96,16 +108,26 @@ export function planImageRaster( ): ImageRasterPlan | null { const { width, height } = geometry; if (width <= 0 || height <= 0 || !intersectsView(geometry, view)) return null; - const useLod = shouldUseLod(width, height); - const window = useLod - ? visibleWindow(geometry, view) - : { i0: 0, i1: width, j0: 0, j1: height }; - const budget = useLod - ? visibleRasterBudget(geometry, window, view, plotPx) - : plotPx; - const plan = useLod - ? rasterPlan(window, budget.w, budget.h, dpr) - : { i0: 0, j0: 0, cw: width, ch: height, strideX: 1, strideY: 1 }; + const window = visibleWindow(geometry, view); + const budget = visibleRasterBudget(geometry, window, view, plotPx); + const windowed = rasterPlan(window, budget.w, budget.h, dpr); + const zoomedIn = + isMagnified(windowed) && windowed.strideX === 1 && windowed.strideY === 1; + const whole: RasterPlan = { + i0: 0, + j0: 0, + cw: width, + ch: height, + strideX: 1, + strideY: 1, + zoomX: zoomedIn ? windowed.zoomX : 1, + zoomY: zoomedIn ? windowed.zoomY : 1, + }; + const wholeFits = zoomedIn + ? width * whole.zoomX <= DEFAULT_MAX_RASTER_DIM && + height * whole.zoomY <= DEFAULT_MAX_RASTER_DIM + : !shouldUseLod(width, height); + const plan = wholeFits ? whole : windowed; return { plan, placement: { ...windowPlacement(plan, geometry), cw: plan.cw, ch: plan.ch }, @@ -123,9 +145,10 @@ export function rasterizeImage( options.dpr, ); if (planned === null) return null; + const { plan } = planned; const canvas = document.createElement("canvas"); - canvas.width = planned.plan.cw; - canvas.height = planned.plan.ch; + canvas.width = plan.cw; + canvas.height = plan.ch; const context = canvas.getContext("2d"); if (!context) return null; const image = paintImageWindow( @@ -133,7 +156,7 @@ export function rasterizeImage( options.rows, options.geometry.width, options.geometry.height, - planned.plan, + plan, options.lut[0], options.lut[1], options.colormap, @@ -143,10 +166,29 @@ export function rasterizeImage( context.putImageData(image, 0, 0); return { ...planned, - source: canvas.toDataURL("image/png"), + source: (magnifyCanvas(canvas, plan) ?? canvas).toDataURL("image/png"), }; } +/** Copy *canvas* onto a canvas magnified by the plan's integer factors, + * without smoothing, so each painted cell becomes a sharp block of bitmap + * pixels. Returns ``null`` when the plan does not magnify (or no 2D context + * is available, in which case the browser upscales the painted bitmap). */ +function magnifyCanvas( + canvas: HTMLCanvasElement, + plan: RasterPlan, +): HTMLCanvasElement | null { + if (!isMagnified(plan)) return null; + const magnified = document.createElement("canvas"); + magnified.width = canvas.width * plan.zoomX; + magnified.height = canvas.height * plan.zoomY; + const context = magnified.getContext("2d"); + if (!context) return null; + context.imageSmoothingEnabled = false; + context.drawImage(canvas, 0, 0, magnified.width, magnified.height); + return magnified; +} + /** 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. */ @@ -167,6 +209,40 @@ export function rasterLayoutImage(raster: ImageRasterResult) { }; } +/** + * Whether a rasterised bitmap shows the image pixels themselves (no + * decimation) enlarged on screen — at least one device pixel per bitmap pixel + * along both axes — when the plot area of *plotPx* CSS pixels shows the + * physical *view*. + * + * The browser smooths enlarged bitmaps: views then set + * ``image-rendering: pixelated`` (CSS class :data:`CRISP_PIXELS_CLASS`) so the + * pixels — magnified into blocks when zoomed in — stay sharp. It must not be + * set on reduced bitmaps, which it would alias (nearest-pixel downscaling), nor + * on decimated ones, whose cells aggregate several pixels. + */ +export function showsCrispPixels( + raster: ImageRasterPlan, + view: ViewRange, + plotPx: { w: number; h: number }, + dpr: number, +): boolean { + const { plan, placement } = raster; + if (plan.strideX !== 1 || plan.strideY !== 1) return false; + const spanX = Math.abs(view.x[1] - view.x[0]); + const spanY = Math.abs(view.y[1] - view.y[0]); + if (!(spanX > 0 && spanY > 0)) return false; + const scaleX = + (plotPx.w * dpr * Math.abs(placement.dx)) / (spanX * plan.zoomX); + const scaleY = + (plotPx.h * dpr * Math.abs(placement.dy)) / (spanY * plan.zoomY); + return Math.min(scaleX, scaleY) >= 1 - 1e-9; +} + +/** CSS class (see ``styles.css``) drawing the layout images of a Plotly plot + * with nearest-pixel scaling (``image-rendering: pixelated``). */ +export const CRISP_PIXELS_CLASS = "image-crisp-pixels"; + function intersectsView( geometry: ImageGeometry, view: ViewRange | null, diff --git a/tests/e2e/image-lod.spec.ts b/tests/e2e/image-lod.spec.ts index 141a155..5cee011 100644 --- a/tests/e2e/image-lod.spec.ts +++ b/tests/e2e/image-lod.spec.ts @@ -12,8 +12,9 @@ * 2. The full-resolution data is untouched — ``getImageData`` still returns * the 2048² grid with exact pixel values, which is what profiles, * statistics and the hover read-out consume. - * 3. Zooming to a few pixels re-rasterises them 1:1 (crisp): the new PNG is - * tiny and ``dx`` collapses back to the native spacing. + * 3. Zooming to a few pixels re-rasterises them: every visible pixel is + * painted (stride 1) and magnified by an integer factor into sharp blocks + * (nearest pixel), drawn on screen without smoothing. * * These assertions are DOM/trace-level (PNG dimensions, trace spacing) rather * than ``window.runtime`` no-ops, per the testing strategy. @@ -193,12 +194,112 @@ test.describe("Image LOD display", () => { .toBeLessThan(zoomedOut.dx); const zoomedIn = await readImageTrace(page); - // Native spacing restored (stride ≈ 1) and a smaller PNG — pixels 1:1. - expect(zoomedIn.dx).toBeLessThanOrEqual(1.5); - expect(zoomedIn.pngWidth).toBeGreaterThan(0); - expect(zoomedIn.pngWidth).toBeLessThan(zoomedOut.pngWidth); + // Every visible pixel painted (stride 1, one cell per unit-spaced pixel) + // and magnified by an integer factor into sharp blocks. + expect(Number.isInteger(zoomedIn.sizex)).toBe(true); + expect(Number.isInteger(zoomedIn.sizey)).toBe(true); + const zoomX = zoomedIn.pngWidth / zoomedIn.sizex; + const zoomY = zoomedIn.pngHeight / zoomedIn.sizey; + expect(Number.isInteger(zoomX) && zoomX >= 2).toBe(true); + expect(Number.isInteger(zoomY) && zoomY >= 2).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); }); + + test("draws zoomed-in pixels as sharp blocks (nearest pixel)", async ({ + page, + }) => { + await page.goto("/"); + await waitForRuntimeReady(page); + await page.getByRole("tab", { name: "Images" }).click(); + // 8×8 checkerboard: any smoothing would show colours between the two. + await page.evaluate(() => { + const runtime = ( + window as unknown as { + runtime: { runPython: (c: string) => Promise }; + } + ).runtime; + return runtime.runPython( + `import numpy as np\n` + + `add_image_from_array("crisp_probe", ` + + `(np.indices((8, 8)).sum(axis=0) % 2).astype(float))`, + ); + }); + await page.getByRole("tab", { name: "Signals" }).click(); + await page.waitForTimeout(150); + await page.getByRole("tab", { name: "Images" }).click(); + await page + .locator(".object-tree-item") + .filter({ hasText: "crisp_probe" }) + .first() + .click(); + await expect + .poll(async () => (await readImageTrace(page)).pngWidth, { + timeout: 30_000, + }) + .toBeGreaterThan(8); + + // The 8 pixels are magnified by an integer factor in the bitmap itself + // (so exported images are sharp too), without blending. + const trace = await readImageTrace(page); + expect(trace.sizex).toBe(8); + const zoom = trace.pngWidth / 8; + expect(Number.isInteger(zoom) && zoom >= 2).toBe(true); + const bitmapColours = await page.evaluate(async (source) => { + const image = new Image(); + image.src = source; + await image.decode(); + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d")!; + context.drawImage(image, 0, 0); + const data = context.getImageData(0, 0, canvas.width, canvas.height); + const colours = new Set(); + for (let p = 0; p < data.data.length; p += 4) { + colours.add( + (data.data[p] << 16) | (data.data[p + 1] << 8) | data.data[p + 2], + ); + } + return colours.size; + }, trace.source); + expect(bitmapColours).toBe(2); + + // On screen, the enlarged bitmap is drawn with nearest-pixel scaling: + // a row across the image holds only the two checkerboard colours. + const bitmap = page + .locator(".image-plot-host .layer-subplot .imagelayer image") + .first(); + expect( + await bitmap.evaluate((node) => getComputedStyle(node).imageRendering), + ).toBe("pixelated"); + const box = await bitmap.boundingBox(); + if (!box) throw new Error("bitmap not rendered"); + const row = await page.screenshot({ + clip: { + x: Math.ceil(box.x) + 1, + y: Math.round(box.y + box.height * (3.5 / 8)), + width: Math.floor(box.width) - 3, + height: 1, + }, + }); + const screenColours = await page.evaluate(async (png) => { + const image = new Image(); + image.src = `data:image/png;base64,${png}`; + await image.decode(); + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d")!; + context.drawImage(image, 0, 0); + const data = context.getImageData(0, 0, canvas.width, 1).data; + const colours = new Set(); + for (let p = 0; p < data.length; p += 4) { + colours.add((data[p] << 16) | (data[p + 1] << 8) | data[p + 2]); + } + return colours.size; + }, row.toString("base64")); + expect(screenColours).toBe(2); + }); }); diff --git a/tests/ts/components/ImagePlot.test.tsx b/tests/ts/components/ImagePlot.test.tsx index 2b7e77a..a99bc2c 100644 --- a/tests/ts/components/ImagePlot.test.tsx +++ b/tests/ts/components/ImagePlot.test.tsx @@ -34,6 +34,7 @@ beforeEach(() => { height, }), putImageData: () => {}, + drawImage: () => {}, })) as unknown as HTMLCanvasElement["getContext"]; HTMLCanvasElement.prototype.toDataURL = vi.fn( () => "data:image/png;base64,STUB", @@ -322,6 +323,45 @@ describe("ImagePlot pixel-center convention", () => { expect(hoverText()).toMatch(/z: 0(\.0+)?$/); }); + it("draws zoomed-in pixels sharp (nearest pixel)", () => { + vi.useFakeTimers(); + const toDataURL = vi.mocked(HTMLCanvasElement.prototype.toDataURL); + render(plotElement({ data: RAMP })); + act(() => vi.advanceTimersByTime(200)); + // 4 pixels over the default 1024-pixel plot area: the bitmap is + // magnified ×256 and drawn with nearest-pixel scaling. + const encoded = toDataURL.mock.contexts.at(-1) as HTMLCanvasElement; + expect([encoded.width, encoded.height]).toEqual([1024, 1024]); + expect( + document + .querySelector(".image-plot-host") + ?.classList.contains("image-crisp-pixels"), + ).toBe(true); + }); + + it("keeps the smooth rendering of decimated images", () => { + vi.useFakeTimers(); + // 1100² image (LOD) shown whole over 1024 pixels: decimated ×2. + const row = new Float32Array(1100); + const large: ImageData = { + ...RAMP, + id: "large", + width: 1100, + height: 1100, + data: Array.from({ length: 1100 }, () => row), + }; + render(plotElement({ data: large })); + act(() => vi.advanceTimersByTime(200)); + expect( + (currentPlot().layout as unknown as { images: unknown[] }).images, + ).toHaveLength(1); + expect( + document + .querySelector(".image-plot-host") + ?.classList.contains("image-crisp-pixels"), + ).toBe(false); + }); + it("computes stats over the pixels the rectangle covers", () => { renderPlot({ data: RAMP }); fireEvent.click(screen.getByRole("button", { name: "Stats area" })); diff --git a/tests/ts/components/MultiImageSpatialPlot.test.tsx b/tests/ts/components/MultiImageSpatialPlot.test.tsx index d545a73..e641d52 100644 --- a/tests/ts/components/MultiImageSpatialPlot.test.tsx +++ b/tests/ts/components/MultiImageSpatialPlot.test.tsx @@ -64,6 +64,7 @@ beforeEach(() => { }; }, putImageData: () => {}, + drawImage: () => {}, })) as unknown as HTMLCanvasElement["getContext"]; HTMLCanvasElement.prototype.toDataURL = vi.fn( () => `data:image/png;base64,STUB${++dataUrlCounter}`, @@ -149,6 +150,13 @@ describe("MultiImageSpatialPlot", () => { // 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]); + // 2×2 images spread over the plot: their pixels are magnified and drawn + // with nearest-pixel scaling. + expect( + container + .querySelector(".multi-image-spatial-wrap") + ?.classList.contains("image-crisp-pixels"), + ).toBe(true); }); it("spans the axis range across all images (Y reversed)", () => { @@ -308,6 +316,9 @@ describe("MultiImageSpatialPlot", () => { 0, ), ).toBeLessThan(1024 * 1024); + const wrap = () => container.querySelector(".multi-image-spatial-wrap"); + // Decimated bitmaps keep the smooth rendering. + expect(wrap()?.classList.contains("image-crisp-pixels")).toBe(false); const plot = plotState.props as { onRelayout: (event: Record) => void; @@ -328,5 +339,7 @@ describe("MultiImageSpatialPlot", () => { expect(zoomed.sizex).toBe(16); expect(zoomed.sizey).toBe(16); expect(rasterSizes.at(-1)).toEqual({ width: 16, height: 16 }); + // Zoomed in, the 16 visible pixels are magnified into sharp blocks. + expect(wrap()?.classList.contains("image-crisp-pixels")).toBe(true); }); }); diff --git a/tests/ts/utils/imageLod.test.ts b/tests/ts/utils/imageLod.test.ts index 863d86c..fcf3119 100644 --- a/tests/ts/utils/imageLod.test.ts +++ b/tests/ts/utils/imageLod.test.ts @@ -14,6 +14,7 @@ import { type ImageGeometry, DEFAULT_MAX_RASTER_DIM, LOD_MIN_PIXELS, + isMagnified, rasterPlan, shouldUseLod, visibleWindow, @@ -104,9 +105,11 @@ describe("visibleWindow", () => { }); describe("rasterPlan", () => { - it("renders 1:1 when zoomed in (window smaller than the display)", () => { + it("magnifies a zoomed-in window by integer factors", () => { const win = { i0: 100, i1: 110, j0: 200, j1: 215 }; const plan = rasterPlan(win, 900, 900, 1); + // Every pixel is painted (stride 1), then drawn as a 90 × 60 block: + // floor(900 / 10) = 90 and floor(900 / 15) = 60. expect(plan).toEqual({ i0: 100, j0: 200, @@ -114,7 +117,10 @@ describe("rasterPlan", () => { ch: 15, strideX: 1, strideY: 1, + zoomX: 90, + zoomY: 60, }); + expect(isMagnified(plan)).toBe(true); }); it("decimates to the display resolution when zoomed out", () => { @@ -125,6 +131,7 @@ describe("rasterPlan", () => { expect(plan.strideY).toBe(5); expect(plan.cw).toBe(Math.ceil(4096 / 5)); expect(plan.ch).toBe(Math.ceil(4096 / 5)); + expect(isMagnified(plan)).toBe(false); }); it("caps the output at maxDim even on hi-DPI displays", () => { @@ -133,6 +140,7 @@ describe("rasterPlan", () => { // plotW*dpr = 8000 but capped to 2048 → stride = ceil(4096/2048) = 2. expect(plan.strideX).toBe(2); expect(plan.cw).toBe(2048); + expect(plan.zoomX).toBe(1); }); it("accounts for the device pixel ratio when zooming in", () => { @@ -141,6 +149,33 @@ describe("rasterPlan", () => { const plan = rasterPlan(win, 800, 800, 2); expect(plan.strideX).toBe(1); expect(plan.cw).toBe(1500); + expect(plan.zoomX).toBe(1); + // 10 pixels over 900 CSS px at DPR 2 → blocks of 180 device pixels. + expect( + rasterPlan({ i0: 0, i1: 10, j0: 0, j1: 10 }, 900, 900, 2), + ).toMatchObject({ zoomX: 180, zoomY: 180 }); + }); + + it("magnifies from two device pixels per image pixel", () => { + const plan = (n: number) => + rasterPlan({ i0: 0, i1: n, j0: 0, j1: n }, 900, 900, 1); + expect(plan(451).zoomX).toBe(1); // 1.99 device pixels per image pixel + expect(plan(450).zoomX).toBe(2); + expect(plan(300).zoomX).toBe(3); + }); + + it("keeps the magnified bitmap within maxDim", () => { + const plan = rasterPlan({ i0: 0, i1: 10, j0: 0, j1: 3 }, 4000, 4000, 2); + // avail = 2048 → floor(2048 / 10) = 204 and floor(2048 / 3) = 682. + expect(plan.zoomX).toBe(204); + expect(plan.zoomY).toBe(682); + expect(plan.cw * plan.zoomX).toBeLessThanOrEqual(DEFAULT_MAX_RASTER_DIM); + expect(plan.ch * plan.zoomY).toBeLessThanOrEqual(DEFAULT_MAX_RASTER_DIM); + }); + + it("magnifies each axis independently", () => { + const plan = rasterPlan({ i0: 0, i1: 10, j0: 0, j1: 3000 }, 900, 900, 1); + expect(plan).toMatchObject({ strideX: 1, zoomX: 90, strideY: 4, zoomY: 1 }); }); }); @@ -275,6 +310,9 @@ describe("shared spatial raster planning", () => { ch: 16, strideX: 1, strideY: 1, + // 16 pixels over 1000 × 800 CSS pixels → blocks of 62 × 50 pixels. + zoomX: 62, + zoomY: 50, }); expect(raster?.placement).toEqual({ x: 2057.5, @@ -287,6 +325,68 @@ describe("shared spatial raster planning", () => { }); }); +describe("zoomed-in magnification (crisp pixels)", () => { + it("magnifies a whole small image when it fits once magnified", () => { + 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?.plan).toMatchObject({ + i0: 0, + j0: 0, + cw: 4, + ch: 4, + strideX: 1, + strideY: 1, + zoomX: 100, + zoomY: 100, + }); + }); + + it("magnifies the visible window of a small image zoomed in", () => { + // 512² image, pixels 100…115 shown over 800 px: the whole image would + // exceed the bitmap cap once magnified, so only the window is painted. + const geometry = { width: 512, height: 512, x0: 0, y0: 0, dx: 1, dy: 1 }; + const view = { + x: [99.5, 115.5] as [number, number], + y: [115.5, 99.5] as [number, number], + }; + const raster = planImageRaster(geometry, view, { w: 800, h: 800 }, 1); + expect(raster?.plan).toEqual({ + i0: 100, + j0: 100, + cw: 16, + ch: 16, + strideX: 1, + strideY: 1, + zoomX: 50, + zoomY: 50, + }); + // The magnified bitmap keeps the physical extent of its 16 pixels. + expect(raster?.placement).toEqual({ + x: 99.5, + dx: 1, + y: 99.5, + dy: 1, + cw: 16, + ch: 16, + }); + }); + + it("paints a small image whole and 1:1 when not zoomed in", () => { + const geometry = { width: 1000, height: 1000, x0: 0, y0: 0, dx: 1, dy: 1 }; + const raster = planImageRaster(geometry, null, { w: 800, h: 800 }, 1); + expect(raster?.plan).toEqual({ + i0: 0, + j0: 0, + cw: 1000, + ch: 1000, + strideX: 1, + strideY: 1, + zoomX: 1, + zoomY: 1, + }); + }); +}); + // --------------------------------------------------------------------------- // paintImageWindow — resampling correctness // --------------------------------------------------------------------------- diff --git a/tests/ts/utils/imageRaster.test.ts b/tests/ts/utils/imageRaster.test.ts new file mode 100644 index 0000000..086818e --- /dev/null +++ b/tests/ts/utils/imageRaster.test.ts @@ -0,0 +1,209 @@ +/** + * Rasterisation of zoomed-in images: the painted window is magnified onto a + * larger canvas without smoothing, so each image pixel becomes a sharp block + * whatever the browser (and in exported PNG images), and the views draw the + * enlarged bitmap with nearest-pixel scaling (``image-rendering: pixelated``) + * only when it shows real pixels enlarged on screen. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + planImageRaster, + rasterizeImage, + showsCrispPixels, +} from "../../../src/utils/imageRaster"; + +interface FakeContext { + canvas: HTMLCanvasElement; + imageSmoothingEnabled: boolean; + drawImage: ReturnType; + createImageData: (w: number, h: number) => ImageData; + putImageData: () => void; +} + +let contexts: FakeContext[] = []; +let refuseMagnifiedContext = false; + +beforeEach(() => { + contexts = []; + refuseMagnifiedContext = false; + HTMLCanvasElement.prototype.getContext = vi.fn(function ( + this: HTMLCanvasElement, + ) { + if (refuseMagnifiedContext && contexts.length > 0) return null; + const context: FakeContext = { + canvas: this, + imageSmoothingEnabled: true, + drawImage: vi.fn(), + createImageData: (w: number, h: number) => + ({ + data: new Uint8ClampedArray(w * h * 4), + width: w, + height: h, + }) as ImageData, + putImageData: () => {}, + }; + contexts.push(context); + return context; + }) as unknown as HTMLCanvasElement["getContext"]; + HTMLCanvasElement.prototype.toDataURL = vi.fn(function ( + this: HTMLCanvasElement, + ) { + return `data:image/png;${this.width}x${this.height}`; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function ramp(width: number, height: number): Float32Array[] { + return Array.from({ length: height }, (_, j) => + Float32Array.from({ length: width }, (_, i) => j * width + i), + ); +} + +function rasterize( + width: number, + height: number, + plotPx: { w: number; h: number }, +) { + return rasterizeImage({ + rows: ramp(width, height), + geometry: { width, height, x0: 0, y0: 0, dx: 1, dy: 1 }, + view: null, + plotPx, + dpr: 1, + lut: [0, width * height - 1], + colormap: "gray", + inverted: false, + resampleMethod: "nearest", + }); +} + +describe("rasterizeImage magnification", () => { + it("draws zoomed-in pixels as blocks without smoothing", () => { + const raster = rasterize(4, 4, { w: 400, h: 400 }); + // Painted 1:1 on a 4×4 canvas, then magnified ×100 without smoothing. + expect(contexts).toHaveLength(2); + const [painted, magnified] = contexts; + expect(painted.canvas.width).toBe(4); + expect(magnified.canvas.width).toBe(400); + expect(magnified.canvas.height).toBe(400); + expect(magnified.imageSmoothingEnabled).toBe(false); + expect(magnified.drawImage).toHaveBeenCalledWith( + painted.canvas, + 0, + 0, + 400, + 400, + ); + expect(raster?.source).toBe("data:image/png;400x400"); + // The bitmap keeps the physical extent of the image. + expect(raster?.placement).toEqual({ + x: -0.5, + dx: 1, + y: -0.5, + dy: 1, + cw: 4, + ch: 4, + }); + }); + + it("encodes the painted bitmap when not zoomed in", () => { + const raster = rasterize(1000, 1000, { w: 800, h: 800 }); + expect(contexts).toHaveLength(1); + expect(contexts[0].drawImage).not.toHaveBeenCalled(); + expect(raster?.source).toBe("data:image/png;1000x1000"); + }); + + it("falls back to the painted bitmap without a 2D context to magnify", () => { + refuseMagnifiedContext = true; + const raster = rasterize(4, 4, { w: 400, h: 400 }); + expect(raster?.source).toBe("data:image/png;4x4"); + expect(raster?.placement.cw).toBe(4); + }); +}); + +describe("showsCrispPixels", () => { + const geometry = (width: number, height: number) => ({ + width, + height, + x0: 0, + y0: 0, + dx: 1, + dy: 1, + }); + const fullView = (width: number, height: number) => ({ + x: [-0.5, width - 0.5] as [number, number], + y: [height - 0.5, -0.5] as [number, number], + }); + + it("is set for magnified pixels", () => { + const raster = planImageRaster( + geometry(4, 4), + null, + { w: 400, h: 400 }, + 1, + )!; + expect( + showsCrispPixels(raster, fullView(4, 4), { w: 400, h: 400 }, 1), + ).toBe(true); + }); + + it("is set for 1:1 pixels slightly enlarged on screen", () => { + // 300 pixels over 400 device pixels: not magnified (< 2 per pixel) but + // still enlarged, and nearest-pixel scaling keeps them sharp. + const raster = planImageRaster( + geometry(300, 300), + null, + { w: 400, h: 400 }, + 1, + )!; + expect(raster.plan.zoomX).toBe(1); + expect( + showsCrispPixels(raster, fullView(300, 300), { w: 400, h: 400 }, 1), + ).toBe(true); + }); + + it("is not set for a bitmap reduced on screen", () => { + // 1000 pixels painted 1:1 over 800 device pixels: nearest-pixel + // downscaling would alias the image. + const raster = planImageRaster( + geometry(1000, 1000), + null, + { w: 800, h: 800 }, + 1, + )!; + expect( + showsCrispPixels(raster, fullView(1000, 1000), { w: 800, h: 800 }, 1), + ).toBe(false); + }); + + it("is not set for a decimated bitmap", () => { + // 2000² image (LOD) decimated ×3 to 667 cells over 800 device pixels: + // enlarged, but each cell aggregates several pixels. + const raster = planImageRaster( + geometry(2000, 2000), + null, + { w: 800, h: 800 }, + 1, + )!; + expect(raster.plan.strideX).toBe(3); + expect( + showsCrispPixels(raster, fullView(2000, 2000), { w: 800, h: 800 }, 1), + ).toBe(false); + }); + + it("accounts for the device pixel ratio", () => { + // 1000 pixels over 800 CSS pixels at DPR 1.5 = 1200 device pixels. + const raster = planImageRaster( + geometry(1000, 1000), + null, + { w: 800, h: 800 }, + 1.5, + )!; + expect( + showsCrispPixels(raster, fullView(1000, 1000), { w: 800, h: 800 }, 1.5), + ).toBe(true); + }); +}); From feb088a73041578bd835c72b27f4fb9f7fdf1c86 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:42:05 +0100 Subject: [PATCH 2/2] docs: changelog entry for the zoomed-in image pixels Refs #74 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c354e4e..911a65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) - **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. +- **Image viewer — blurred pixels when zooming in:** since version 0.6.1, zooming in on an image showed blurred pixels, each one fading into its neighbours, so that close values could not be told apart. Zoomed-in pixels are now drawn as sharp squares (nearest pixel, as in DataLab desktop) in the image viewer, the _Spatial_ multi-image view and the processing preview, in every browser and in the images exported from the plot. Zoomed-out images are displayed as before. ### Changed