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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
5 changes: 5 additions & 0 deletions docs/_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
181 changes: 181 additions & 0 deletions js/colormaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<uniform> params: RegionParams;
@group(0) @binding(1) var<storage, read> data: array<f32>;
@group(0) @binding(2) var<storage, read> range_in: RangeIn;
@group(0) @binding(3) var<storage, read_write> bins: array<atomic<u32>>;

@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<number>(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).
Expand Down
76 changes: 57 additions & 19 deletions js/show3d/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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) {
Expand Down
64 changes: 64 additions & 0 deletions tests/show3d/test_zoom_contrast_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == []
Expand Down