Skip to content

Add frequency Filter family (low/high/band-pass) — distinct from Denoise, WebGPU + FFT-ring UI #154

Description

@bobleesj

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:

  • "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.ts getWebGPUFFT/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.
  • Any active filter announces itself in the banner (mode + cutoff), and the banner clears when the filter is off (do not repeat the Show2D denoise UI: three controls labeled "Denoise" + banner persists when master toggle is OFF #153 stale-banner bug).
  • 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.

Related

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions