You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add a frequency Filter family to Show2D and Show3D — low-pass, high-pass, band-pass — as a view operation distinct from Denoise. Reuse the existing WebGPU FFT engine (js/fft.ts). Denoise stays count-respecting ("clean up"); Filter reshapes frequency content ("remove background / isolate a periodicity").
This is a sibling to the existing display-only Denoise (js/displayFilter.ts) and should follow the same architecture: browser-side WebGPU, view-only (never mutates the stored array, stats, or raw-count exports), announces itself in the banner.
Why a separate control, not folded into Denoise
Users think in goals, not in our taxonomy:
"This EDS map is too noisy — clean it up" -> Denoise (count-respecting; still measurable).
"There's a brightness gradient across the field — kill it" -> High-pass.
"I want to see the lattice / a specific periodicity" -> Band-pass.
The two carry different promises: denoise output is still a faithful (cleaner) version of the measurement; high-pass/band-pass deliberately delete real signal, so raw counts off them are meaningless. Keeping them as one control would tell a microscopist "this is cleaner data" while handing them something they must not measure. That is the same honesty problem tracked in #153. So: split them, and label by goal (plain term first, technical term in parentheses).
Note the honest overlap: a Gaussian denoise is a real-space low-pass. That's fine — same tool, two intents. Real-space smoothing stays under Denoise (Anscombe-aware, count-preserving); the FFT ring filters live under Filter.
Proposed UI
Filter control (sibling to Denoise), members: None / Low-pass / High-pass / Band-pass.
Parameters: a cutoff frequency for low/high-pass; center + width for band-pass. Expressed in physical units (1/nm) using the dataset sampling when available, falling back to normalized 0-1 (Nyquist).
Draggable ring overlay on the FFT panel you already render (Show2D has an FFT toggle) so users set the cutoff by eye.
Own on/off, chainable with Denoise (pipeline: denoise -> filter). Each announces itself in the banner.
Consistent across Show2D (single / per-panel / A-B) and Show3D.
Architecture
New browser module (e.g. js/frequencyFilter.ts) mirroring displayFilter.ts: a WebGPU path (forward FFT -> apply radial mask -> inverse FFT, via js/fft.tsgetWebGPUFFT/fft2d) plus a CPU fallback for the offline/no-GPU path.
Python side: view-only traits parallel to the denoise family (mode, cutoff, band center/width, per-panel arrays, scope), a filter_banner, and a _webgpu_filter_ok-style negotiation so Python ships RAW frames and the browser applies the filter live. Never bake the filter into the stored data or exports.
Radial mask on GPU: smooth (e.g. Butterworth/Gaussian-edged) rings to avoid ringing artifacts, not a hard binary cutoff.
Acceptance criteria
Low/high/band-pass all run browser-side on WebGPU, interactive (cutoff drag recomputes live), with a CPU fallback for offline HTML.
View-only: stored array, Mean/Min/Max/Std stats, and raw-count exports are unchanged; setting Filter to None restores the raw view.
Filter is visibly distinct from Denoise in the UI (separate control, goal-oriented label) and the two chain correctly.
Works in Show2D (single / per-panel / A-B) and Show3D; drive-verified on the real GPU with before/after screenshots.
Follow-up (separate issue, not this one)
"Which denoise/filter should I apply?" guidance — detect sparse/low-count data and suggest a preset (e.g. Poisson/Anscombe, bin 2, sigma 6-10), or a small side-by-side preview so users pick by eye. This is the higher-value usability work but is orthogonal to shipping the Filter family; track it separately.
Update the user-story spec (anti-regression record)
Add SOURCE-tagged user stories for the Filter family to the story spec
(docs/user-stories/show2d.md and docs/user-stories/show3d.md, gitignored,
per the widget-drive skill). Each as GIVEN / WHEN / THEN with a VISIBLE then, e.g.:
GIVEN an EDS map with a slow brightness gradient, WHEN I enable High-pass and
drag the cutoff ring, THEN the gradient flattens live and the banner reads the
cutoff; setting Filter to None restores the raw view.
GIVEN a lattice image, WHEN I enable Band-pass centered on the lattice
frequency, THEN only that periodicity remains; stats/exports stay raw counts.
Follow the failing-story -> fix -> drive-confirm loop: write the stories first,
then implement, then drive-verify each story passes on the real GPU.
Summary
Add a frequency Filter family to Show2D and Show3D — low-pass, high-pass, band-pass — as a view operation distinct from Denoise. Reuse the existing WebGPU FFT engine (
js/fft.ts). Denoise stays count-respecting ("clean up"); Filter reshapes frequency content ("remove background / isolate a periodicity").This is a sibling to the existing display-only Denoise (
js/displayFilter.ts) and should follow the same architecture: browser-side WebGPU, view-only (never mutates the stored array, stats, or raw-count exports), announces itself in the banner.Why a separate control, not folded into Denoise
Users think in goals, not in our taxonomy:
The two carry different promises: denoise output is still a faithful (cleaner) version of the measurement; high-pass/band-pass deliberately delete real signal, so raw counts off them are meaningless. Keeping them as one control would tell a microscopist "this is cleaner data" while handing them something they must not measure. That is the same honesty problem tracked in #153. So: split them, and label by goal (plain term first, technical term in parentheses).
Note the honest overlap: a Gaussian denoise is a real-space low-pass. That's fine — same tool, two intents. Real-space smoothing stays under Denoise (Anscombe-aware, count-preserving); the FFT ring filters live under Filter.
Proposed UI
Architecture
js/frequencyFilter.ts) mirroringdisplayFilter.ts: a WebGPU path (forward FFT -> apply radial mask -> inverse FFT, viajs/fft.tsgetWebGPUFFT/fft2d) plus a CPU fallback for the offline/no-GPU path.filter_banner, and a_webgpu_filter_ok-style negotiation so Python ships RAW frames and the browser applies the filter live. Never bake the filter into the stored data or exports.Acceptance criteria
Follow-up (separate issue, not this one)
"Which denoise/filter should I apply?" guidance — detect sparse/low-count data and suggest a preset (e.g. Poisson/Anscombe, bin 2, sigma 6-10), or a small side-by-side preview so users pick by eye. This is the higher-value usability work but is orthogonal to shipping the Filter family; track it separately.
Related
js/displayFilter.ts,js/fft.ts,src/quantem/widget/show2d.py,src/quantem/widget/show3d.py.Update the user-story spec (anti-regression record)
Add SOURCE-tagged user stories for the Filter family to the story spec
(
docs/user-stories/show2d.mdanddocs/user-stories/show3d.md, gitignored,per the widget-drive skill). Each as GIVEN / WHEN / THEN with a VISIBLE then, e.g.:
drag the cutoff ring, THEN the gradient flattens live and the banner reads the
cutoff; setting Filter to None restores the raw view.
frequency, THEN only that periodicity remains; stats/exports stay raw counts.
Follow the failing-story -> fix -> drive-confirm loop: write the stories first,
then implement, then drive-verify each story passes on the real GPU.