Repository navigation
feat(core): add Direct Binary Search (DBS) refinement - #71
Merged
Merged
Conversation
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 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.
This was referenced Oct 7, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 itsdbs.ts(MIT) and then optimized.dither_with_canonicalstay pinned, and their target is their own ink, so they don't pull neighbours.dither_image(..., dbs=None | True | DbsParams(...))and in JS asditherImage(..., { dbs?: boolean | DbsParams }). Both validate input; JS validates in TypeScript because wasm-bindgen silently coerces numbers intou32. Both READMEs document it.Results (8 fixtures, 800×480, Spectra 6 measured, 40 cm, 127 ppi, 10 passes)
seeed_opendisplay(a graphic, not a photo) is slightly worse: 0.183 → 0.186.d1da1eb6d8735fbeddd433ae07061af7db6unsafe)Rejected:
f32correlation 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
dither_with_canonical.cargo clippy --workspace -D warningsis clean; Python and WASM crates compile.Review notes
cargo run --release --example dbs_comparewrites PNGs plus anindex.html(full-size strips and 4× zoom crops) topackages/rust/target/dbs_compare/.CI fixes (toolchain drift, mostly in pre-existing code)
CI installs the latest toolchains on every run, and
mainhadn't run CI since July:chunks_exact_to_as_chunks. Four sites now useas_chunks(core and the iOS wrapper; three of them pre-date this PR).core.ts's hand-wired instantiation. The JS package now builds withwasm-pack --target weband initialises with the generatedinitSync({ module }), so it no longer references wasm-bindgen internals. The CI and release workflows, the README and AGENTS.md are updated.CALIBRATION.md.Pinning toolchains in CI would prevent this class of breakage. That's left as a separate decision.