Skip to content

feat(core): add Direct Binary Search (DBS) refinement - #71

Merged
g4bri3lDev merged 12 commits into
mainfrom
feat/dbs-refinement
Oct 7, 2026
Merged

g4bri3lDev merged 12 commits into
mainfrom
feat/dbs-refinement

Conversation

@g4bri3lDev

@g4bri3lDev g4bri3lDev commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds optional Direct Binary Search (DBS) refinement to the Rust core, as a final pipeline step after dithering: DitherConfig::dbs: Option<DbsParams>, off by default. Inspired by OpenDithering's DBS; the algorithm is ported from its dbs.ts (MIT) and then optimized.

  • What it does: iteratively toggles pixels to other inks and swaps 8-neighbours, keeping any change that lowers the perceived error. That error is the halftone-minus-target difference, blurred by an eye model.
  • Eye model: narrow blur for luminance, wider for chrominance, scaled by viewing distance and PPI. Constants from Kolpatzik & Bouman (1992); the chroma model is fitted to Mullen (1985).
  • Error space: YyCxCz (Flohr, Kolpatzik et al.), a linear transform of XYZ, so blurring averages light physically. This deliberately differs from the OKLab matching used for error diffusion, which stays unchanged.
  • Pinned pixels: exact canonical pixels under dither_with_canonical stay pinned, and their target is their own ink, so they don't pull neighbours.
  • Bindings: exposed in Python as dither_image(..., dbs=None | True | DbsParams(...)) and in JS as ditherImage(..., { dbs?: boolean | DbsParams }). Both validate input; JS validates in TypeScript because wasm-bindgen silently coerces numbers into u32. Both READMEs document it.

Results (8 fixtures, 800×480, Spectra 6 measured, 40 cm, 127 ppi, 10 passes)

  • Quality: neutral metric (mean OKLab ΔE on 4×4 block averages) improves 12–57% on 7 of 8 images. seeed_opendisplay (a graphic, not a photo) is slightly worse: 0.183 → 0.186.
  • Speed: about 0.26 s per panel on 4P+4E cores, versus about 35 ms for Burkes alone.
Commit Change DBS time (8 images)
d1da1eb Baseline port 16.37 s
6d8735f Interleaved channels + fused chroma update 10.58 s (bit-identical)
beddd43 Row-slice footprint sweep 6.71 s (bit-identical)
3ae0706 Blur truncation 3σ → 2.5σ 5.44 s
1af7db6 Parallel tiles (2×2 checkerboard phases, no unsafe) 2.07 s

Rejected: f32 correlation storage. It gained only 5% and broke the exact error cross-check.

Single-threaded (e.g. WASM without threads), the tiled code costs about 4% over serial. It does not get the 2.6× speedup.

Tests

  • Local optimum: after convergence, no single toggle or neighbour swap lowers the error, checked by brute force.
  • Error cross-check: the incremental error matches a direct blur-and-square computation on single- and multi-tile images. This catches a wrong ΔE formula or an undersized tile margin; both were mutation-tested.
  • Linear light: flat sRGB 188 on black/white reaches ≈50% white coverage. This pins the decision to measure error in linear light.
  • Pinning: pinned pixels never change, including through dither_with_canonical.
  • Determinism: output is identical with 1 and 4 threads.
  • Existing suites: workspace tests pass; cargo clippy --workspace -D warnings is clean; Python and WASM crates compile.

Review notes

  • Visual check: cargo run --release --example dbs_compare writes PNGs plus an index.html (full-size strips and 4× zoom crops) to packages/rust/target/dbs_compare/.
  • Colour speckle: worth checking on a real panel before choosing defaults. DBS builds mid-greys from red/blue/white mazes and puts sparse coloured dots in dark shadows. The eye model predicts these average out at viewing distance.

CI fixes (toolchain drift, mostly in pre-existing code)

CI installs the latest toolchains on every run, and main hadn't run CI since July:

  • Clippy 1.99 added chunks_exact_to_as_chunks. Four sites now use as_chunks (core and the iOS wrapper; three of them pre-date this PR).
  • wasm-bindgen 0.2.129 renamed internal glue imports, which broke core.ts's hand-wired instantiation. The JS package now builds with wasm-pack --target web and initialises with the generated initSync({ module }), so it no longer references wasm-bindgen internals. The CI and release workflows, the README and AGENTS.md are updated.
  • ruff 0.16 now formats code blocks in Markdown. It was applied to the Python README and CALIBRATION.md.

Pinning toolchains in CI would prevent this class of breakage. That's left as a separate decision.

Optional post-dither refinement step (`DitherConfig::dbs`, off by default)
that minimises perceived error under a luminance/chrominance eye model in
YyCxCz (Kolpatzik & Bouman CSF constants). Exact canonical pixels stay
pinned. Ported from OpenDithering's dbs.ts (MIT).

Adds the `dbs_compare` example for side-by-side PNG/HTML comparison.
Bindings pass `dbs: None`; exposing it is left for a follow-up.
--variants limits a run to named variants for faster benchmarking loops.
--baseline compares renders against an earlier --out directory and
reports the percentage of differing pixels, plus per-variant totals, so
optimizations can be checked for bit-identical output.
Store c_pe as one [f64; 3] per pixel instead of three planes, and update
Cx and Cz in a single footprint sweep since they share the chrominance
kernel. Output is bit-identical; DBS time on the 8 fixtures drops from
16.37 s to 10.58 s (1.55x).
Zip kernel and c_pe row slices instead of indexing per element, removing
per-element index arithmetic and bounds checks. Output is bit-identical;
10.58 s -> 6.71 s (1.58x).
Truncate the Gaussian blur itself (not its autocorrelation), so c_pp stays
the exact autocorrelation and the error model remains self-consistent.
Chroma kernel radius 7 -> 6, autocorrelation 29x29 -> 25x25.
6.71 s -> 5.44 s (1.24x); block dE 0.0667 -> 0.0666. 2.0 sigma was a
further 15% faster with no measurable change, but block dE cannot see
texture, so the conservative value is kept pending on-panel checks.
Each pass runs in four phases over a 2x2 checkerboard of tiles. Tiles of
one phase are at least 2*margin+1 apart (margin = autocorrelation radius
+ 1), so they never touch each other's data and run in parallel with
rayon on private region copies; no unsafe. The result depends only on the
fixed tile order, not on thread count (new test), and the error
cross-check now spans multiple tiles to catch an undersized margin.
5.44 s -> 2.07 s (2.6x) on 4P+4E cores with unchanged quality; ~4%
overhead when single-threaded.
@codecov

codecov Bot commented Oct 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Returns a typed InvalidDbsParams error for non-finite or non-positive
viewing distance or ppi, so FFI bindings can report bad input as an
error instead of hitting the panic in dbs_refine.
dither_image(..., dbs=None | True | DbsParams(...)). DbsParams is a frozen
dataclass (viewing_distance_cm=40, ppi=127, max_passes=10) validated on
construction; the binding takes flat dbs_* keyword arguments and also
validates, raising ValueError. Runs inside the existing GIL-released call.
ditherImage(..., { dbs?: boolean | DbsParams }) with DbsParams
{ viewingDistanceCm, ppi, maxPasses }. Validated in TypeScript because
wasm-bindgen silently coerces numbers into u32 (-1 -> 4294967295); the
WASM binding validates geometry again as a backstop.
Clippy 1.99 (used by CI's unpinned stable toolchain) adds
chunks_exact_to_as_chunks, which fails `-D warnings` on four sites:
composite.rs, two in core lib.rs, and the iOS wrapper. as_chunks yields
&[u8; N] chunks, so the iOS palette parse simplifies to to_vec().
Clean on both 1.96 and 1.99.
ruff 0.16 (resolved by CI; uv.lock is not committed) formats Python code
blocks in Markdown, which failed the ruff-format hook on README.md and
docs/CALIBRATION.md. Cosmetic only: quotes, trailing commas, line joins.
core.ts instantiated the module by hand from the --target bundler output,
listing wasm-bindgen's internal glue imports by name. wasm-bindgen 0.2.129
(resolved by CI; the wasm lockfile is not committed) renamed them
(__wbindgen_cast_* -> __wbindgen_generic_*), so instantiation failed with
a LinkError.

Build with --target web instead and call the generated
initSync({ module }) on the inlined bytes: wasm-bindgen's supported API
for synchronous init, which wires its own imports. This also drops the
hand-written _bg.js type declarations in favour of the generated .d.ts.
CI, release workflow, README and AGENTS.md updated to --target web.
Verified ESM and CJS builds in Node.
@g4bri3lDev
g4bri3lDev merged commit 5fc0843 into main Oct 7, 2026
10 checks passed
@github-actions github-actions Bot mentioned this pull request Oct 7, 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