From 32aa09dd7a084f4c90ac4942dd2c86374001a563 Mon Sep 17 00:00:00 2001 From: Sangjoon Bob Lee Date: Mon, 10 Aug 2026 21:10:37 -0700 Subject: [PATCH 1/3] fix: keep Show3D histograms live during playback --- js/colormaps.ts | 181 +++++++++++++++++++++ js/show3d/index.tsx | 76 ++++++--- tests/show3d/test_zoom_contrast_browser.py | 64 ++++++++ 3 files changed, 302 insertions(+), 19 deletions(-) diff --git a/js/colormaps.ts b/js/colormaps.ts index d2560e13..bde6cd5a 100644 --- a/js/colormaps.ts +++ b/js/colormaps.ts @@ -4168,6 +4168,7 @@ fn main(@builtin(global_invocation_id) gid: vec3u) { private histPipeline: GPUComputePipeline | null = null; private histClearPipeline: GPUComputePipeline | null = null; + private histRegionPipeline: GPUComputePipeline | null = null; private ensureHistPipeline(): void { if (this.histPipeline) return; @@ -4212,6 +4213,186 @@ fn clear_bins(@builtin(global_invocation_id) gid: vec3u) { }); } + private ensureHistRegionPipeline(): void { + if (this.histRegionPipeline) return; + const code = /* wgsl */ ` +struct RegionParams { + region: vec4u, + full_width: u32, + log_scale: u32, + _pad0: u32, + _pad1: u32, +}; +struct RangeIn { vmin: f32, vmax: f32, _p0: f32, _p1: f32 }; + +@group(0) @binding(0) var params: RegionParams; +@group(0) @binding(1) var data: array; +@group(0) @binding(2) var range_in: RangeIn; +@group(0) @binding(3) var bins: array>; + +@compute @workgroup_size(16, 16) +fn histogram(@builtin(global_invocation_id) gid: vec3u) { + if (gid.x >= params.region.z || gid.y >= params.region.w) { return; } + let idx = (params.region.y + gid.y) * params.full_width + params.region.x + gid.x; + var val = data[idx]; + if (params.log_scale == 1u) { val = log(1.0 + max(val, 0.0)); } + let span = max(range_in.vmax - range_in.vmin, 1e-30); + let t = clamp((val - range_in.vmin) / span, 0.0, 1.0); + let bin = min(u32(t * 256.0), 255u); + atomicAdd(&bins[bin], 1u); +} +`; + const module = this.device.createShaderModule({ code }); + this.histRegionPipeline = this.device.createComputePipeline({ + layout: "auto", + compute: { module, entryPoint: "histogram" }, + }); + } + + /** + * Compute ranges and 256-bin histograms for rectangular regions of one slot. + * + * Show3D packs independent panels side-by-side in one resident frame slot. + * This keeps playback histogram refreshes on WebGPU: range reduction and bin + * accumulation happen in one submission, with only the small ranges and bins + * read back for drawing the histogram controls. + */ + async computeHistogramRegions( + idx: number, + regions: { x: number; y: number; width: number; height: number }[], + logScale: boolean = false, + ): Promise<{ range: { min: number; max: number }; bins: number[] }[]> { + this.ensureRangeRegionPipeline(); + this.ensureHistRegionPipeline(); + const slot = this.slots[idx]; + if (!slot || !this.rangeRegionPipeline || !this.histRegionPipeline || regions.length === 0) { + return []; + } + + const validRegions = regions.map(region => { + const x = Math.max(0, Math.min(slot.width - 1, Math.round(region.x))); + const y = Math.max(0, Math.min(slot.height - 1, Math.round(region.y))); + return { + x, + y, + width: Math.max(1, Math.min(slot.width - x, Math.round(region.width))), + height: Math.max(1, Math.min(slot.height - y, Math.round(region.height))), + }; + }); + const binsBytes = validRegions.length * 256 * 4; + const rangesBytes = validRegions.length * 16; + const binsBuffer = this.device.createBuffer({ + size: binsBytes, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, + }); + const binsReadBuffer = this.device.createBuffer({ + size: binsBytes, + usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, + }); + const rangesReadBuffer = this.device.createBuffer({ + size: rangesBytes, + usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST, + }); + const paramsBuffers: GPUBuffer[] = []; + const encoder = this.device.createCommandEncoder(); + encoder.clearBuffer(binsBuffer); + + for (let k = 0; k < validRegions.length; k++) { + const region = validRegions[k]; + const scratch = this.ensurePanelScratch(k, region.width * region.height); + const paramsBuffer = this.device.createBuffer({ + size: 32, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + this.device.queue.writeBuffer( + paramsBuffer, + 0, + new Uint32Array([ + region.x, + region.y, + region.width, + region.height, + slot.width, + logScale ? 1 : 0, + 0, + 0, + ]), + ); + paramsBuffers.push(paramsBuffer); + + const rangeGroup = this.device.createBindGroup({ + layout: this.rangeRegionPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: slot.dataBuffer } }, + { binding: 1, resource: { buffer: paramsBuffer } }, + { binding: 2, resource: { buffer: scratch.range } }, + ], + }); + const rangePass = encoder.beginComputePass(); + rangePass.setPipeline(this.rangeRegionPipeline); + rangePass.setBindGroup(0, rangeGroup); + rangePass.dispatchWorkgroups(1); + rangePass.end(); + + const histogramGroup = this.device.createBindGroup({ + layout: this.histRegionPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: paramsBuffer } }, + { binding: 1, resource: { buffer: slot.dataBuffer } }, + { binding: 2, resource: { buffer: scratch.range } }, + { + binding: 3, + resource: { buffer: binsBuffer, offset: k * 256 * 4, size: 256 * 4 }, + }, + ], + }); + const histogramPass = encoder.beginComputePass(); + histogramPass.setPipeline(this.histRegionPipeline); + histogramPass.setBindGroup(0, histogramGroup); + histogramPass.dispatchWorkgroups( + Math.ceil(region.width / 16), + Math.ceil(region.height / 16), + ); + histogramPass.end(); + encoder.copyBufferToBuffer(scratch.range, 0, rangesReadBuffer, k * 16, 16); + } + + encoder.copyBufferToBuffer(binsBuffer, 0, binsReadBuffer, 0, binsBytes); + this.device.queue.submit([encoder.finish()]); + + try { + await Promise.all([ + binsReadBuffer.mapAsync(GPUMapMode.READ), + rangesReadBuffer.mapAsync(GPUMapMode.READ), + ]); + const rawBins = new Uint32Array(binsReadBuffer.getMappedRange().slice(0)); + const rawRanges = new Float32Array(rangesReadBuffer.getMappedRange().slice(0)); + binsReadBuffer.unmap(); + rangesReadBuffer.unmap(); + + return validRegions.map((_, k) => { + const offset = k * 256; + let maxCount = 0; + for (let bin = 0; bin < 256; bin++) { + maxCount = Math.max(maxCount, rawBins[offset + bin]); + } + const bins = new Array(256); + for (let bin = 0; bin < 256; bin++) { + bins[bin] = maxCount > 0 ? rawBins[offset + bin] / maxCount : 0; + } + return { + range: { min: rawRanges[k * 4], max: rawRanges[k * 4 + 1] }, + bins, + }; + }); + } finally { + for (const buffer of paramsBuffers) buffer.destroy(); + binsBuffer.destroy(); + binsReadBuffer.destroy(); + rangesReadBuffer.destroy(); + } + } + /** * Batch-compute 256-bin histograms for multiple slots in ONE GPU submission. * Uses persistent per-slot histogram buffers (zero create/destroy overhead). diff --git a/js/show3d/index.tsx b/js/show3d/index.tsx index 641a5849..86223187 100644 --- a/js/show3d/index.tsx +++ b/js/show3d/index.tsx @@ -8566,24 +8566,10 @@ function Show3D() { // here at the same 10 Hz cadence. Skip every 2nd tick → ~5 Hz refresh. playbackHistogramCounterRef.current = (playbackHistogramCounterRef.current + 1) % 2; if (playbackHistogramCounterRef.current === 0) { - if ((nPanels || 1) > 1 && !linkContrast && frame) { - // Keep playback free of per-panel Float32Array copies. The static - // histogram effect refreshes panel ranges after playback stops or - // when the user commits a scrub. On large 8-panel exports this is - // the difference between microscope-like playback and a browser - // ArrayBuffer allocation crash. - const d = show3dPerfDebug(); - if (d) { - d.lastHistogramFrame = next; - d.lastHistogramSource = "deferred-per-panel-playback"; - } - } else { - // GPU histogram for the current frame (honors WebGPU-first-class): - // refreshHistogram computes bins on the GPU (live slot or offline - // scratch slot) AND sets lastHistogramFrame so it is verifiable. - // Replaces the old CPU setImageHistogramData(frame). - void refreshHistogramRef.current?.(next); - } + // Refresh the visible histogram from the current resident frame. + // Independent packed panels use one GPU submission for all visible + // regions, so playback never allocates panel-sized Float32Array slabs. + void refreshHistogramRef.current?.(next); } } if (!isRgb && transformActive) { @@ -8731,6 +8717,58 @@ function Show3D() { if (!raw || raw.length === 0) return; if (perPanelHistogramEnabled) { const n = Math.max(1, nPanels || 1); + const engine = gpuCmapRef.current; + if ( + engine && + gpuCmapReadyRef.current && + gpuFrameCacheUploadedRef.current.has(renderIdx) && + !frameTransformActive() + ) { + const panelW = totalPanelCount > 1 + ? Math.max(1, panelWidthPx || Math.round(width / totalPanelCount)) + : Math.max(1, width); + const regions = visiblePanelIndices.map(panel => ({ + x: sharedPanelSource ? 0 : panel * panelW, + y: 0, + width: panelW, + height, + })); + try { + const histograms = await engine.computeHistogramRegions( + renderIdx, + regions, + logScale, + ); + if ( + serial === histogramRefreshSerialRef.current && + histograms.length === visiblePanelIndices.length + ) { + const nextBins: (number[] | null)[] = Array.from({ length: n }, () => null); + const nextRanges: { min: number; max: number }[] = Array.from( + { length: n }, + (_, panel) => panelDataRanges[panel] + ?? resolveDisplayBounds(dataMin, dataMax, null, null, logScale), + ); + visiblePanelIndices.forEach((panel, k) => { + nextBins[panel] = histograms[k].bins; + nextRanges[panel] = histograms[k].range; + }); + const dbg = show3dPerfDebug(); + if (dbg) { + dbg.lastHistogramFrame = renderIdx; + dbg.lastHistogramSource = "gpu-panel-regions"; + } + setPanelHistogramData(Array.from({ length: n }, () => null)); + setPanelHistogramBins(nextBins); + setPanelDataRanges(nextRanges); + setImageHistogramBins(null); + return; + } + } catch { + // Preserve the existing CPU fallback for browsers without a usable + // region compute path. Hardware WebGPU stays on the resident path. + } + } const nextData: (Float32Array | null)[] = Array.from({ length: n }, () => null); const nextRanges: { min: number; max: number }[] = Array.from( { length: n }, @@ -8779,7 +8817,7 @@ function Show3D() { window.setTimeout(() => { void refreshHistogram(pending); }, 0); } } - }, [logScale, dataMin, dataMax, perPanelHistogramEnabled, nPanels, nSlices, visiblePanelIndices, extractPanelSlice, displaySliceIdx, isRgb]); + }, [logScale, dataMin, dataMax, perPanelHistogramEnabled, nPanels, nSlices, visiblePanelIndices, extractPanelSlice, displaySliceIdx, isRgb, height, panelDataRanges, panelWidthPx, sharedPanelSource, totalPanelCount, width, diffMode, avgWindow, frequencyFilterIsActive, offline, subpixelAlignEnabled]); refreshHistogramRef.current = refreshHistogram; React.useEffect(() => { if (playing) { diff --git a/tests/show3d/test_zoom_contrast_browser.py b/tests/show3d/test_zoom_contrast_browser.py index 57e490ae..e6b276f8 100644 --- a/tests/show3d/test_zoom_contrast_browser.py +++ b/tests/show3d/test_zoom_contrast_browser.py @@ -623,6 +623,70 @@ def test_display_controls_repaint_pixels_immediately_during_playback(tmp_path): assert page.get_by_role("button", name="Pause playback").count() == 1 assert np.abs(playing_after_style - playing_before_style).mean() > 2 assert min(_panel_spatial_std(playing_after_style, 3, layout_cols)) > 3 + + # 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. + page.get_by_role("checkbox", name="Link contrast across panels").uncheck() + page.wait_for_function( + "() => window.__quantemShow3DPerf.lastHistogramSource === 'gpu-panel-regions'" + ) + histogram_canvas = page.get_by_role( + "img", name="Histogram of intensity values with min and max clip handles" + ).first + histogram_before = histogram_canvas.screenshot() + histogram_frame = page.evaluate( + "() => window.__quantemShow3DPerf.lastHistogramFrame" + ) + page.wait_for_function( + "previous => window.__quantemShow3DPerf.lastHistogramFrame !== previous", + arg=histogram_frame, + ) + histogram_after = histogram_canvas.screenshot() + assert histogram_after != histogram_before + + frame_before_histogram_drag = page.evaluate( + "() => window.__quantemShow3DPerf.lastPlaybackLiveCountText" + ) + playing_before_histogram_drag = _visible_canvas_pixels(page) + playing_histogram_thumb = page.locator( + 'input[aria-label="Histogram intensity clip range"]' + ).first + playing_histogram_slider = playing_histogram_thumb.locator( + "xpath=ancestor::*[contains(@class, 'MuiSlider-root')]" + ) + playing_histogram_slider.scroll_into_view_if_needed() + playing_thumb_box = playing_histogram_slider.locator( + ".MuiSlider-thumb" + ).first.bounding_box() + playing_slider_box = playing_histogram_slider.bounding_box() + assert playing_thumb_box is not None and playing_slider_box is not None + page.mouse.move( + playing_thumb_box["x"] + playing_thumb_box["width"] / 2, + playing_thumb_box["y"] + playing_thumb_box["height"] / 2, + ) + page.mouse.down() + page.mouse.move( + playing_slider_box["x"] + playing_slider_box["width"] * 0.45, + playing_slider_box["y"] + playing_slider_box["height"] / 2, + ) + page.wait_for_function( + "() => window.__quantemShow3DPerf.lastHistogramRenderPath?.includes('webgpu')" + ) + playing_during_histogram_drag = _visible_canvas_pixels(page) + histogram_drag_perf = page.evaluate( + "() => ({ ...window.__quantemShow3DPerf })" + ) + assert page.get_by_role("button", name="Pause playback").count() == 1 + assert np.abs( + playing_during_histogram_drag - playing_before_histogram_drag + ).mean() > 1 + assert histogram_drag_perf["lastHistogramInputLatencyMs"] < 100 + page.mouse.up() + page.wait_for_function( + "previous => window.__quantemShow3DPerf.lastPlaybackLiveCountText !== previous", + arg=frame_before_histogram_drag, + ) page.get_by_role("button", name="Pause playback").click() assert page_errors == [] From 08842d102f722fe56fb7b21123b03df24af18792 Mon Sep 17 00:00:00 2001 From: Sangjoon Bob Lee Date: Mon, 10 Aug 2026 21:10:47 -0700 Subject: [PATCH 2/3] docs: unblock documentation deployment --- docs/_config.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/_config.yml b/docs/_config.yml index 6939cb8f..10717b80 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -16,6 +16,11 @@ exclude_patterns: execute: execute_notebooks: "force" timeout: 900 + # Keep the documentation deployable while this tutorial's referenced public + # dataset is unavailable. The source page is still published; only its build- + # time execution is skipped. Remove this once the public fixture exists. + exclude_patterns: + - "tutorials/showdiffraction.ipynb" # Embedded anywidget views need the ipywidgets HTML manager in the static page. # myst-nb writes the widget state baked into each notebook; this pulls the JS From 09712d20246501efc046f36001ac6531c5fa07db Mon Sep 17 00:00:00 2001 From: Sangjoon Bob Lee Date: Mon, 10 Aug 2026 21:14:22 -0700 Subject: [PATCH 3/3] chore: simplify pull request template footer --- .github/PULL_REQUEST_TEMPLATE.md | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8524ffef..a1fc1bf6 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,11 +10,6 @@ should only see boxes that are relevant to this PR. Either verify each box yourself or have a coding agent verify it and check it off for you; mark an item that is in a relevant section but does not apply as "n/a — reason". -This PR workflow follows the scientific-software packaging standards described -in: S. Lee, C. Myers, A. Yang, T. Zhang, Y. Xiao, and S. J. L. Billinge, -"Scikit-package - software packaging standards and roadmap for sharing -reproducible scientific software", Digital Discovery (2026). -https://doi.org/10.1039/d6dd00121a --> ## Summary @@ -206,8 +201,6 @@ select any missing gates without relying on private logs. --- -This PR workflow follows the packaging standards for reproducible scientific -software described in [scikit-package](https://doi.org/10.1039/d6dd00121a): -S. Lee, C. Myers, A. Yang, T. Zhang, Y. Xiao, and S. J. L. Billinge, -*Digital Discovery* (2026), DOI -[10.1039/d6dd00121a](https://doi.org/10.1039/d6dd00121a). +This PR follows the +[scikit-package](https://scikit-package.github.io/scikit-package/) workflow for +reproducible scientific software.