From 660335c8898f9139e6a2342ef89e0daffb91bd55 Mon Sep 17 00:00:00 2001 From: Sangjoon Bob Lee Date: Wed, 12 Aug 2026 20:51:47 -0700 Subject: [PATCH 1/2] fix: keep Show3D panel contrast independent --- js/colormaps.ts | 59 ++- js/show3d/index.tsx | 240 ++++++++--- src/quantem/widget/show3d.py | 129 ++++-- tests/show3d/test_pages.py | 2 +- tests/show3d/test_zoom_contrast.py | 85 +++- tests/show3d/test_zoom_contrast_browser.py | 457 +++++++++++++++++++++ tests/test_widget_performance_contract.py | 2 +- 7 files changed, 849 insertions(+), 125 deletions(-) diff --git a/js/colormaps.ts b/js/colormaps.ts index bde6cd5a..82f69627 100644 --- a/js/colormaps.ts +++ b/js/colormaps.ts @@ -1178,6 +1178,7 @@ type GPUSlot = { directSlotBindGroup: GPUBindGroup | null; directRegionParamsBuffers: (GPUBuffer | null)[]; directRegionBindGroups: (GPUBindGroup | null)[]; + directRegionLutNames: string[]; sharedGridBindGroup: GPUBindGroup | null; sharedGridBlitBindGroup: GPUBindGroup | null; count: number; @@ -1216,6 +1217,7 @@ export class GPUColormapEngine { private slots: GPUSlot[] = []; private lutBuffer: GPUBuffer | null = null; private currentLutName: string = ""; + private namedLutBuffers = new Map(); private directGridParams = new ArrayBuffer(64); private directGridParamsU32 = new Uint32Array(this.directGridParams); private directGridParamsF32 = new Float32Array(this.directGridParams); @@ -1274,6 +1276,27 @@ export class GPUColormapEngine { for (const buf of slot.directRegionParamsBuffers) buf?.destroy(); } + private createLutBuffer(lut: Uint8Array): GPUBuffer { + const packed = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + packed[i] = lut[i * 3] | (lut[i * 3 + 1] << 8) | (lut[i * 3 + 2] << 16); + } + const buffer = this.device.createBuffer({ + size: packed.byteLength, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, + }); + this.device.queue.writeBuffer(buffer, 0, packed); + return buffer; + } + + private namedLutBuffer(name: string, lut: Uint8Array): GPUBuffer { + const cached = this.namedLutBuffers.get(name); + if (cached) return cached; + const buffer = this.createLutBuffer(lut); + this.namedLutBuffers.set(name, buffer); + return buffer; + } + private retireSlot(slot: GPUSlot): void { this.retiredSlots.push(slot); void this.device.queue.onSubmittedWorkDone() @@ -1482,16 +1505,7 @@ export class GPUColormapEngine { slot.sharedGridBindGroup = null; } } - // Pack RGB triplets into u32 for GPU (R in low bits) - const packed = new Uint32Array(256); - for (let i = 0; i < 256; i++) { - packed[i] = lut[i * 3] | (lut[i * 3 + 1] << 8) | (lut[i * 3 + 2] << 16); - } - this.lutBuffer = this.device.createBuffer({ - size: packed.byteLength, - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, - }); - this.device.queue.writeBuffer(this.lutBuffer, 0, packed); + this.lutBuffer = this.createLutBuffer(lut); this.currentLutName = lutName; } @@ -1563,6 +1577,7 @@ export class GPUColormapEngine { directSlotBindGroup: null, directRegionParamsBuffers: [], directRegionBindGroups: [], + directRegionLutNames: [], sharedGridBindGroup: null, sharedGridBlitBindGroup: null, count: data.length, @@ -1618,6 +1633,7 @@ export class GPUColormapEngine { directSlotBindGroup: null, directRegionParamsBuffers: [], directRegionBindGroups: [], + directRegionLutNames: [], sharedGridBindGroup: null, sharedGridBlitBindGroup: null, count, @@ -3156,6 +3172,7 @@ export class GPUColormapEngine { sourcePanelWidth: number; transforms?: { zoom: number; panX: number; panY: number }[]; sourcePanelIndices?: number[]; + panelLuts?: { name: string; lut: Uint8Array }[]; smooth?: boolean; }, ): boolean { @@ -3167,7 +3184,11 @@ export class GPUColormapEngine { Math.abs(transform?.panX ?? 0) > 1e-3 || Math.abs(transform?.panY ?? 0) > 1e-3 )); - if (!hasActiveTransform && this.renderPackedPanelTransformComputeToCanvas(slot, range, logScale, ctx, opts)) { + // The compute shortcut binds the one shared LUT. Any explicit panel LUT + // contract must use the per-panel fragment bindings, even when all named + // panel maps currently happen to be identical. + const hasPanelLuts = Boolean(opts.panelLuts?.length); + if (!hasActiveTransform && !hasPanelLuts && this.renderPackedPanelTransformComputeToCanvas(slot, range, logScale, ctx, opts)) { return true; } const outW = Math.max(1, Math.round(opts.width)); @@ -3202,6 +3223,12 @@ export class GPUColormapEngine { }); slot.directRegionParamsBuffers[panel] = paramsBuffer; } + const panelLut = opts.panelLuts?.[panel]; + const lutName = panelLut?.name || this.currentLutName; + const lutBuffer = panelLut + ? this.namedLutBuffer(panelLut.name, panelLut.lut) + : this.lutBuffer; + if (!lutBuffer) continue; const panelRange = Array.isArray(range) ? (range[panel] ?? range[0]) : range; const panelLogScale = Array.isArray(logScale) ? !!logScale[panel] : logScale; const sourcePanel = Math.max(0, Math.round(opts.sourcePanelIndices?.[panel] ?? panel)); @@ -3229,15 +3256,19 @@ export class GPUColormapEngine { pf[14] = transform?.panX ?? 0; pf[15] = transform?.panY ?? 0; this.device.queue.writeBuffer(paramsBuffer, 0, params); - if (!slot.directRegionBindGroups[panel]) { + if ( + !slot.directRegionBindGroups[panel] + || slot.directRegionLutNames[panel] !== lutName + ) { slot.directRegionBindGroups[panel] = this.device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [ { binding: 0, resource: { buffer: paramsBuffer } }, { binding: 1, resource: { buffer: slot.dataBuffer } }, - { binding: 2, resource: { buffer: this.lutBuffer } }, + { binding: 2, resource: { buffer: lutBuffer } }, ], }); + slot.directRegionLutNames[panel] = lutName; } } @@ -3309,6 +3340,8 @@ export class GPUColormapEngine { this.retiredSlots = []; this.lutBuffer?.destroy(); this.lutBuffer = null; + for (const buffer of this.namedLutBuffers.values()) buffer.destroy(); + this.namedLutBuffers.clear(); this.directGridRangesBuffer?.destroy(); this.directGridRangesBuffer = null; this.directGridRangesCapacity = 0; diff --git a/js/show3d/index.tsx b/js/show3d/index.tsx index dfd09844..9d75aa61 100644 --- a/js/show3d/index.tsx +++ b/js/show3d/index.tsx @@ -3477,6 +3477,11 @@ function Show3D() { next = [panel]; } lastSelectedPanelRef.current = panel; + const selectionDebug = show3dPerfDebug(); + if (selectionDebug) { + selectionDebug.lastSelectedPanel = panel; + selectionDebug.selectedPanels = next.slice(); + } setSelectedPanels(next); return event.shiftKey || event.metaKey || event.ctrlKey; }, [displayOrderedPanelIndices, hasPanelChoices, reorderMode, selectedPanelSet, selectedVisiblePanels, setSelectedPanels, visiblePanelIndices]); @@ -3886,6 +3891,10 @@ function Show3D() { () => Array.isArray(panelCmaps) ? panelCmaps : [], [panelCmaps], ); + const panelCmapsLiveRef = React.useRef(normalizedPanelCmaps); + React.useEffect(() => { + panelCmapsLiveRef.current = normalizedPanelCmaps; + }, [normalizedPanelCmaps]); const panelCmapFor = React.useCallback((panelIdx: number) => { const value = normalizedPanelCmaps[panelIdx]; return (value && COLORMAPS[value]) ? value : (cmap || "inferno"); @@ -3902,11 +3911,14 @@ function Show3D() { if (shared || n <= 1) { const value = panelCmapFor(panelIdx); cmapLiveRef.current = value; + panelCmapsLiveRef.current = []; setCmap(value); setPanelCmaps([]); return; } - setPanelCmaps(Array.from({ length: n }, (_, idx) => panelCmapFor(idx))); + const next = Array.from({ length: n }, (_, idx) => panelCmapFor(idx)); + panelCmapsLiveRef.current = next; + setPanelCmaps(next); }, [nPanels, panelCmapFor, setCmap, setPanelCmaps]); const setCmapForPanel = React.useCallback((panelIdx: number, value: string) => { const n = Math.max(1, nPanels || 1); @@ -3921,7 +3933,13 @@ function Show3D() { ? [...normalizedPanelCmaps] : Array.from({ length: n }, () => cmap || "inferno"); next[idx] = value; + panelCmapsLiveRef.current = next; setPanelCmaps(next); + const debug = show3dPerfDebug(); + if (debug) { + debug.lastColorPanel = idx; + debug.lastPanelCmaps = next.slice(); + } if (idx === 0) setCmap(value); }, [cmap, colorShared, normalizedPanelCmaps, nPanels, setCmap, setPanelCmaps]); @@ -4023,6 +4041,12 @@ function Show3D() { const [dataMax] = useModelState("data_max"); const [autoVmins] = useModelState("auto_vmins"); const [autoVmaxs] = useModelState("auto_vmaxs"); + const [autoVminsPerPanel] = useModelState("auto_vmins_per_panel"); + const [autoVmaxsPerPanel] = useModelState("auto_vmaxs_per_panel"); + const initialAutoPercentilesRef = React.useRef({ + low: percentileLow, + high: percentileHigh, + }); React.useEffect(() => { if (compareMode !== "blink") { setBlinkPhase(0); @@ -5411,7 +5435,11 @@ function Show3D() { const requestedCols = (maxCols && maxCols > 0) ? Math.min(maxCols, n, MAX_PANEL_COLUMNS) : Math.min(n, MAX_PANEL_COLUMNS); - if (n >= 8 && requestedCols >= 3 && rootLayoutWidth > 0) { + // Honor the scientist's column choice by shrinking square panels to fit + // the current surface. Previously only galleries with 8+ panels did this, + // so a 3-panel stack could display `Cols 2` while retaining max_cols=3/4, + // then silently jump to three columns after an unrelated repaint. + if (rootLayoutWidth > 0) { return Math.max(180, Math.min(500, Math.floor(rootLayoutWidth / requestedCols))); } return 500; @@ -5463,13 +5491,16 @@ function Show3D() { } if (cursorInfoRafRef.current != null) return; if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") { - if (next) setCursorInfo(next); + setCursorInfo(next); return; } cursorInfoRafRef.current = window.requestAnimationFrame(() => { cursorInfoRafRef.current = null; const pending = cursorInfoPendingRef.current; - if (!pending) return; + if (!pending) { + setCursorInfo(null); + return; + } setCursorInfo((prev) => ( prev && prev.row === pending.row && @@ -5482,6 +5513,13 @@ function Show3D() { }); }, []); + // Hover is inspection only. A committed color edit targets the selected + // panel so moving from the canvas to the dropdown cannot silently retarget + // the control or retain a stale hover from another panel. + const colorTargetPanel = nPanels > 1 + ? Math.max(0, selectedVisiblePanels[0] ?? visiblePanelIndices[0] ?? 0) + : 0; + React.useEffect(() => () => { if (cursorInfoRafRef.current != null && typeof window !== "undefined") { window.cancelAnimationFrame(cursorInfoRafRef.current); @@ -5864,28 +5902,41 @@ function Show3D() { const imageHistogramPreviewPctRef = React.useRef<[number, number] | null>(null); const panelHistogramPreviewPctRef = React.useRef>(new Map()); const histogramPreviewPaintRafRef = React.useRef(null); - const perPanelHistogramEnabled = (nPanels || 1) > 1 && !linkContrast; + // Packed panels are independent scientific domains. Always compute their + // histograms and numerical ranges independently; `linkContrast` links only + // the relative handle gesture and must never share one absolute range. + const perPanelHistogramEnabled = (nPanels || 1) > 1 && !sharedPanelSource; - const updatePanelState = (panel: number, patch: Partial) => { + const setPanelRangePercentages = ( + panel: number, + minPct: number, + maxPct: number, + linked: boolean, + ) => { const n = Math.max(1, nPanels || 1); - const live = panelStatesLiveRef.current.length === n + const stack = resolveDisplayBounds(dataMin, dataMax, traitVmin, traitVmax, logScale); + const liveStates = panelStatesLiveRef.current.length === n ? panelStatesLiveRef.current : panelStates; - const next = Array.from({ length: n }, (_, i) => { - const state = live[i] || panelStates[i] || initialState; - return i === panel ? { ...state, ...patch } : state; + const nextStates = Array.from({ length: n }, (_, index) => { + const state = liveStates[index] || initialState; + return linked || index === panel + ? { ...state, imageVminPct: minPct, imageVmaxPct: maxPct } + : state; }); - panelStatesLiveRef.current = next; - setPanelStates(next); - }; - const setPanelRangeValues = (panel: number, minValue: number | null, maxValue: number | null) => { - const n = Math.max(1, nPanels || 1); - const nextMins = Array.from({ length: n }, (_, i) => vminPerPanelLiveRef.current[i] ?? null); - const nextMaxs = Array.from({ length: n }, (_, i) => vmaxPerPanelLiveRef.current[i] ?? null); - nextMins[panel] = minValue; - nextMaxs[panel] = maxValue; + const nextMins = Array.from({ length: n }, (_, index) => vminPerPanelLiveRef.current[index] ?? null); + const nextMaxs = Array.from({ length: n }, (_, index) => vmaxPerPanelLiveRef.current[index] ?? null); + for (let index = 0; index < n; index++) { + if (!linked && index !== panel) continue; + const panelRange = panelDataRanges[index]; + const range = panelRange && panelRange.max > panelRange.min ? panelRange : stack; + nextMins[index] = pctToValue(minPct, range.min, range.max); + nextMaxs[index] = pctToValue(maxPct, range.min, range.max); + } + panelStatesLiveRef.current = nextStates; vminPerPanelLiveRef.current = nextMins; vmaxPerPanelLiveRef.current = nextMaxs; + setPanelStates(nextStates); setVminPerPanel(nextMins); setVmaxPerPanel(nextMaxs); }; @@ -5939,8 +5990,8 @@ function Show3D() { const highPct = valueToPct(sharedAutoRange.vmax, stack.min, stack.max, imageVmaxPct); return { ...sliderRange(effectiveRange.min, effectiveRange.max, lowPct, highPct), logScale }; } - const lowPct = linkContrast ? imageVminPct : state.imageVminPct; - const highPct = linkContrast ? imageVmaxPct : state.imageVmaxPct; + const lowPct = perPanelHistogramEnabled ? state.imageVminPct : imageVminPct; + const highPct = perPanelHistogramEnabled ? state.imageVmaxPct : imageVmaxPct; const slider = sliderRange(effectiveRange.min, effectiveRange.max, lowPct, highPct); return { ...slider, logScale }; }; @@ -5955,10 +6006,10 @@ function Show3D() { const dataRange = findDataRange(panelData); const range = dataRange.max > dataRange.min ? dataRange : fallbackRange; if (range.max <= range.min) return null; - let clipped: { vmin: number; vmax: number } = percentileClip(panelData, low, high); + let clipped = percentileClip(panelData, low, high); const span = range.max - range.min; if (!Number.isFinite(clipped.vmin) || !Number.isFinite(clipped.vmax) || clipped.vmax <= clipped.vmin || clipped.vmax - clipped.vmin < span * 1e-4) { - clipped = { vmin: range.min, vmax: range.max }; + return { vmin: range.min, vmax: range.max, logScale }; } return { vmin: clipped.vmin, vmax: Math.max(clipped.vmin, clipped.vmax), logScale }; }; @@ -6074,7 +6125,27 @@ function Show3D() { low: number, high: number, ): { vmin: number; vmax: number; logScale: boolean } => { - if ((perPanelHistogramEnabled || linkContrast) && autoOn) { + const initialPercentiles = initialAutoPercentilesRef.current; + const temporalAverageOnly = offline + && normalizedAverageWindow(avgWindow) > 1 + && diffMode === "off" + && !browserFilterOnRef.current + && !frequencyFilterIsActive + && !subpixelAlignEnabled; + const stablePanelRange = (!frameTransformActive() || temporalAverageOnly) + && Math.abs(low - initialPercentiles.low) < 1e-6 + && Math.abs(high - initialPercentiles.high) < 1e-6 + ? cachedAutoDisplayRange( + autoVminsPerPanel, + autoVmaxsPerPanel, + panel, + logScale, + ) + : null; + if (autoOn && stablePanelRange) { + return { ...stablePanelRange, logScale }; + } + if (perPanelHistogramEnabled && autoOn) { const autoRange = autoPanelRangeFromData(panelData, range, low, high); if (autoRange) return autoRange; } @@ -6887,7 +6958,7 @@ function Show3D() { const pathIdxRef = React.useRef(0); const playRef = React.useRef({ fps: playbackFps, reverse, boomerang, loop, loopStart, loopEnd: effectiveLoopEnd, - nSlices, width, height, displayScale, canvasW, canvasH, + nSlices, width, height, displayScale, canvasW, canvasH, panelCols: _colsLocal, logScale, autoContrast, percentileLow, percentileHigh, dataMin, dataMax, cmap, imageVminPct, imageVmaxPct, autoVmins, autoVmaxs, @@ -6913,7 +6984,7 @@ function Show3D() { : panelStates; playRef.current = { fps: playbackFps, reverse, boomerang, loop, loopStart, loopEnd: effectiveLoopEnd, - nSlices, width, height, displayScale, canvasW, canvasH, + nSlices, width, height, displayScale, canvasW, canvasH, panelCols: _colsLocal, logScale, autoContrast, percentileLow, percentileHigh, dataMin, dataMax, cmap, imageVminPct, imageVmaxPct, autoVmins, autoVmaxs, @@ -6927,7 +6998,7 @@ function Show3D() { diffMode, avgWindow, }; }, [playbackFps, reverse, boomerang, loop, loopStart, effectiveLoopEnd, - nSlices, width, height, displayScale, canvasW, canvasH, + nSlices, width, height, displayScale, canvasW, canvasH, _colsLocal, logScale, autoContrast, percentileLow, percentileHigh, dataMin, dataMax, cmap, imageVminPct, imageVmaxPct, autoVmins, autoVmaxs, linkContrast, linkedState, linkPanels, panelStates, vminPerPanel, vmaxPerPanel, visiblePanelIndices, @@ -7311,30 +7382,25 @@ function Show3D() { panels: number[], c: typeof playRef.current, ): RenderRange | RenderRange[] => { - if (panels.length > 1) { + // A packed multi-panel source remains multi-panel even when the scientist + // hides all but one panel. Basing this branch on `panels.length` decoded an + // isolated phase panel against the global phase+BF+DF count range during + // GPU scrubbing/playback, so the visible phase image became black. + if (Math.max(1, nPanels || 1) > 1 && !sharedPanelSource) { const sharedAutoRange = c.autoContrast ? sharedDirectDisplayRange(normalized, c) : null; const stack = resolveDisplayBounds(c.dataMin, c.dataMax, c.traitVmin, c.traitVmax, c.logScale); - if (c.linkContrast && !c.autoContrast) { - return panels.map((panel) => { - const panelRange = panelDataRanges[panel]; - const bounds = (panelRange && panelRange.max > panelRange.min) - ? panelRange - : stack; - return { - ...sliderRange( - bounds.min, - bounds.max, - c.imageVminPct, - c.imageVmaxPct, - ), - logScale: c.logScale, - }; - }); - } return panels.map((panel) => { const pdr = panelDataRanges[panel]; const bounds = (pdr && pdr.max > pdr.min) ? pdr : stack; - return resolvePanelRange(panel, bounds, sharedAutoRange); + return resolvePanelRenderRange( + panel, + bounds, + sharedAutoRange, + null, + c.autoContrast, + c.percentileLow, + c.percentileHigh, + ); }); } return sharedDirectDisplayRange(normalized, c); @@ -7360,7 +7426,6 @@ function Show3D() { if ( sharedPanelSource || isRgb - || hasMixedPanelCmaps || flipRows || flipCols || imageRotation % 4 !== 0 @@ -7426,11 +7491,23 @@ function Show3D() { const lut = COLORMAPS[liveCmap] || COLORMAPS.inferno; engine.uploadLUT(liveCmap, lut); const panelCount = panels.length; - const cols = panelColsForCount(panelCount); + // Playback continues in one long rAF effect while layout controls remain + // live. Read columns from playRef with the other live canvas geometry; + // panelColsForCount here would be the closure captured when Play started. + const cols = Math.max(1, Math.min(panelCount, Math.round(c.panelCols || 1))); const rows = Math.ceil(panelCount / cols); const gap = panelCount > 1 ? panelGapPx : 0; const ranges = directPanelRanges(normalized, panels, c); const transforms = directPanelTransforms(panels, c); + const livePanelCmaps = panelCmapsLiveRef.current; + const panelLuts = livePanelCmaps.length === panelSourceCount + ? panels.map((panel) => { + const name = livePanelCmaps[panel] && COLORMAPS[livePanelCmaps[panel]] + ? livePanelCmaps[panel] + : liveCmap; + return { name, lut: COLORMAPS[name] || lut }; + }) + : undefined; const rendered = engine.renderCombinedPanelRegionsDirectToCanvas( slotIdx, ranges, @@ -7447,6 +7524,7 @@ function Show3D() { sourcePanelWidth, transforms, sourcePanelIndices: panels, + panelLuts, smooth: c.smooth, }, ); @@ -7478,6 +7556,7 @@ function Show3D() { }; dbg.lastDirectSourcePanelIndices = panels.slice(); dbg.lastDirectSourcePanelWidth = sourcePanelWidth; + dbg.lastRenderedPanelCmaps = panelLuts?.map((entry) => entry.name) ?? [liveCmap]; dbg.gpuFrameCacheUploaded = gpuFrameCacheUploadedRef.current.size; } return true; @@ -7546,7 +7625,7 @@ function Show3D() { if (hiddenPanelSet.size > 0) return false; const n = Math.max(1, nPanels || 1); - const cols = panelColsForCount(n); + const cols = Math.max(1, Math.min(n, Math.round(c.panelCols || 1))); const rows = Math.ceil(n / cols); const gap = n > 1 ? (panelGapPx) : 0; const renderStartMs = performance.now(); @@ -7602,6 +7681,10 @@ function Show3D() { dbg.lastFrameSource = residentSlotIdx === null ? "gpu-cache" : "gpu-resident-transform"; dbg.lastRenderPath = "webgpu-grid-panels-explicit-ranges"; dbg.lastRenderMs = Number((performance.now() - renderStartMs).toFixed(2)); + dbg.lastDirectPanelRanges = ranges.map(range => ({ + vmin: Number(range.vmin.toPrecision(6)), + vmax: Number(range.vmax.toPrecision(6)), + })); } return true; } @@ -7713,6 +7796,8 @@ function Show3D() { } const engine = gpuCmapRef.current; const adapterInfo = getGPUInfo(); + const adapterDebug = show3dPerfDebug(); + if (adapterDebug) adapterDebug.webgpuAdapter = adapterInfo || "unknown"; if (!engine || !gpuCmapReadyRef.current || /swiftshader|software/i.test(adapterInfo)) { setGpuResidency({ stage: "fallback", ready: 0, error: "WebGPU unavailable" }); const dbg = show3dPerfDebug(); @@ -8252,7 +8337,7 @@ function Show3D() { dh <= c.canvasH; const drawSharedScaledBitmap = (ctx: CanvasRenderingContext2D, bitmap: ImageBitmap) => { const n = Math.max(1, nPanels || 1); - const cols = panelColsForCount(n); + const cols = Math.max(1, Math.min(n, Math.round(c.panelCols || 1))); const rows = Math.ceil(n / cols); const gap = n > 1 ? (panelGapPx) : 0; const outPanelW = (c.canvasW - gap * (cols - 1)) / cols; @@ -8281,6 +8366,7 @@ function Show3D() { if (sourceW <= 0 || frame.length < c.height * sourceW) return false; const panelImg = offCtx.createImageData(panelW, c.height); const sharedAutoRange = c.autoContrast ? { vmin, vmax } : null; + const livePanelCmaps = panelCmapsLiveRef.current; offCtx.clearRect(0, 0, offscreen.width, offscreen.height); for (const panel of c.visiblePanelIndices) { if (panel < 0 || panel >= panelCountForGrid) continue; @@ -8295,7 +8381,11 @@ function Show3D() { ? pdr : resolveDisplayBounds(c.dataMin, c.dataMax, c.traitVmin, c.traitVmax, c.logScale); const range = resolvePanelRenderRange(panel, panelRange, sharedAutoRange, null, c.autoContrast, c.percentileLow, c.percentileHigh); - renderPackedPanelPlayback(frame, sourceW, x0, panelW, c.height, panelImg.data, lut, range.vmin, range.vmax, c.logScale); + const panelCmap = livePanelCmaps.length === panelCountForGrid + ? livePanelCmaps[panel] + : liveCmap; + const panelLut = COLORMAPS[panelCmap] || lut; + renderPackedPanelPlayback(frame, sourceW, x0, panelW, c.height, panelImg.data, panelLut, range.vmin, range.vmax, c.logScale); offCtx.putImageData(panelImg, panel * panelW, 0); } drawMain(ctx, offscreen); @@ -8368,7 +8458,7 @@ function Show3D() { const gpuCtx = ensureGpuDisplayContext(engine, c.canvasW, c.canvasH); if (gpuCtx) { const n = panelCountForGrid; - const cols = panelColsForCount(n); + const cols = Math.max(1, Math.min(n, Math.round(c.panelCols || 1))); const rows = Math.ceil(n / cols); const gap = n > 1 ? (panelGapPx) : 0; const sourcePanelWidthForGrid = sharedPanelSource @@ -8389,7 +8479,7 @@ function Show3D() { if (usedPackedPanelRegions) { const panelW = sourcePanelWidthForGrid; const sharedAutoRange = c.autoContrast ? { vmin, vmax } : null; - const ranges = c.linkContrast + const ranges = !perPanelHistogramEnabled ? { vmin, vmax } : Array.from({ length: n }, (_, p) => { const panelData = frame ? extractPanelSlice(frame, p, c.logScale) : null; @@ -8407,6 +8497,15 @@ function Show3D() { : resolveDisplayBounds(c.dataMin, c.dataMax, c.traitVmin, c.traitVmax, c.logScale)); return resolvePanelRenderRange(p, panelRange, sharedAutoRange, panelData, c.autoContrast, c.percentileLow, c.percentileHigh); }); + const livePanelCmaps = panelCmapsLiveRef.current; + const panelLuts = livePanelCmaps.length === n + ? Array.from({ length: n }, (_, panel) => { + const name = livePanelCmaps[panel] && COLORMAPS[livePanelCmaps[panel]] + ? livePanelCmaps[panel] + : liveCmap; + return { name, lut: COLORMAPS[name] || lut }; + }) + : undefined; rendered = engine.renderCombinedPanelRegionsDirectToCanvas( slotIdx, ranges, @@ -8422,6 +8521,7 @@ function Show3D() { bgRgb: packedRgbFromHex(interPanelGapColor), sourcePanelWidth: panelW, transforms: panelTransformsForDirect, + panelLuts, smooth: c.smooth, }, ); @@ -9399,7 +9499,11 @@ function Show3D() { const n = Math.max(1, Math.round(nSlices || 1)); const drawIdx = ((Math.round(playbackIdxRef.current || liveSliceIdx || 0) % n) + n) % n; const inputAt = Number(show3dPerfDebug()?.lastHistogramInputAt || performance.now()); - if (gpuFrameCacheUploadedRef.current.has(drawIdx) && renderGpuCachedSliceDirect(drawIdx, false)) { + const rendered = gpuFrameCacheUploadedRef.current.has(drawIdx) && ( + renderGpuTemporalAverageSliceDirect(drawIdx, false) + || renderGpuCachedSliceDirect(drawIdx, false) + ); + if (rendered) { updatePlaybackLiveControls(drawIdx); const debug = show3dPerfDebug(); if (debug) { @@ -9417,6 +9521,7 @@ function Show3D() { liveSliceIdx, nSlices, renderGpuCachedSliceDirect, + renderGpuTemporalAverageSliceDirect, updatePlaybackLiveControls, ]); @@ -9699,7 +9804,8 @@ function Show3D() { return; } const started = performance.now(); - const rendered = renderGpuCachedSliceDirect(idx, false); + const rendered = renderGpuTemporalAverageSliceDirect(idx, false) + || renderGpuCachedSliceDirect(idx, false); if (!rendered) { if (debug) debug.lastDisplayStyleSkip = "gpu-render-declined"; return; @@ -12375,6 +12481,8 @@ function Show3D() { // multiple of max_cols). Otherwise the click attributes to the last // real panel and zoom/pan jumps unexpectedly. const panelForSelection = panelIdxFromEvent(e); + const pointerDebug = show3dPerfDebug(); + if (pointerDebug) pointerDebug.lastCanvasMouseDownPanel = panelForSelection; if (panelForSelection < 0) return; if (handlePanelSelectionMouseDown(e, panelForSelection)) return; clickStartRef.current = { x: e.clientX, y: e.clientY }; @@ -12663,6 +12771,8 @@ function Show3D() { // numbers from a phantom panel. const canvas = canvasRef.current; const hoverPanelIdx = panelIdxFromEvent(e); + const hoverDebug = show3dPerfDebug(); + if (hoverDebug) hoverDebug.lastHoverPanel = hoverPanelIdx; if (hoverPanelIdx < 0) { scheduleCursorInfo(null); if (showLens) setLensPos(null); @@ -14988,17 +15098,17 @@ function Show3D() { dense onClick={() => setColorShared( colorShared ? false : true, - nPanels > 1 ? Math.max(0, cursorInfo?.panelIdx ?? visiblePanelIndices[0] ?? 0) : 0, + colorTargetPanel, )} sx={{ fontSize: 12, gap: 1, color: !colorShared ? themeColors.accent : themeColors.text }} > - Color shared + Color shared e.stopPropagation()} onChange={(e) => setColorShared( e.target.checked, - nPanels > 1 ? Math.max(0, cursorInfo?.panelIdx ?? visiblePanelIndices[0] ?? 0) : 0, + colorTargetPanel, )} size="small" sx={switchStyles.small} @@ -15151,7 +15261,7 @@ function Show3D() { Zoom setLinkPanels(e.target.checked)} size="small" sx={switchStyles.small} slotProps={{ input: { "aria-label": "Link zoom and pan across panels" } }} /> Contrast - setLinkContrast(e.target.checked)} size="small" sx={switchStyles.small} slotProps={{ input: { "aria-label": "Link contrast across panels" } }} /> + setLinkContrast(e.target.checked)} size="small" sx={switchStyles.small} title="Link relative contrast adjustment; numerical ranges remain panel-local" slotProps={{ input: { "aria-label": "Link contrast across panels" } }} /> )} {fftAllowed && ( @@ -15276,6 +15386,7 @@ function Show3D() { setHiddenPageSlotsTrait([]); } setHiddenPanels([]); + setPanelMenuAnchor(null); }} > @@ -16220,14 +16331,14 @@ function Show3D() { Color @@ -16475,13 +16586,10 @@ function Show3D() { if (debug) debug.lastHistogramInputAt = performance.now(); panelHistogramPreviewPctRef.current.set(panel, [min, max]); const commitPanelRange = () => { + setPanelRangePercentages(panel, min, max, linkContrast); if (autoContrast) { - freezeCurrentPanelContrastAsManual(panel, { min, max }); manualImageRangeBeforeAutoRef.current = null; setAutoContrast(false); - } else { - updatePanelState(panel, { imageVminPct: min, imageVmaxPct: max }); - setPanelRangeValues(panel, pctToValue(min, panelRange.min, panelRange.max), pctToValue(max, panelRange.min, panelRange.max)); } const live = playRef.current; live.autoContrast = false; diff --git a/src/quantem/widget/show3d.py b/src/quantem/widget/show3d.py index 5c926cab..5b1f1c4f 100644 --- a/src/quantem/widget/show3d.py +++ b/src/quantem/widget/show3d.py @@ -753,10 +753,11 @@ class Show3D(WatchedImageFolderMixin, StaticFallbackMixin, anywidget.AnyWidget): auto_contrast : bool, default True Use percentile-based contrast (ignores vmin/vmax). link_contrast : bool, optional - Share one contrast range across panels. Paged data defaults to - ``False`` so each reconstruction uses its own automatic percentile - range; ordinary multi-panel data defaults to ``True``. Pass ``True`` - for a matched physical scale across every page and panel. + Link relative contrast-handle movement across panels. Multi-panel data + defaults to ``False`` because panels may represent different physical + quantities. Pass ``True`` only when every panel has a comparable + intensity scale; each panel still resolves the linked percentages in + its own data range. percentile_low : float, default 0.5 Lower percentile for auto-contrast. percentile_high : float, default 99.5 @@ -1221,6 +1222,8 @@ class Show3D(WatchedImageFolderMixin, StaticFallbackMixin, anywidget.AnyWidget): data_max = traitlets.Float(0.0).tag(sync=True) auto_vmins = traitlets.List(traitlets.Float()).tag(sync=True) auto_vmaxs = traitlets.List(traitlets.Float()).tag(sync=True) + auto_vmins_per_panel = traitlets.List(traitlets.Float()).tag(sync=True) + auto_vmaxs_per_panel = traitlets.List(traitlets.Float()).tag(sync=True) identity_colors = traitlets.List(traitlets.Unicode(), default_value=[]).tag(sync=True) # ========================================================================= @@ -2585,7 +2588,10 @@ def __init__( labels=panel_titles, ) if link_contrast is None: - link_contrast = n_pages <= 1 + # Independent contrast is the scientifically safe multi-panel + # default. Phase, BF, DF, residual, and reconstruction panels may + # differ by orders of magnitude even when they share a layout. + link_contrast = len(panel_titles or data_args or []) <= 1 kwargs["link_contrast"] = bool(link_contrast) _t0 = time.perf_counter() # Reject unknown kwargs so typos raise instead of being silently ignored. @@ -6903,64 +6909,101 @@ def _get_display_frame(self, idx: int | None = None) -> np.ndarray: return frame def _refresh_auto_contrast_ranges(self) -> None: - """Precompute one stack-level auto-contrast range for JS playback. + """Precompute stable stack and panel auto-contrast ranges. - Show3D is a scrubber, so Auto should give a stable intensity mapping - across frames and panels. The synced lists still have one entry per - slice for the existing JS cache contract, but every entry carries the - same stack percentile range. + A Show3D panel may represent a different physical quantity from its + neighbors, such as SSB phase beside bright- and dark-field counts. + Each independent panel therefore receives one percentile range across + its complete time series. This keeps playback stable without forcing + unlike units through one absolute intensity window. """ if self.n_slices <= 0: self.auto_vmins = [] self.auto_vmaxs = [] + self.auto_vmins_per_panel = [] + self.auto_vmaxs_per_panel = [] return if not self.auto_contrast: self.auto_vmins = [] self.auto_vmaxs = [] + self.auto_vmins_per_panel = [] + self.auto_vmaxs_per_panel = [] return - low_target = self.percentile_low / 100.0 - high_target = self.percentile_high / 100.0 bins = 1024 - denom = bins - 1 - mn = float("inf") - mx = float("-inf") - total_size = 0 - for i in range(self.n_slices): - frame = self._get_display_frame(i) - mn = min(mn, float(np.min(frame))) - mx = max(mx, float(np.max(frame))) - total_size += int(frame.size) - if total_size <= 0 or not math.isfinite(mn) or not math.isfinite(mx): + + def stack_percentiles( + frames: list[np.ndarray], + ) -> tuple[float, float] | None: + """Approximate configured percentiles without joining frames.""" + if not frames: + return None + mn = min(float(np.min(frame)) for frame in frames) + mx = max(float(np.max(frame)) for frame in frames) + total_size = sum(int(frame.size) for frame in frames) + if total_size <= 0 or not math.isfinite(mn) or not math.isfinite(mx): + return None + if mn == mx: + return mn, mx + hist = np.zeros(bins, dtype=np.int64) + for frame in frames: + frame_hist, _ = np.histogram(frame, bins=bins, range=(mn, mx)) + hist += frame_hist + csum = np.cumsum(hist) + lo = int( + np.searchsorted( + csum, + int(total_size * self.percentile_low / 100.0), + side="left", + ) + ) + hi = int( + np.searchsorted( + csum, + int(np.ceil(total_size * self.percentile_high / 100.0)), + side="left", + ) + ) + denom = bins - 1 + lo = max(0, min(denom, lo)) + hi = max(0, min(denom, hi)) + span = mx - mn + return mn + (lo / denom) * span, mn + (hi / denom) * span + + display_frames = [ + np.asarray(self._get_display_frame(i)) + for i in range(int(self.n_slices)) + ] + stack_range = stack_percentiles(display_frames) + if stack_range is None: self.auto_vmins = [] self.auto_vmaxs = [] + self.auto_vmins_per_panel = [] + self.auto_vmaxs_per_panel = [] return - if mn == mx: - self.auto_vmins = [mn] * int(self.n_slices) - self.auto_vmaxs = [mx] * int(self.n_slices) - return - - hist = np.zeros(bins, dtype=np.int64) - for i in range(self.n_slices): - frame = self._get_display_frame(i) - frame_hist, _ = np.histogram(frame, bins=bins, range=(mn, mx)) - hist += frame_hist - - csum = np.cumsum(hist) - low_count = int(total_size * low_target) - high_count = int(np.ceil(total_size * high_target)) - lo = int(np.searchsorted(csum, low_count, side="left")) - hi = int(np.searchsorted(csum, high_count, side="left")) - lo = max(0, min(denom, lo)) - hi = max(0, min(denom, hi)) - span = mx - mn - vmin = float(mn + (lo / denom) * span) - vmax = float(mn + (hi / denom) * span) + vmin, vmax = stack_range vmins = [vmin] * int(self.n_slices) vmaxs = [vmax] * int(self.n_slices) + panel_vmins: list[float] = [] + panel_vmaxs: list[float] = [] + independent_panels = int(self.n_panels) > 1 and not self.shared_panel_source + if independent_panels: + for panel in range(int(self.n_panels)): + panel_range = stack_percentiles( + [ + np.asarray(self._get_display_panel_frame(panel, i)) + for i in range(int(self.n_slices)) + ] + ) + if panel_range is not None: + panel_vmins.append(panel_range[0]) + panel_vmaxs.append(panel_range[1]) + with self.hold_sync(): self.auto_vmins = vmins self.auto_vmaxs = vmaxs + self.auto_vmins_per_panel = panel_vmins + self.auto_vmaxs_per_panel = panel_vmaxs # Traits that carry the bulk pixel payload. Dropped from the saved-notebook # snapshot when save_state is False so a plain display stays a few MB, not GB. diff --git a/tests/show3d/test_pages.py b/tests/show3d/test_pages.py index 5c9fade1..074aef70 100644 --- a/tests/show3d/test_pages.py +++ b/tests/show3d/test_pages.py @@ -68,7 +68,7 @@ def test_show3d_pages_default_to_independent_auto_contrast() -> None: assert paged.auto_contrast is True assert paged.link_contrast is False assert shared_pages.link_contrast is True - assert ordinary_panels.link_contrast is True + assert ordinary_panels.link_contrast is False def test_show3d_accepts_dict_pages_and_page_stars() -> None: diff --git a/tests/show3d/test_zoom_contrast.py b/tests/show3d/test_zoom_contrast.py index bb39ef12..8e587687 100644 --- a/tests/show3d/test_zoom_contrast.py +++ b/tests/show3d/test_zoom_contrast.py @@ -4,6 +4,10 @@ import pathlib +import numpy as np + +from quantem.widget import Show3D + def test_packed_panel_frame_refresh_preserves_independent_contrast_on_zoom() -> None: """The retained zoom frame must use the settled per-panel contrast path.""" @@ -24,7 +28,9 @@ def test_one_resident_renderer_keeps_unlinked_contrast_per_panel() -> None: ).read_text(encoding="utf-8") assert "const directPanelRanges =" in frontend - assert "return resolvePanelRange(panel, bounds, sharedAutoRange);" in frontend + assert "return resolvePanelRenderRange(" in frontend + assert "autoVminsPerPanel" in frontend + assert "autoVmaxsPerPanel" in frontend assert "const renderOfflinePackedPanels2D =" in frontend assert "const range = resolvePanelRenderRange(" in frontend assert "sidecarComposite" not in frontend @@ -87,3 +93,80 @@ def test_gpu_resident_display_controls_are_immediate_repaint_dependencies() -> N "vmaxPerPanel", ): assert dependency in effect + + +def test_playback_uses_live_per_panel_colormap_contract() -> None: + """Resident, CPU, and offline playback must keep panel color identity.""" + root = pathlib.Path(__file__).resolve().parents[2] + frontend = (root / "js" / "show3d" / "index.tsx").read_text(encoding="utf-8") + colormaps = (root / "js" / "colormaps.ts").read_text(encoding="utf-8") + + assert "const panelCmapsLiveRef = React.useRef" in frontend + assert "panelCmapsLiveRef.current = next" in frontend + assert "const panelLuts = livePanelCmaps.length" in frontend + assert "panelLuts," in frontend + assert "const panelLut = COLORMAPS[panelCmap] || lut" in frontend + assert "panelLuts?: { name: string; lut: Uint8Array }[]" in colormaps + assert "private namedLutBuffer" in colormaps + assert "slot.directRegionLutNames[panel] !== lutName" in colormaps + + +def test_playback_uses_live_panel_columns_with_live_canvas_geometry() -> None: + """Layout changes during playback must not retain the Play-start columns.""" + root = pathlib.Path(__file__).resolve().parents[2] + frontend = (root / "js" / "show3d" / "index.tsx").read_text(encoding="utf-8") + + assert "canvasW, canvasH, panelCols: _colsLocal" in frontend + assert "Math.round(c.panelCols || 1)" in frontend + assert "canvasW, canvasH, _colsLocal" in frontend + + +def test_auto_contrast_caches_stable_ranges_for_mixed_unit_panels() -> None: + """Phase and count panels must not share one absolute auto window.""" + rng = np.random.default_rng(21) + phase = rng.normal(0.0, 0.02, (4, 24, 24)).astype(np.float32) + bright_field = rng.normal(47_000, 500, (4, 24, 24)).astype(np.float32) + dark_field = rng.normal(1_200, 100, (4, 24, 24)).astype(np.float32) + + widget = Show3D( + phase, + bright_field, + dark_field, + link_contrast=True, + auto_contrast=True, + display_bin=1, + verbose=False, + ) + + assert len(set(widget.auto_vmins)) == 1 + assert len(set(widget.auto_vmaxs)) == 1 + np.testing.assert_allclose( + widget.auto_vmins_per_panel, + [np.percentile(phase, 0.5), np.percentile(bright_field, 0.5), np.percentile(dark_field, 0.5)], + rtol=0.05, + ) + np.testing.assert_allclose( + widget.auto_vmaxs_per_panel, + [np.percentile(phase, 99.5), np.percentile(bright_field, 99.5), np.percentile(dark_field, 99.5)], + rtol=0.05, + ) + assert widget.auto_vmaxs_per_panel[0] < 1 + assert widget.auto_vmins_per_panel[1] > 40_000 + + +def test_multi_panel_contrast_defaults_to_independent_domains() -> None: + """Unlike scientific panels must not share an absolute clip by default.""" + phase = np.zeros((2, 8, 8), dtype=np.float32) + counts = np.ones((2, 8, 8), dtype=np.float32) * 40_000 + + widget = Show3D(phase, counts, display_bin=1, verbose=False) + comparable = Show3D( + counts, + counts + 100, + link_contrast=True, + display_bin=1, + verbose=False, + ) + + assert widget.link_contrast is False + assert comparable.link_contrast is True diff --git a/tests/show3d/test_zoom_contrast_browser.py b/tests/show3d/test_zoom_contrast_browser.py index 7a56cc82..ece5cdcf 100644 --- a/tests/show3d/test_zoom_contrast_browser.py +++ b/tests/show3d/test_zoom_contrast_browser.py @@ -131,6 +131,46 @@ def _panel_spatial_std( return spreads +def _panel_color_spreads( + pixels: np.ndarray, + count: int, + cols: int, +) -> list[float]: + """Measure RGB channel separation inside each real grid panel.""" + rows = int(np.ceil(count / cols)) + height, width = pixels.shape[:2] + spreads: list[float] = [] + for panel in range(count): + row, col = divmod(panel, cols) + y0 = round((row + 0.08) * height / rows) + y1 = round((row + 0.92) * height / rows) + x0 = round((col + 0.08) * width / cols) + x1 = round((col + 0.92) * width / cols) + region = pixels[y0:y1, x0:x1] + spreads.append(float((region.max(axis=2) - region.min(axis=2)).mean())) + return spreads + + +def _panel_saturation_fractions( + pixels: np.ndarray, + count: int, + cols: int, +) -> list[tuple[float, float]]: + """Return near-black and near-white fractions for each scientific panel.""" + rows = int(np.ceil(count / cols)) + height, width = pixels.shape[:2] + fractions: list[tuple[float, float]] = [] + for panel in range(count): + row, col = divmod(panel, cols) + y0 = round((row + 0.12) * height / rows) + y1 = round((row + 0.88) * height / rows) + x0 = round((col + 0.08) * width / cols) + x1 = round((col + 0.92) * width / cols) + luminance = pixels[y0:y1, x0:x1].mean(axis=2) + fractions.append((float((luminance < 3).mean()), float((luminance > 252).mean()))) + return fractions + + @pytest.mark.skipif( os.environ.get("QT_RUN_BROWSER_TESTS") != "1", reason="set QT_RUN_BROWSER_TESTS=1 to run Show3D browser regression tests", @@ -437,6 +477,398 @@ def test_scrub_commit_preserves_mixed_panel_colormaps(tmp_path): browser.close() +@pytest.mark.skipif( + os.environ.get("QT_RUN_BROWSER_TESTS") != "1", + reason="set QT_RUN_BROWSER_TESTS=1 to run Show3D browser regression tests", +) +def test_column_choice_persists_and_preserves_square_panels(tmp_path): + """Changing 2↔3 columns keeps square panels and survives display edits.""" + chrome = _chrome_executable() + if chrome is None: + pytest.skip("Chrome/Chromium executable not found") + + bf, df, phase = _contrast_fixture() + widget = Show3D( + bf, + df, + phase, + panel_titles=["Phase", "BF", "DF"], + cmap=["plasma", "gray", "gray"], + max_cols=3, + debug=True, + verbose=False, + ) + html_path = tmp_path / "show3d-column-aspect.html" + widget.export_html(html_path, encoding="full") + + with sync_playwright() as pw: + browser = pw.chromium.launch( + executable_path=chrome, + headless=True, + args=["--enable-unsafe-swiftshader", "--enable-webgpu"], + ) + page = browser.new_page(viewport={"width": 1280, "height": 900}) + page.goto(html_path.as_uri()) + page.wait_for_function( + "() => window.__quantemShow3DPerf?.offlineFramePrewarmDone === 3" + ) + + columns = page.get_by_role("combobox", name="Show3D panel columns") + columns.click() + page.get_by_role("option", name="2", exact=True).click() + page.wait_for_function( + "() => window.__quantemShow3DPerf?.layoutRequestedMaxCols === 2 " + "&& window.__quantemShow3DPerf?.layoutCols === 2" + ) + two = page.evaluate("() => ({...window.__quantemShow3DPerf})") + assert two["layoutRows"] == 2 + assert abs(two["layoutCanvasW"] / 2 - two["layoutCanvasH"] / 2) <= 1 + + columns.click() + page.get_by_role("option", name="3", exact=True).click() + page.wait_for_function( + "() => window.__quantemShow3DPerf?.layoutRequestedMaxCols === 3 " + "&& window.__quantemShow3DPerf?.layoutCols === 3" + ) + three = page.evaluate("() => ({...window.__quantemShow3DPerf})") + assert three["layoutRows"] == 1 + assert abs(three["layoutCanvasW"] / 3 - three["layoutCanvasH"]) <= 1 + + page.get_by_role("combobox", name="Selected panel colormap").click() + page.get_by_role("option", name="Inferno", exact=True).click() + page.wait_for_timeout(100) + assert page.evaluate( + "() => window.__quantemShow3DPerf.layoutRequestedMaxCols" + ) == 3 + assert page.evaluate("() => window.__quantemShow3DPerf.layoutCols") == 3 + browser.close() + + +@pytest.mark.skipif( + os.environ.get("QT_RUN_BROWSER_TESTS") != "1", + reason="set QT_RUN_BROWSER_TESTS=1 to run Show3D browser regression tests", +) +def test_column_change_during_playback_uses_live_layout(tmp_path): + """A 2→1 column change must not stretch the old two-column GPU frame.""" + chrome = _chrome_executable() + if chrome is None: + pytest.skip("Chrome/Chromium executable not found") + + bf, df, phase = _contrast_fixture() + widget = Show3D( + phase, + bf, + df, + panel_titles=["Phase", "BF", "DF"], + cmap=["magma", "RdBu", "gray"], + hidden_panels=["Phase"], + link_contrast=False, + max_cols=2, + fps=12, + debug=True, + verbose=False, + ) + html_path = tmp_path / "show3d-live-column-playback.html" + widget.export_html(html_path, encoding="full") + + page_errors: list[str] = [] + with sync_playwright() as pw: + browser = pw.chromium.launch( + executable_path=chrome, + headless=True, + args=["--enable-unsafe-swiftshader", "--enable-webgpu"], + ) + page = browser.new_page(viewport={"width": 1400, "height": 1200}) + page.on("pageerror", lambda error: page_errors.append(str(error))) + page.goto(html_path.as_uri()) + page.wait_for_function( + "() => window.__quantemShow3DPerf?.offlineFramePrewarmDone === 3" + ) + page.wait_for_function( + "() => window.__quantemShow3DPerf?.layoutVisiblePanels === 2 " + "&& window.__quantemShow3DPerf?.layoutCols === 2" + ) + + page.get_by_role("button", name="Play", exact=True).click() + page.wait_for_timeout(250) + two_columns = _panel_color_spreads(_visible_canvas_pixels(page), 2, 2) + assert two_columns[0] > 20 + assert two_columns[1] < 4 + + columns = page.get_by_role("combobox", name="Show3D panel columns") + columns.click() + page.get_by_role("option", name="1", exact=True).click() + page.wait_for_function( + "() => window.__quantemShow3DPerf?.layoutCols === 1 " + "&& window.__quantemShow3DPerf?.layoutCanvasH > " + "window.__quantemShow3DPerf?.layoutCanvasW" + ) + page.wait_for_timeout(100) + one_column_pixels = _visible_canvas_pixels(page) + one_column = _panel_color_spreads(one_column_pixels, 2, 1) + assert one_column[0] > 20 + assert one_column[1] < 4 + assert min(_panel_spatial_std(one_column_pixels, 2, 1)) > 3 + assert page.get_by_role("button", name="Pause playback").count() == 1 + + columns.click() + page.get_by_role("option", name="2", exact=True).click() + page.wait_for_function( + "() => window.__quantemShow3DPerf?.layoutCols === 2" + ) + page.wait_for_timeout(100) + restored = _panel_color_spreads(_visible_canvas_pixels(page), 2, 2) + assert restored[0] > 20 + assert restored[1] < 4 + assert page.get_by_role("button", name="Pause playback").count() == 1 + + page.get_by_role("button", name="Pause playback").click() + assert page_errors == [] + browser.close() + + +@pytest.mark.skipif( + os.environ.get("QT_RUN_BROWSER_TESTS") != "1", + reason="set QT_RUN_BROWSER_TESTS=1 to run Show3D browser regression tests", +) +def test_playback_preserves_and_live_updates_mixed_panel_colormaps(tmp_path): + """Paused, playing, hidden, and restored panels keep their color identity.""" + chrome = _chrome_executable() + if chrome is None: + pytest.skip("Chrome/Chromium executable not found") + + bf, df, phase = _contrast_fixture() + widget = Show3D( + bf, + df, + phase, + panel_titles=["Phase", "BF", "DF"], + cmap=["plasma", "gray", "gray"], + link_contrast=False, + max_cols=3, + fps=12, + debug=True, + verbose=False, + ) + html_path = tmp_path / "show3d-mixed-colormap-playback.html" + widget.export_html(html_path, encoding="full") + + page_errors: list[str] = [] + with sync_playwright() as pw: + browser = pw.chromium.launch( + executable_path=chrome, + headless=True, + args=["--enable-unsafe-swiftshader", "--enable-webgpu"], + ) + page = browser.new_page(viewport={"width": 1400, "height": 900}) + page.on("pageerror", lambda error: page_errors.append(str(error))) + page.goto(html_path.as_uri()) + page.wait_for_function( + "() => window.__quantemShow3DPerf?.offlineFramePrewarmDone === 3" + ) + page.wait_for_timeout(200) + + layout_cols = page.evaluate("() => window.__quantemShow3DPerf.layoutCols") + assert isinstance(layout_cols, int) and layout_cols > 0 + paused = _panel_color_spreads(_visible_canvas_pixels(page), 3, layout_cols) + assert paused[0] > 20 + assert max(paused[1:]) < 4 + + page.get_by_role("button", name="Play", exact=True).click() + page.wait_for_timeout(300) + playing = _panel_color_spreads(_visible_canvas_pixels(page), 3, layout_cols) + assert page.get_by_role("button", name="Pause playback").count() == 1 + assert playing[0] > 20 + assert max(playing[1:]) < 4 + + # Hiding and restoring a gray panel while playing must not shift the + # source-indexed color assignments onto a neighboring panel. + canvas = page.locator('canvas[role="img"]').first + box = canvas.bounding_box() + assert box is not None + page.mouse.move(2, 2) + page.mouse.move( + box["x"] + box["width"] * 0.5, + box["y"] + box["height"] * 0.5, + ) + page.get_by_role("button", name="Hide BF").click() + page.wait_for_function( + "() => window.__quantemShow3DPerf?.layoutVisiblePanels === 2" + ) + page.get_by_role("button", name="Choose visible panels").click() + page.get_by_text("Show all panels", exact=True).click() + page.wait_for_function( + "() => window.__quantemShow3DPerf?.layoutVisiblePanels === 3" + ) + restored = _panel_color_spreads(_visible_canvas_pixels(page), 3, layout_cols) + assert restored[0] > 20 + assert max(restored[1:]) < 4 + + # Color shared is a live display control. After the menu transition no + # panel is hovered, so it adopts the selected Phase palette for every + # panel without pausing. + page.locator('button[aria-label="More tools"]').click() + page.get_by_role("checkbox", name="Toggle shared panel colormap").check() + page.keyboard.press("Escape") + page.wait_for_timeout(150) + shared = _panel_color_spreads(_visible_canvas_pixels(page), 3, layout_cols) + assert min(shared) > 20 + assert page.get_by_role("button", name="Pause playback").count() == 1 + + # Turn sharing off, select BF, and make only that panel gray while + # the same playback loop remains active. + page.locator('button[aria-label="More tools"]').click() + page.get_by_role("checkbox", name="Toggle shared panel colormap").uncheck() + page.keyboard.press("Escape") + canvas.scroll_into_view_if_needed() + box = canvas.bounding_box() + assert box is not None + current_cols = page.evaluate("() => window.__quantemShow3DPerf.layoutCols") + bf_col = 1 % current_cols + bf_row = 1 // current_cols + canvas.click( + position={ + "x": box["width"] * ((bf_col + 0.5) / current_cols), + "y": box["height"] * ( + (bf_row + 0.5) / int(np.ceil(3 / current_cols)) + ), + }, + ) + page.wait_for_function( + "() => window.__quantemShow3DPerf.lastCanvasMouseDownPanel === 1" + ) + page.wait_for_function( + "() => window.__quantemShow3DPerf.lastSelectedPanel === 1" + ) + page.get_by_role( + "combobox", name="Selected panel colormap" + ).click() + page.get_by_role("option", name="Gray", exact=True).click() + page.wait_for_timeout(150) + assert page.evaluate( + "() => window.__quantemShow3DPerf.lastColorPanel" + ) == 1 + assert page.evaluate( + "() => window.__quantemShow3DPerf.lastPanelCmaps" + ) == ["plasma", "gray", "plasma"] + independent = _panel_color_spreads(_visible_canvas_pixels(page), 3, layout_cols) + assert independent[0] > 20 + assert independent[1] < 4 + assert independent[2] > 20 + + page.get_by_role("button", name="Pause playback").click() + paused_again = _panel_color_spreads(_visible_canvas_pixels(page), 3, layout_cols) + assert paused_again[0] > 20 + assert paused_again[1] < 4 + assert paused_again[2] > 20 + assert page_errors == [] + browser.close() + + +@pytest.mark.skipif( + os.environ.get("QT_RUN_BROWSER_TESTS") != "1", + reason="set QT_RUN_BROWSER_TESTS=1 to run Show3D browser regression tests", +) +def test_linked_auto_contrast_resolves_mixed_physical_panel_domains(tmp_path): + """Linked Auto shares policy, never one absolute BF/DF/phase range.""" + chrome = _chrome_executable() + if chrome is None: + pytest.skip("Chrome/Chromium executable not found") + + bf, df, phase = _contrast_fixture() + widget = Show3D( + phase, + bf, + df, + panel_titles=["Phase", "BF", "DF"], + cmap=["magma", "gray", "gray"], + hidden_panels=["BF", "DF"], + auto_contrast=True, + link_contrast=True, + display_bin=1, + max_cols=3, + fps=12, + debug=True, + verbose=False, + ) + assert widget.auto_vmaxs_per_panel[0] < 1 + assert widget.auto_vmins_per_panel[1] > 10_000 + assert widget.auto_vmins_per_panel[2] > 100 + html_path = tmp_path / "show3d-linked-mixed-units.html" + widget.export_html(html_path, encoding="full") + + page_errors: list[str] = [] + with sync_playwright() as pw: + browser = pw.chromium.launch( + executable_path=chrome, + headless=True, + args=["--enable-unsafe-swiftshader", "--enable-webgpu"], + ) + page = browser.new_page(viewport={"width": 1400, "height": 900}) + page.on("pageerror", lambda error: page_errors.append(str(error))) + page.goto(html_path.as_uri()) + page.wait_for_function( + "() => window.__quantemShow3DPerf?.offlineFramePrewarmDone === 3" + ) + page.wait_for_timeout(250) + + # The reported regression first appeared before restoring BF/DF: with + # one visible panel, fast frame scrubbing must still use the Phase + # panel's range rather than the packed phase+BF+DF global range. + slider = page.locator( + 'input[aria-label^="Loop range and current frame"][data-index="1"]' + ) + slider.press("End") + page.wait_for_timeout(100) + hidden_scrub = _visible_canvas_pixels(page) + assert min(_panel_spatial_std(hidden_scrub, 1, 1)) > 3 + assert _panel_color_spreads(hidden_scrub, 1, 1)[0] > 3 + page.get_by_role("button", name="Play", exact=True).click() + page.wait_for_timeout(250) + hidden_playing = _visible_canvas_pixels(page) + assert min(_panel_spatial_std(hidden_playing, 1, 1)) > 3 + assert _panel_color_spreads(hidden_playing, 1, 1)[0] > 3 + page.get_by_role("button", name="Pause playback").click() + + # Reproduce the quantem.gpu SSB workflow exactly: BF and DF begin + # hidden, the scientist restores them, and only then starts playback. + page.get_by_role("button", name="Choose visible panels").click() + page.get_by_text("Show all panels", exact=True).click() + page.wait_for_function( + "() => window.__quantemShow3DPerf?.layoutVisiblePanels === 3" + ) + page.wait_for_timeout(150) + layout_cols = page.evaluate("() => window.__quantemShow3DPerf.layoutCols") + + paused = _visible_canvas_pixels(page) + paused_texture = _panel_spatial_std(paused, 3, layout_cols) + paused_color = _panel_color_spreads(paused, 3, layout_cols) + assert min(paused_texture) > 3 + assert paused_color[0] > 3 + assert max(paused_color[1:]) < 4 + + page.get_by_role("button", name="Play", exact=True).click() + page.wait_for_timeout(350) + playing = _visible_canvas_pixels(page) + playing_texture = _panel_spatial_std(playing, 3, layout_cols) + playing_color = _panel_color_spreads(playing, 3, layout_cols) + assert min(playing_texture) > 3 + assert playing_color[0] > 3 + assert max(playing_color[1:]) < 4 + assert all( + black < 0.9 and white < 0.9 + for black, white in _panel_saturation_fractions(playing, 3, layout_cols) + ) + assert page.get_by_role("button", name="Pause playback").count() == 1 + + page.get_by_role("button", name="Pause playback").click() + page.wait_for_timeout(100) + stopped = _visible_canvas_pixels(page) + assert min(_panel_spatial_std(stopped, 3, layout_cols)) > 3 + assert page_errors == [] + browser.close() + + @pytest.mark.skipif( os.environ.get("QT_RUN_BROWSER_TESTS") != "1", reason="set QT_RUN_BROWSER_TESTS=1 to run Show3D browser regression tests", @@ -672,6 +1104,31 @@ def test_display_controls_repaint_pixels_immediately_during_playback(tmp_path): ) > 1 page.mouse.up() + # Averaging must keep all contrast ranges panel-local while the linked + # relative gesture and playback remain active. A previous regression + # sent the BF stack-count window to Phase and DF, making them ~99% black. + average = page.get_by_role("slider", name="Moving average window") + average.focus() + for _ in range(4): + average.press("ArrowRight") + page.wait_for_timeout(150) + averaged_playing = _visible_canvas_pixels(page) + averaged_ranges = page.evaluate( + "() => window.__quantemShow3DPerf.lastDirectPanelRanges" + ) + assert page.get_by_role("button", name="Pause playback").count() == 1 + assert len(averaged_ranges) == 3 + assert averaged_ranges[0]["vmax"] < 1 + assert averaged_ranges[1]["vmin"] > 10_000 + assert averaged_ranges[2]["vmin"] > 100 + assert min(_panel_spatial_std(averaged_playing, 3, layout_cols)) > 3 + assert all( + black < 0.9 and white < 0.9 + for black, white in _panel_saturation_fractions( + averaged_playing, 3, layout_cols + ) + ) + # Independent histogram curves must follow resident playback frames, # and dragging one panel's clip range must repaint immediately without # stopping the movie or waiting for pointer release. diff --git a/tests/test_widget_performance_contract.py b/tests/test_widget_performance_contract.py index ee13e7e9..7e517522 100644 --- a/tests/test_widget_performance_contract.py +++ b/tests/test_widget_performance_contract.py @@ -135,7 +135,7 @@ def test_show3d_histogram_drag_repaints_single_file_exports(): stress = (ROOT / "scripts" / "widget_show3d_stress.py").read_text(encoding="utf-8") assert show3d.count("commitOnChange") >= 2 - assert "freezeCurrentPanelContrastAsManual(panel, { min, max })" in show3d + assert "setPanelRangePercentages(panel, min, max, linkContrast)" in show3d assert "keep the visible Auto windows as the editable manual baseline" in show3d assert "commitPanelRange();" in show3d assert "commitSharedRange();" in show3d From 95e5369d7c1ed33afe8acc94540034e80b53a888 Mon Sep 17 00:00:00 2001 From: Sangjoon Bob Lee Date: Wed, 12 Aug 2026 21:38:09 -0700 Subject: [PATCH 2/2] test: simplify Show3D playback regressions --- tests/show3d/test_zoom_contrast_browser.py | 221 +++------------------ 1 file changed, 25 insertions(+), 196 deletions(-) diff --git a/tests/show3d/test_zoom_contrast_browser.py b/tests/show3d/test_zoom_contrast_browser.py index ece5cdcf..addfd94b 100644 --- a/tests/show3d/test_zoom_contrast_browser.py +++ b/tests/show3d/test_zoom_contrast_browser.py @@ -117,18 +117,22 @@ def _panel_spatial_std( cols: int, ) -> list[float]: """Measure scientific texture while excluding titles and panel edges.""" + return [float(np.std(region.mean(axis=2))) for region in _panel_regions(pixels, count, cols)] + + +def _panel_regions(pixels: np.ndarray, count: int, cols: int) -> list[np.ndarray]: + """Return panel interiors without titles and grid edges.""" rows = int(np.ceil(count / cols)) height, width = pixels.shape[:2] - spreads: list[float] = [] + regions = [] for panel in range(count): row, col = divmod(panel, cols) y0 = round((row + 0.12) * height / rows) y1 = round((row + 0.88) * height / rows) x0 = round((col + 0.08) * width / cols) x1 = round((col + 0.92) * width / cols) - region = pixels[y0:y1, x0:x1] - spreads.append(float(np.std(region.mean(axis=2)))) - return spreads + regions.append(pixels[y0:y1, x0:x1]) + return regions def _panel_color_spreads( @@ -137,18 +141,10 @@ def _panel_color_spreads( cols: int, ) -> list[float]: """Measure RGB channel separation inside each real grid panel.""" - rows = int(np.ceil(count / cols)) - height, width = pixels.shape[:2] - spreads: list[float] = [] - for panel in range(count): - row, col = divmod(panel, cols) - y0 = round((row + 0.08) * height / rows) - y1 = round((row + 0.92) * height / rows) - x0 = round((col + 0.08) * width / cols) - x1 = round((col + 0.92) * width / cols) - region = pixels[y0:y1, x0:x1] - spreads.append(float((region.max(axis=2) - region.min(axis=2)).mean())) - return spreads + return [ + float((region.max(axis=2) - region.min(axis=2)).mean()) + for region in _panel_regions(pixels, count, cols) + ] def _panel_saturation_fractions( @@ -157,16 +153,9 @@ def _panel_saturation_fractions( cols: int, ) -> list[tuple[float, float]]: """Return near-black and near-white fractions for each scientific panel.""" - rows = int(np.ceil(count / cols)) - height, width = pixels.shape[:2] fractions: list[tuple[float, float]] = [] - for panel in range(count): - row, col = divmod(panel, cols) - y0 = round((row + 0.12) * height / rows) - y1 = round((row + 0.88) * height / rows) - x0 = round((col + 0.08) * width / cols) - x1 = round((col + 0.92) * width / cols) - luminance = pixels[y0:y1, x0:x1].mean(axis=2) + for region in _panel_regions(pixels, count, cols): + luminance = region.mean(axis=2) fractions.append((float((luminance < 3).mean()), float((luminance > 252).mean()))) return fractions @@ -477,73 +466,6 @@ def test_scrub_commit_preserves_mixed_panel_colormaps(tmp_path): browser.close() -@pytest.mark.skipif( - os.environ.get("QT_RUN_BROWSER_TESTS") != "1", - reason="set QT_RUN_BROWSER_TESTS=1 to run Show3D browser regression tests", -) -def test_column_choice_persists_and_preserves_square_panels(tmp_path): - """Changing 2↔3 columns keeps square panels and survives display edits.""" - chrome = _chrome_executable() - if chrome is None: - pytest.skip("Chrome/Chromium executable not found") - - bf, df, phase = _contrast_fixture() - widget = Show3D( - bf, - df, - phase, - panel_titles=["Phase", "BF", "DF"], - cmap=["plasma", "gray", "gray"], - max_cols=3, - debug=True, - verbose=False, - ) - html_path = tmp_path / "show3d-column-aspect.html" - widget.export_html(html_path, encoding="full") - - with sync_playwright() as pw: - browser = pw.chromium.launch( - executable_path=chrome, - headless=True, - args=["--enable-unsafe-swiftshader", "--enable-webgpu"], - ) - page = browser.new_page(viewport={"width": 1280, "height": 900}) - page.goto(html_path.as_uri()) - page.wait_for_function( - "() => window.__quantemShow3DPerf?.offlineFramePrewarmDone === 3" - ) - - columns = page.get_by_role("combobox", name="Show3D panel columns") - columns.click() - page.get_by_role("option", name="2", exact=True).click() - page.wait_for_function( - "() => window.__quantemShow3DPerf?.layoutRequestedMaxCols === 2 " - "&& window.__quantemShow3DPerf?.layoutCols === 2" - ) - two = page.evaluate("() => ({...window.__quantemShow3DPerf})") - assert two["layoutRows"] == 2 - assert abs(two["layoutCanvasW"] / 2 - two["layoutCanvasH"] / 2) <= 1 - - columns.click() - page.get_by_role("option", name="3", exact=True).click() - page.wait_for_function( - "() => window.__quantemShow3DPerf?.layoutRequestedMaxCols === 3 " - "&& window.__quantemShow3DPerf?.layoutCols === 3" - ) - three = page.evaluate("() => ({...window.__quantemShow3DPerf})") - assert three["layoutRows"] == 1 - assert abs(three["layoutCanvasW"] / 3 - three["layoutCanvasH"]) <= 1 - - page.get_by_role("combobox", name="Selected panel colormap").click() - page.get_by_role("option", name="Inferno", exact=True).click() - page.wait_for_timeout(100) - assert page.evaluate( - "() => window.__quantemShow3DPerf.layoutRequestedMaxCols" - ) == 3 - assert page.evaluate("() => window.__quantemShow3DPerf.layoutCols") == 3 - browser.close() - - @pytest.mark.skipif( os.environ.get("QT_RUN_BROWSER_TESTS") != "1", reason="set QT_RUN_BROWSER_TESTS=1 to run Show3D browser regression tests", @@ -650,6 +572,9 @@ def test_playback_preserves_and_live_updates_mixed_panel_colormaps(tmp_path): debug=True, verbose=False, ) + assert widget.auto_vmaxs_per_panel[0] > 10_000 + assert widget.auto_vmins_per_panel[1] > 100 + assert widget.auto_vmaxs_per_panel[2] < 1 html_path = tmp_path / "show3d-mixed-colormap-playback.html" widget.export_html(html_path, encoding="full") @@ -677,9 +602,17 @@ def test_playback_preserves_and_live_updates_mixed_panel_colormaps(tmp_path): page.get_by_role("button", name="Play", exact=True).click() page.wait_for_timeout(300) playing = _panel_color_spreads(_visible_canvas_pixels(page), 3, layout_cols) + playing_pixels = _visible_canvas_pixels(page) assert page.get_by_role("button", name="Pause playback").count() == 1 assert playing[0] > 20 assert max(playing[1:]) < 4 + assert min(_panel_spatial_std(playing_pixels, 3, layout_cols)) > 3 + assert all( + black < 0.9 and white < 0.9 + for black, white in _panel_saturation_fractions( + playing_pixels, 3, layout_cols + ) + ) # Hiding and restoring a gray panel while playing must not shift the # source-indexed color assignments onto a neighboring panel. @@ -765,110 +698,6 @@ def test_playback_preserves_and_live_updates_mixed_panel_colormaps(tmp_path): browser.close() -@pytest.mark.skipif( - os.environ.get("QT_RUN_BROWSER_TESTS") != "1", - reason="set QT_RUN_BROWSER_TESTS=1 to run Show3D browser regression tests", -) -def test_linked_auto_contrast_resolves_mixed_physical_panel_domains(tmp_path): - """Linked Auto shares policy, never one absolute BF/DF/phase range.""" - chrome = _chrome_executable() - if chrome is None: - pytest.skip("Chrome/Chromium executable not found") - - bf, df, phase = _contrast_fixture() - widget = Show3D( - phase, - bf, - df, - panel_titles=["Phase", "BF", "DF"], - cmap=["magma", "gray", "gray"], - hidden_panels=["BF", "DF"], - auto_contrast=True, - link_contrast=True, - display_bin=1, - max_cols=3, - fps=12, - debug=True, - verbose=False, - ) - assert widget.auto_vmaxs_per_panel[0] < 1 - assert widget.auto_vmins_per_panel[1] > 10_000 - assert widget.auto_vmins_per_panel[2] > 100 - html_path = tmp_path / "show3d-linked-mixed-units.html" - widget.export_html(html_path, encoding="full") - - page_errors: list[str] = [] - with sync_playwright() as pw: - browser = pw.chromium.launch( - executable_path=chrome, - headless=True, - args=["--enable-unsafe-swiftshader", "--enable-webgpu"], - ) - page = browser.new_page(viewport={"width": 1400, "height": 900}) - page.on("pageerror", lambda error: page_errors.append(str(error))) - page.goto(html_path.as_uri()) - page.wait_for_function( - "() => window.__quantemShow3DPerf?.offlineFramePrewarmDone === 3" - ) - page.wait_for_timeout(250) - - # The reported regression first appeared before restoring BF/DF: with - # one visible panel, fast frame scrubbing must still use the Phase - # panel's range rather than the packed phase+BF+DF global range. - slider = page.locator( - 'input[aria-label^="Loop range and current frame"][data-index="1"]' - ) - slider.press("End") - page.wait_for_timeout(100) - hidden_scrub = _visible_canvas_pixels(page) - assert min(_panel_spatial_std(hidden_scrub, 1, 1)) > 3 - assert _panel_color_spreads(hidden_scrub, 1, 1)[0] > 3 - page.get_by_role("button", name="Play", exact=True).click() - page.wait_for_timeout(250) - hidden_playing = _visible_canvas_pixels(page) - assert min(_panel_spatial_std(hidden_playing, 1, 1)) > 3 - assert _panel_color_spreads(hidden_playing, 1, 1)[0] > 3 - page.get_by_role("button", name="Pause playback").click() - - # Reproduce the quantem.gpu SSB workflow exactly: BF and DF begin - # hidden, the scientist restores them, and only then starts playback. - page.get_by_role("button", name="Choose visible panels").click() - page.get_by_text("Show all panels", exact=True).click() - page.wait_for_function( - "() => window.__quantemShow3DPerf?.layoutVisiblePanels === 3" - ) - page.wait_for_timeout(150) - layout_cols = page.evaluate("() => window.__quantemShow3DPerf.layoutCols") - - paused = _visible_canvas_pixels(page) - paused_texture = _panel_spatial_std(paused, 3, layout_cols) - paused_color = _panel_color_spreads(paused, 3, layout_cols) - assert min(paused_texture) > 3 - assert paused_color[0] > 3 - assert max(paused_color[1:]) < 4 - - page.get_by_role("button", name="Play", exact=True).click() - page.wait_for_timeout(350) - playing = _visible_canvas_pixels(page) - playing_texture = _panel_spatial_std(playing, 3, layout_cols) - playing_color = _panel_color_spreads(playing, 3, layout_cols) - assert min(playing_texture) > 3 - assert playing_color[0] > 3 - assert max(playing_color[1:]) < 4 - assert all( - black < 0.9 and white < 0.9 - for black, white in _panel_saturation_fractions(playing, 3, layout_cols) - ) - assert page.get_by_role("button", name="Pause playback").count() == 1 - - page.get_by_role("button", name="Pause playback").click() - page.wait_for_timeout(100) - stopped = _visible_canvas_pixels(page) - assert min(_panel_spatial_std(stopped, 3, layout_cols)) > 3 - assert page_errors == [] - browser.close() - - @pytest.mark.skipif( os.environ.get("QT_RUN_BROWSER_TESTS") != "1", reason="set QT_RUN_BROWSER_TESTS=1 to run Show3D browser regression tests",