Skip to content

Quality pass: faster DCT, consistent numbers, standard SSIM, accessibility, two inspector features - #5

Merged
thundermage117 merged 9 commits into
mainfrom
feature/quality-pass
Oct 10, 2026
Merged

thundermage117 merged 9 commits into
mainfrom
feature/quality-pass

Conversation

@thundermage117

Copy link
Copy Markdown
Owner

Stacked on #4, which is stacked on #3. Merge those first; this PR then retargets to main.

Summary

This PR mostly improves existing behaviour, plus two small features. Each item is its own commit.

Improvements

  • Faster DCT (perf(core)): a separable, table-driven 8×8 DCT/IDCT. On a 768×512 image at 4:2:0, process_image (DCT) drops from 118 to 33 ms and rd_encode from 99 to 14 ms. Output matches the direct definition to 1e-9.
  • Phone layouts (fix(ui)): at 390 px wide the viewer scrolled 115 px sideways, and both inspectors were cramped into short scrolling panels with overlapping controls. Narrow screens now use a single scrolling column.
  • Consistent numbers (fix):
    • The coefficient histogram now comes from the codec's own DCT, Haar and CDF 9/7 coefficients, with doubling-width bins and the share zeroed at the current quality. get_coeff_histogram is replaced by get_coeff_stats.
    • The block inspector codes DC as the difference from the previous block, as JPEG does. "Est. Bits" is relabelled "JPEG Bits (est.)".
    • The heatmap legend shows real pixel levels.
  • Accessibility (feat(a11y)):
    • Inspector grids are labelled groups with one tab stop each. Arrow keys, Home and End move between cells. Focus behaves like hover. Enter or Space activates a cell. The first Escape closes the popover and the second exits.
    • Every cell has an aria-label.
    • 18 toggle buttons expose aria-pressed.
  • Standard SSIM (fix(core)): 11×11 Gaussian window with σ = 1.5 (Wang et al. 2004), checked against an independent NumPy implementation to 1e-9. Values move down slightly; for example, Kodak Wavelet fixes (CDF 9/7, fair bit estimates), DCT edge fixes, and viewer UX #3 luma at Q50 goes from 0.9435 to 0.9359.
  • One quality control (feat(ui)): QualityControl.svelte replaces three separate slider implementations. While dragging, the image updates live through the new preview_image binding, which skips PSNR/SSIM; the metrics are computed on release.

Features

  • Drop coefficients (feat(inspector)): keep the first N coefficients in zig-zag order, or turn on "Edit coefficients" and toggle individual ones. Reconstruction, error, statistics, bits, zig-zag and entropy panels all update. The JS reconstruction is verified against the codec's own in a real-WASM browser test.
  • Artifact map → block (feat(artifacts)): in DCT mode, clicking a spot on the artifact map opens that 8×8 block in the block inspector. Back or Escape returns to the same map.

Behaviour changes

  • SSIM values change slightly, because it is now the standard metric.
  • A full encode with metrics takes 56 ms instead of 33 ms, because SSIM now runs at every pixel. Dragging the slider is unaffected, since the preview skips metrics.

Test plan

  • make test: 62/62. New: DCT vs. direct definition, orientation, coefficient stats, and SSIM vs. the reference.

  • npm run check: 0 errors, 0 warnings. npm run build succeeds.

  • npm run test: 221. New: grid keyboard navigation, QualityControl, coefficient-edit state, reconstructBlock, DC prediction and scan order, getCoeffStats, mode navigation.

  • npm run test:browser: 46, including real-WASM tests for coefficient stats and JS-vs-codec reconstruction.

  • Manual check in Chromium via Playwright:

    • phone layouts in all three modes;
    • live slider drag;
    • grid keyboard flow;
    • dropping coefficients (keep 6 of 34: MSE 49 → 1047, bits 295 → 72);
    • artifact map → block → back.

    No console errors.

Known limitation

Opening a block from the artifact map is mouse-only.

🤖 Generated with Claude Code

thundermage117 and others added 9 commits October 10, 2026 00:12
The direct formula evaluated two std::cos calls per term, 4096 terms per
block. Precompute the orthonormal basis once and apply the transform as
two 1D passes (1024 multiply-adds per block).

768x512 4:2:0 in Chromium: process_image (DCT) 118 -> 33 ms,
rd_encode (DCT) 99 -> 14 ms. Output matches the direct definition to
1e-9 (new TransformTest.MatchesDirectDefinition), plus an orientation
test so a transposed implementation can't slip through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At 390 px wide:
- Viewer: the canvas header (Comparison/Original/Processed + Zoom) was a
  fixed 3-column grid with 90 px-min buttons and scrolled the page 115 px
  sideways. Below 600 px the toggle gets its own full-width row.
- Inspectors: the sidebar was clamped to 200 px with its own scrollbar
  while an empty pane filled the screen; the 56 px topbar made the
  artifact mode chips wrap over the sidebar; Zoom was absolutely
  positioned over the view toggle. Below 900 px the page now scrolls as
  one column, the topbar grows, chips get a scrollable row, and Zoom
  flows inline.
- Selecting a block from the navigator or a suggestion scrolls the
  pipeline into view on narrow screens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Coefficient histogram compared 8x8 Haar blocks, which the codec no
  longer uses, in linear 16-wide bins (nearly everything in bar one).
  It now comes from ImageCodec::coefficientStats: the codec's own DCT /
  Haar / CDF 9/7 coefficients of the luma channel, log2 bins, plus the
  share that quantizes to zero at the current quality (follows the
  slider). get_coeff_histogram is replaced by get_coeff_stats.
- Block inspector codes DC as the difference from the previous block's
  quantized DC, as JPEG does (non-interleaved raster scan; subsampled
  chroma steps back in its own grid). "Est. Bits" is relabelled "JPEG
  Bits (est.)" and its tooltip explains why it differs from the
  viewer's whole-image entropy estimate.
- Artifact heatmap legend shows real pixel levels for the current map
  and gain instead of the 0-255 map scale, matching the tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Inspector 8x8 grids are labelled groups with one tab stop each
  (roving tabindex). Arrow keys / Home / End move between cells without
  triggering block navigation; focusing a cell does what hovering does
  (cross-grid highlight, tooltip or basis popover); Enter/Space does what
  a click does; the first Escape closes the popover, the next exits.
- Every cell has an aria-label (position, value, meaning, action).
- 18 stateful toggle buttons (view toggles, chips, presets, blink /
  heatmap / grid, zoom, suggested blocks) expose aria-pressed.
- The image viewer's label names what is shown (original, compressed
  with transform and quality, or the artifact map and its meaning).
- The inspector's shortcut panel documents the grid keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SSIM used a flat 8x8 window sampled every 4 pixels, so its numbers
didn't match other tools (and multi-channel input indexed the buffer as
if it had one channel). It now follows Wang et al. (2004): 11x11
Gaussian window, sigma 1.5, K1 = 0.01, K2 = 0.03, every pixel, mean over
the region where the window fits; clamped borders for tiny images;
mean over channels for multi-channel input.

Verified against an independent NumPy implementation of the same
definition (the ssim_index.m / scikit-image formulation) to 1e-9 on
three image pairs. The blur pairs symmetric taps and only clamps near
the edges: full encode with metrics is 56 ms on 768x512 (the RD sweep,
which skips SSIM, is unaffected at 14 ms). Typical values move slightly
down, e.g. Kodak #3 luma at Q50: 0.9435 -> 0.9359.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The viewer, block inspector and artifact inspector each had their own
quality slider + presets with different commit rules (two committed on
release, the inspector re-encoded on every input event). They now share
QualityControl.svelte, which keeps the existing element ids:

- The committed value (appState.quality) is written on release, by
  presets, or by keyboard; the slider follows external changes.
- While dragging, an optional onPreview runs at most once per frame.
  The viewer and artifact inspector use the new preview_image binding,
  which re-encodes and redraws without PSNR/SSIM (computed once on
  release); the block inspector commits live, now frame-throttled.
- Unit tests mount the component (vitest resolves Svelte's browser
  build under jsdom).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A "what if?" bar in the decoding pipeline:
- Keep first N (1-64) coefficients in zig-zag order, and/or switch on
  "Edit coefficients" and click (or Enter on) a coefficient in the
  decoder-side Quantized / Dequantized grids to drop or restore it.
- Dropped cells are struck through; reconstruction, error, MSE / peak
  error, zero count, JPEG bit estimate, zig-zag and entropy panels all
  follow the edit, with a "Dropping k of n non-zero" summary and Reset.
- Per-block toggles clear when you move to another block; keep-first-N
  persists, so arrowing through blocks shows each one's low-frequency
  approximation.

inspection.ts now copies a block out of WASM once (inspectBlock) and
renders it with edits applied (renderInspectedBlock, untracked so the
block-change effect doesn't subscribe to edit state). Edited blocks are
re-synthesised by reconstructBlock in JS; a real-WASM browser test
checks it matches the codec's reconstruction when nothing is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In DCT mode the artifact map outlines the 8x8 block under the cursor
(the tooltip says which), and a click (outside zoom mode) opens it in
the Block Inspector. Back / Escape there returns to the artifact
inspector on the same map ("Back to Artifacts") instead of the viewer.
Wavelets have no blocks, so there the hint says to switch to DCT and
clicks do nothing.

- state: leaveCurrentMode() / openBlockInspector() replace the
  duplicated Escape / Back logic in App and InspectorMode, with an
  optional return target.
- ImageViewer: optional blockOutline prop (pointer cursor while set).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@thundermage117
thundermage117 changed the base branch from fix/inspector-bugs to main October 10, 2026 15:13
@thundermage117
thundermage117 merged commit 3814d65 into main Oct 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant