diff --git a/.github/workflows/javascript-test.yml b/.github/workflows/javascript-test.yml new file mode 100644 index 0000000..550cd71 --- /dev/null +++ b/.github/workflows/javascript-test.yml @@ -0,0 +1,57 @@ +name: JavaScript Tests + +on: + push: + branches: [main] + paths: + - 'packages/javascript/**' + - 'packages/rust/wasm/**' + - 'packages/rust/core/**' + - '.github/workflows/javascript-test.yml' + pull_request: + paths: + - 'packages/javascript/**' + - 'packages/rust/wasm/**' + - 'packages/rust/core/**' + - '.github/workflows/javascript-test.yml' + workflow_dispatch: + +defaults: + run: + working-directory: packages/javascript + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: packages/rust + + - name: Install wasm-pack + run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + + - name: Build WASM + working-directory: . + run: wasm-pack build packages/rust/wasm --target bundler --out-dir ../../javascript/src/wasm-core + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Type check + run: bun run type-check + + - name: Run tests + run: bun run test + + - name: Build + run: bun run build diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml index 9bb4022..bc15ef7 100644 --- a/.github/workflows/python-lint.yml +++ b/.github/workflows/python-lint.yml @@ -26,7 +26,7 @@ jobs: run: uv python install 3.11 - name: Install dependencies - run: uv --project packages/python sync --all-extras + run: uv --project packages/python sync --all-extras --no-install-project - name: Run prek run: uv --project packages/python run prek run --all-files --config packages/python/.pre-commit-config.yaml \ No newline at end of file diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index b6ae4a0..b06991e 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -5,10 +5,12 @@ on: branches: [main] paths: - 'packages/python/**' + - 'packages/rust/**' - '.github/workflows/python-test.yml' pull_request: paths: - 'packages/python/**' + - 'packages/rust/**' - '.github/workflows/python-test.yml' workflow_dispatch: @@ -33,8 +35,17 @@ jobs: - name: Set up Python ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }} + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: packages/python + - name: Install dependencies - run: uv sync --all-extras + run: uv sync --all-extras --no-install-project + + - name: Build and install package + run: uv run maturin develop --release - name: Run tests run: uv run pytest tests/ -v --tb=short @@ -49,4 +60,4 @@ jobs: with: file: ./packages/python/coverage.xml fail_ci_if_error: false - flags: python \ No newline at end of file + flags: python diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc7ee75..0c8bd9e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,78 +15,145 @@ jobs: release-please: runs-on: ubuntu-latest outputs: - python-release-created: ${{ steps.release.outputs['packages/python--release_created'] }} - python-tag: ${{ steps.release.outputs['packages/python--tag_name'] }} - javascript-release-created: ${{ steps.release.outputs['packages/javascript--release_created'] }} - javascript-tag: ${{ steps.release.outputs['packages/javascript--tag_name'] }} + release-created: ${{ steps.release.outputs['packages/python--release_created'] }} + tag: ${{ steps.release.outputs['packages/python--tag_name'] }} steps: - uses: googleapis/release-please-action@v4 id: release with: - # Use manifest-based config for monorepo config-file: release-please-config.json manifest-file: .release-please-manifest.json - # Publish Python package to PyPI - publish-python: + # ── Rust: publish epaper-dithering-core to crates.io ──────────────────────── + + publish-rust: needs: release-please - if: ${{ needs.release-please.outputs.python-release-created }} + if: ${{ needs.release-please.outputs.release-created }} runs-on: ubuntu-latest - defaults: - run: - working-directory: packages/python steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable - - name: Set up Python - uses: actions/setup-python@v5 + - uses: Swatinem/rust-cache@v2 with: - python-version: "3.11" + workspaces: packages/rust - - name: Install uv - uses: astral-sh/setup-uv@v4 + - name: Publish to crates.io + run: cargo publish -p epaper-dithering-core + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - - name: Install dependencies - run: uv sync + # ── Python: build wheels for each platform ────────────────────────────────── - - name: Build package - run: uv build + build-python-wheels: + needs: release-please + if: ${{ needs.release-please.outputs.release-created }} + name: Build wheels (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64 + - os: ubuntu-latest + target: aarch64 + - os: ubuntu-latest + target: armv7 + - os: macos-latest + target: universal2-apple-darwin + - os: windows-latest + target: x86_64 + steps: + - uses: actions/checkout@v4 + + - uses: PyO3/maturin-action@v1 + with: + working-directory: packages/python + target: ${{ matrix.target }} + manylinux: auto + args: --release --out dist --find-interpreter + sccache: true + + - uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.target }} + path: packages/python/dist + + build-python-sdist: + needs: release-please + if: ${{ needs.release-please.outputs.release-created }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: PyO3/maturin-action@v1 with: - packages-dir: packages/python/dist/ + working-directory: packages/python + command: sdist + args: --out dist + + - uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: packages/python/dist + + publish-python: + needs: [build-python-wheels, build-python-sdist] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + pattern: wheels-* + merge-multiple: true + path: dist + + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + + # ── JavaScript ─────────────────────────────────────────────────────────────── - # Publish JavaScript package to npm publish-javascript: needs: release-please - if: ${{ needs.release-please.outputs.javascript-release-created }} + if: ${{ needs.release-please.outputs.release-created }} runs-on: ubuntu-latest permissions: - id-token: write # Required for npm provenance + id-token: write contents: read defaults: run: working-directory: packages/javascript steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: packages/rust + + - name: Install wasm-pack + run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + + - name: Build WASM + working-directory: ${{ github.workspace }} + run: wasm-pack build packages/rust/wasm --target bundler --out-dir ../../javascript/src/wasm-core - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: '25' registry-url: 'https://registry.npmjs.org' - - name: Setup Bun - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@v2 - name: Install dependencies run: bun install - name: Run tests - run: bun test + run: bun run test - name: Build package run: bun run build diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml new file mode 100644 index 0000000..97afc2b --- /dev/null +++ b/.github/workflows/rust-test.yml @@ -0,0 +1,36 @@ +name: Rust Tests + +on: + push: + branches: [main] + paths: + - 'packages/rust/**' + - '.github/workflows/rust-test.yml' + pull_request: + paths: + - 'packages/rust/**' + - '.github/workflows/rust-test.yml' + workflow_dispatch: + +defaults: + run: + working-directory: packages/rust + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: packages/rust + + - name: Test + run: cargo test --workspace + + - name: Clippy + run: cargo clippy --workspace -- -D warnings diff --git a/.gitignore b/.gitignore index 62a5514..c419eea 100644 --- a/.gitignore +++ b/.gitignore @@ -77,4 +77,11 @@ yarn.lock *.gif *.bmp !fixtures/images/ -uv.lock \ No newline at end of file +uv.lock + +# Python scratch / dev files +packages/python/test.py +packages/python/test_timed.py +packages/python/benchmark.py +packages/python/np_test.py +packages/python/*.pdf \ No newline at end of file diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 200d86e..e2d298f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,3 @@ { - "packages/python": "0.6.4", - "packages/javascript": "2.2.1" + "packages/python": "3.0.0" } \ No newline at end of file diff --git a/README.md b/README.md index 227562b..a0a391a 100644 --- a/README.md +++ b/README.md @@ -2,146 +2,111 @@ [![PyPI](https://img.shields.io/pypi/v/epaper-dithering?style=flat-square)](https://pypi.org/project/epaper-dithering/) [![npm](https://img.shields.io/npm/v/@opendisplay/epaper-dithering?style=flat-square)](https://www.npmjs.com/package/@opendisplay/epaper-dithering) -[![Python Tests](https://img.shields.io/github/actions/workflow/status/OpenDisplay-org/epaper-dithering/python-test.yml?style=flat-square&label=tests)](https://github.com/OpenDisplay-org/epaper-dithering/actions/workflows/python-test.yml) -[![Python Lint](https://img.shields.io/github/actions/workflow/status/OpenDisplay-org/epaper-dithering/python-lint.yml?style=flat-square&label=lint)](https://github.com/OpenDisplay-org/epaper-dithering/actions/workflows/python-lint.yml) +[![Python Tests](https://img.shields.io/github/actions/workflow/status/OpenDisplay-org/epaper-dithering/python-test.yml?style=flat-square&label=python)](https://github.com/OpenDisplay-org/epaper-dithering/actions/workflows/python-test.yml) +[![JS Tests](https://img.shields.io/github/actions/workflow/status/OpenDisplay-org/epaper-dithering/javascript-test.yml?style=flat-square&label=javascript)](https://github.com/OpenDisplay-org/epaper-dithering/actions/workflows/javascript-test.yml) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json&style=flat-square)](https://github.com/astral-sh/ruff) -[![mypy](https://img.shields.io/badge/mypy-strict-blue?style=flat-square)](https://mypy.readthedocs.io/) -A monorepo containing dithering algorithm implementations for e-paper/e-ink displays in multiple languages. +Dithering algorithms for e-paper/e-ink displays. Rust core with Python and JavaScript/TypeScript bindings. + +## Examples + +![Frankfurt at night — original vs Spectra 6-color with auto tone + gamut compression](docs/examples/frankfurt_before_after.png) + +**Dithering algorithms** (Spectra 6-color, Marienplatz U-Bahn station): + +![All dithering algorithms compared](docs/examples/algorithms_grid.png) + +**Color schemes** (Burkes, river scene): + +![All color schemes compared](docs/examples/color_schemes_grid.png) ## Packages ### Python (`packages/python/`) -Dithering algorithms for e-paper displays. Published to PyPI as `epaper-dithering`. +Published to PyPI as [`epaper-dithering`](https://pypi.org/project/epaper-dithering/). -**Installation:** ```bash pip install epaper-dithering ``` -**Usage:** ```python from PIL import Image -from epaper_dithering import dither_image, ColorScheme, DitherMode +from epaper_dithering import dither_image, ColorScheme, DitherMode, SPECTRA_7_3_6COLOR_V2 img = Image.open("photo.jpg") -dithered = dither_image(img, ColorScheme.BWR, DitherMode.FLOYD_STEINBERG) -dithered.save("output.png") + +# Idealized palette +dithered = dither_image(img, ColorScheme.BWR, mode=DitherMode.FLOYD_STEINBERG) + +# Measured palette — auto tone + gamut compression +dithered = dither_image(img, SPECTRA_7_3_6COLOR_V2) ``` -See [`packages/python/README.md`](packages/python/README.md) for detailed documentation. +See [`packages/python/README.md`](packages/python/README.md) for full documentation. ### JavaScript/TypeScript (`packages/javascript/`) -Dithering algorithms for e-paper displays in TypeScript. Published to npm as `@opendisplay/epaper-dithering`. +Published to npm as [`@opendisplay/epaper-dithering`](https://www.npmjs.com/package/@opendisplay/epaper-dithering). -**Installation:** ```bash npm install @opendisplay/epaper-dithering -# or -bun add @opendisplay/epaper-dithering ``` -**Usage (Browser):** ```typescript -import { ditherImage, ColorScheme, DitherMode } from '@opendisplay/epaper-dithering'; - -// Create ImageBuffer from Canvas -const canvas = document.createElement('canvas'); -const ctx = canvas.getContext('2d')!; -ctx.drawImage(img, 0, 0); -const imageData = ctx.getImageData(0, 0, img.width, img.height); +import { ditherImage, ColorScheme, DitherMode, SPECTRA_7_3_6COLOR_V2 } from '@opendisplay/epaper-dithering'; -const imageBuffer = { - width: imageData.width, - height: imageData.height, - data: imageData.data, -}; +// ImageBuffer from Canvas API or Node.js (sharp, etc.) +const dithered = ditherImage(imageBuffer, ColorScheme.BWR, { mode: DitherMode.BURKES }); -const dithered = ditherImage(imageBuffer, ColorScheme.BWR, DitherMode.FLOYD_STEINBERG); +// Measured palette — auto tone + gamut compression +const dithered = ditherImage(imageBuffer, SPECTRA_7_3_6COLOR_V2); ``` -**Usage (Node.js with sharp):** -```typescript -import sharp from 'sharp'; -import { ditherImage, ColorScheme, DitherMode } from '@opendisplay/epaper-dithering'; - -const { data, info } = await sharp('photo.jpg') - .ensureAlpha() - .raw() - .toBuffer({ resolveWithObject: true }); - -const imageBuffer = { - width: info.width, - height: info.height, - data: new Uint8ClampedArray(data), -}; - -const dithered = ditherImage(imageBuffer, ColorScheme.BWR, DitherMode.BURKES); -``` - -See [`packages/javascript/README.md`](packages/javascript/README.md) for detailed documentation. +See [`packages/javascript/README.md`](packages/javascript/README.md) for full documentation. ## Features -- **9 Dithering Algorithms**: NONE, BURKES, ORDERED, FLOYD_STEINBERG, ATKINSON, STUCKI, SIERRA, SIERRA_LITE, JARVIS_JUDICE_NINKE +- **Rust Core**: All dithering logic in `packages/rust/core/` — shared by both packages +- **9 Dithering Algorithms**: NONE, ORDERED, BURKES, FLOYD_STEINBERG, ATKINSON, STUCKI, SIERRA, SIERRA_LITE, JARVIS_JUDICE_NINKE - **8 Color Schemes**: MONO, BWR, BWY, BWRY, BWGBRY (Spectra 6), GRAYSCALE_4, GRAYSCALE_8, GRAYSCALE_16 -- **Multiple Languages**: Python and JavaScript/TypeScript implementations -- **Universal**: Works in Python, Node.js, and browser environments -- **Type-Safe**: Full type coverage in both languages +- **Measured Palettes**: Calibrated RGB values for real displays with tone + gamut compression +- **OKLab Color Matching**: Weighted Cartesian OKLab — preserves hue without the achromatic-attractor bug of LCH-weighted approaches +- **Pre-dither Knobs**: Per-image exposure, saturation, shadows, highlights, and gamut compression — all orthogonal -## Development +## Repository Structure -### Python Development +``` +epaper-dithering/ +├── packages/ +│ ├── rust/ +│ │ ├── core/ # Shared Rust algorithms (OKLab, dithering, tone/gamut) +│ │ └── wasm/ # wasm-bindgen bindings for JavaScript +│ ├── python/ # PyO3/maturin extension + Python wrapper +│ └── javascript/ # TypeScript wrapper + bundled WASM +├── docs/ # Calibration and color science documentation +└── .github/workflows/ # CI for all packages +``` + +## Development ```bash +# Python (requires Rust toolchain: https://rustup.rs) cd packages/python uv sync --all-extras +uv run maturin develop --release uv run pytest tests/ -v -uv run ruff check src/ tests/ -uv run mypy src/epaper_dithering -``` -### JavaScript Development - -```bash +# JavaScript cd packages/javascript bun install -bun test -bun run build -``` - -### Repository Structure +bun run test -``` -epaper-dithering/ -├── packages/ -│ ├── python/ # Python implementation -│ │ ├── src/ -│ │ ├── tests/ -│ │ └── pyproject.toml -│ └── javascript/ # TypeScript/JavaScript implementation -│ ├── src/ -│ ├── tests/ -│ └── package.json -├── fixtures/ # Shared test fixtures -│ ├── images/ # Input test images -│ └── expected/ # Expected dithered outputs -├── .github/ -│ └── workflows/ # CI/CD for both packages -├── docs/ # Shared documentation -└── README.md +# Rust +cd packages/rust +cargo test --workspace ``` ## Related Projects - **py-opendisplay**: [Python library for OpenDisplay BLE e-paper devices](https://github.com/OpenDisplay-org/py-opendisplay) - -## Future Plans - -- [ ] Python and js feature parity -- [x] Add s-curve tone mapping for better contrast -- [ ] Rust implementation with bindings for Python/JavaScript/other languages -- [ ] Web-based demo/playground - diff --git a/docs/examples/algorithms_grid.png b/docs/examples/algorithms_grid.png new file mode 100644 index 0000000..b765ebc Binary files /dev/null and b/docs/examples/algorithms_grid.png differ diff --git a/docs/examples/color_schemes_grid.png b/docs/examples/color_schemes_grid.png new file mode 100644 index 0000000..74c0478 Binary files /dev/null and b/docs/examples/color_schemes_grid.png differ diff --git a/docs/examples/frankfurt_before_after.png b/docs/examples/frankfurt_before_after.png new file mode 100644 index 0000000..f8511d9 Binary files /dev/null and b/docs/examples/frankfurt_before_after.png differ diff --git a/docs/examples/generate.py b/docs/examples/generate.py new file mode 100644 index 0000000..5da1226 --- /dev/null +++ b/docs/examples/generate.py @@ -0,0 +1,116 @@ +"""Generate example images for README documentation.""" + +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont +from epaper_dithering import dither_image, ColorScheme, DitherMode, SPECTRA_7_3_6COLOR_V2 + +FIXTURES = Path(__file__).parent.parent.parent / "packages/rust/core/tests/fixtures/images" +OUT = Path(__file__).parent + +def load(name: str) -> Image.Image: + return Image.open(FIXTURES / name).convert("RGB") + + +def dithered_rgb(img: Image.Image, palette, mode: DitherMode) -> Image.Image: + return dither_image(img, palette, mode=mode).convert("RGB") + + +def label(img: Image.Image, text: str, size: int = 20) -> Image.Image: + """Add a label bar at the bottom of an image.""" + bar_h = 32 + out = Image.new("RGB", (img.width, img.height + bar_h), (30, 30, 30)) + out.paste(img, (0, 0)) + draw = ImageDraw.Draw(out) + try: + font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size) + except Exception: + font = ImageFont.load_default() + draw.text((img.width // 2, img.height + bar_h // 2), text, fill=(220, 220, 220), font=font, anchor="mm") + return out + + +def grid(cells: list[list[Image.Image]]) -> Image.Image: + rows = len(cells) + cols = max(len(r) for r in cells) + w = cells[0][0].width + h = cells[0][0].height + out = Image.new("RGB", (cols * w, rows * h), (15, 15, 15)) + for r, row in enumerate(cells): + for c, cell in enumerate(row): + out.paste(cell, (c * w, r * h)) + return out + + +# ── 1. Frankfurt night before/after ─────────────────────────────────────────── + +print("Generating frankfurt night before/after...") +frankfurt_orig = Image.open(FIXTURES / "frankfurt_nacht.png").convert("RGB") +frankfurt_no_pre = dither_image(frankfurt_orig, SPECTRA_7_3_6COLOR_V2, + mode=DitherMode.BURKES, tone=0.0, gamut=0.0).convert("RGB") +frankfurt_auto = dither_image(frankfurt_orig, SPECTRA_7_3_6COLOR_V2, + mode=DitherMode.BURKES).convert("RGB") + +p1 = label(frankfurt_orig, "Original") +p2 = label(frankfurt_no_pre, "Spectra 6-color · Burkes · no preprocessing") +p3 = label(frankfurt_auto, "Spectra 6-color · Burkes · auto tone + gamut") + +ba = Image.new("RGB", (p1.width + p2.width + p3.width + 8, p1.height), (15, 15, 15)) +ba.paste(p1, (0, 0)) +ba.paste(p2, (p1.width + 4, 0)) +ba.paste(p3, (p1.width + p2.width + 8, 0)) +ba.save(OUT / "frankfurt_before_after.png") + +# ── 2. All algorithms grid ──────────────────────────────────────────────────── + +print("Generating algorithms grid...") +src = load("ubahn_station.png") + +ALGOS = [ + (DitherMode.NONE, "None (direct map)"), + (DitherMode.ORDERED, "Ordered (Bayer 4×4)"), + (DitherMode.FLOYD_STEINBERG, "Floyd-Steinberg"), + (DitherMode.ATKINSON, "Atkinson"), + (DitherMode.BURKES, "Burkes"), + (DitherMode.SIERRA_LITE, "Sierra Lite"), + (DitherMode.SIERRA, "Sierra"), + (DitherMode.STUCKI, "Stucki"), + (DitherMode.JARVIS_JUDICE_NINKE,"Jarvis-Judice-Ninke"), +] + +algo_cells = [] +for i in range(0, len(ALGOS), 3): + row = [] + for mode, name in ALGOS[i:i+3]: + cell = dithered_rgb(src, SPECTRA_7_3_6COLOR_V2, mode) + row.append(label(cell, name)) + algo_cells.append(row) + +grid(algo_cells).save(OUT / "algorithms_grid.png") + +# ── 3. All color schemes grid ───────────────────────────────────────────────── + +print("Generating color schemes grid...") +src2 = load("river.png") + +SCHEMES = [ + (ColorScheme.MONO, "Mono"), + (ColorScheme.BWR, "BWR"), + (ColorScheme.BWY, "BWY"), + (ColorScheme.BWRY, "BWRY"), + (ColorScheme.BWGBRY, "BWGBRY (Spectra 6)"), + (ColorScheme.GRAYSCALE_4, "Grayscale 4"), + (ColorScheme.GRAYSCALE_8, "Grayscale 8"), + (ColorScheme.GRAYSCALE_16,"Grayscale 16"), +] + +scheme_cells = [] +for i in range(0, len(SCHEMES), 4): + row = [] + for scheme, name in SCHEMES[i:i+4]: + cell = dithered_rgb(src2, scheme, DitherMode.BURKES) + row.append(label(cell, name)) + scheme_cells.append(row) + +grid(scheme_cells).save(OUT / "color_schemes_grid.png") + +print("Done. Output in", OUT) diff --git a/docs/index.html b/docs/index.html index b8f5d7f..b15c99d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -184,6 +184,65 @@ color: var(--text); } + /* Slider */ + .slider-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + } + + .slider-row input[type=range] { + flex: 1; + -webkit-appearance: none; + appearance: none; + height: 3px; + background: var(--border-hi); + outline: none; + cursor: pointer; + } + + .slider-row input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + background: var(--accent); + cursor: pointer; + border-radius: 0; + } + + .slider-row input[type=range]:disabled { + opacity: 0.3; + cursor: not-allowed; + } + + .slider-row input[type=range]:disabled::-webkit-slider-thumb { + cursor: not-allowed; + } + + .slider-val { + font-size: 10px; + color: var(--accent); + width: 32px; + text-align: right; + flex-shrink: 0; + } + + .slider-val.muted { color: var(--muted); } + + /* Compression section hidden when not applicable */ + #compressionSection { display: none; } + + .sub-group-label { + font-size: 9px; + font-weight: 500; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 6px; + margin-top: 10px; + } + .toggle { position: relative; width: 30px; height: 16px; flex-shrink: 0; } .toggle input { opacity: 0; width: 0; height: 0; position: absolute; } @@ -506,6 +565,7 @@ + @@ -534,7 +594,7 @@ - +
Options
@@ -546,6 +606,34 @@
+ +
+
Tone Compression
+
+ Auto levels + +
+
+ + auto +
+
Gamut Compression
+
+ Auto gamut + +
+
+ + auto +
+
+
@@ -607,6 +695,7 @@ ColorScheme, DitherMode, SPECTRA_7_3_6COLOR, + SPECTRA_7_3_6COLOR_V2, BWRY_3_97, MONO_4_26, BWRY_4_2, @@ -627,6 +716,7 @@ GRAYSCALE_8: ColorScheme.GRAYSCALE_8, GRAYSCALE_16: ColorScheme.GRAYSCALE_16, SPECTRA_7_3_6COLOR, + SPECTRA_7_3_6COLOR_V2, BWRY_3_97, MONO_4_26, BWRY_4_2, @@ -658,6 +748,13 @@ const schemeSelect = document.getElementById('schemeSelect'); const modeSelect = document.getElementById('modeSelect'); const serpentine = document.getElementById('serpentineToggle'); +const compressionSection = document.getElementById('compressionSection'); +const toneAutoToggle = document.getElementById('toneAutoToggle'); +const toneSlider = document.getElementById('toneSlider'); +const toneVal = document.getElementById('toneVal'); +const gamutAutoToggle = document.getElementById('gamutAutoToggle'); +const gamutSlider = document.getElementById('gamutSlider'); +const gamutVal = document.getElementById('gamutVal'); const origCanvas = document.getElementById('origCanvas'); const dithCanvas = document.getElementById('dithCanvas'); const canvasRow = document.getElementById('canvasRow'); @@ -689,6 +786,26 @@ } } +// ── Compression helpers ────────────────────────────────────────── + +function isMeasuredPalette() { + return typeof SCHEMES[schemeSelect.value] !== 'number'; +} + +function updateCompressionSection() { + compressionSection.style.display = isMeasuredPalette() ? 'block' : 'none'; +} + +function getToneCompression() { + if (!isMeasuredPalette()) return 'auto'; + return toneAutoToggle.checked ? 'auto' : toneSlider.value / 100; +} + +function getGamutCompression() { + if (!isMeasuredPalette()) return 'auto'; + return gamutAutoToggle.checked ? 'auto' : gamutSlider.value / 100; +} + // ── Core: dither and render ────────────────────────────────────── function runDither() { @@ -697,6 +814,8 @@ const schemeKey = schemeSelect.value; const modeKey = modeSelect.value; const useSerpentine = serpentine.checked; + const toneComp = getToneCompression(); + const gamutComp = getGamutCompression(); const scheme = SCHEMES[schemeKey]; const mode = MODES[modeKey]; @@ -709,7 +828,7 @@ const result = ditherImage( { width: currentImageData.width, height: currentImageData.height, data: currentImageData.data }, - scheme, mode, useSerpentine, + scheme, mode, useSerpentine, toneComp, gamutComp, ); const elapsed = performance.now() - t0; @@ -778,6 +897,7 @@ statSize.textContent = `${img.naturalWidth}×${img.naturalHeight}`; document.title = `Dither Lab — ${file.name}`; + updateCompressionSection(); runDither(); }; img.src = e.target.result; @@ -796,9 +916,31 @@ uploadZone.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', (e) => loadFile(e.target.files[0])); -schemeSelect.addEventListener('change', onSettingChange); +schemeSelect.addEventListener('change', () => { updateCompressionSection(); onSettingChange(); }); modeSelect.addEventListener('change', onSettingChange); serpentine.addEventListener('change', onSettingChange); +toneAutoToggle.addEventListener('change', () => { + const isAuto = toneAutoToggle.checked; + toneSlider.disabled = isAuto; + toneVal.textContent = isAuto ? 'auto' : `${toneSlider.value}%`; + toneVal.className = 'slider-val' + (isAuto ? ' muted' : ''); + onSettingChange(); +}); +toneSlider.addEventListener('input', () => { + toneVal.textContent = `${toneSlider.value}%`; + runDither(); +}); +gamutAutoToggle.addEventListener('change', () => { + const isAuto = gamutAutoToggle.checked; + gamutSlider.disabled = isAuto; + gamutVal.textContent = isAuto ? 'auto' : `${gamutSlider.value}%`; + gamutVal.className = 'slider-val' + (isAuto ? ' muted' : ''); + onSettingChange(); +}); +gamutSlider.addEventListener('input', () => { + gamutVal.textContent = `${gamutSlider.value}%`; + runDither(); +}); uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('over'); }); uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('over')); diff --git a/packages/javascript/README.md b/packages/javascript/README.md index 343bfcc..28c6900 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -2,18 +2,19 @@ [![npm](https://img.shields.io/npm/v/@opendisplay/epaper-dithering?style=flat-square)](https://www.npmjs.com/package/@opendisplay/epaper-dithering) -High-quality dithering algorithms for e-paper/e-ink displays, implemented in TypeScript. Works in both browser and Node.js environments. +High-quality dithering algorithms for e-paper/e-ink displays, powered by a Rust/WASM core. Works in both browser and Node.js environments. ## Features +- **Rust/WASM Core**: Compiled Rust logic bundled inline — no async init, no external files, works everywhere - **9 Dithering Algorithms**: From fast ordered dithering to high-quality error diffusion - **8 Color Schemes**: MONO, BWR, BWY, BWRY, BWGBRY (Spectra 6), GRAYSCALE\_4/8/16 -- **Measured Palettes**: Use real display-calibrated colors for accurate dithering (SPECTRA\_7\_3\_6COLOR, BWRY\_3\_97, and more) -- **LCH Color Matching**: Perceptual LAB color space with hue-weighted distance — hue errors can't be recovered by error diffusion, so they're prioritized +- **Measured Palettes**: Use real display-calibrated colors for accurate dithering (SPECTRA\_7\_3\_6COLOR\_V2, BWRY\_3\_97, and more) +- **OKLab Color Matching**: Weighted Cartesian OKLab — preserves hue without the achromatic-attractor bug that plagues LCH-weighted approaches +- **Pre-dither Adjustments**: Per-image exposure, saturation, shadows, highlights, dynamic-range compression, and gamut compression — all orthogonal knobs - **Serpentine Scanning**: Alternates row direction to eliminate directional artifacts -- **Universal**: Works in browser (Canvas API) and Node.js (with sharp/jimp) -- **Zero Dependencies**: Pure TypeScript, no image library dependencies -- **Fast**: 256-entry sRGB LUT, pre-computed palette LAB arrays, typed array pixel buffers +- **Universal**: Works in browser (Canvas API) and Node.js (≥18) +- **Zero Dependencies**: WASM binary bundled inline, no image library required ## Installation @@ -44,7 +45,7 @@ const imageData = ctx.getImageData(0, 0, img.width, img.height); const dithered = ditherImage( { width: imageData.width, height: imageData.height, data: imageData.data }, ColorScheme.BWR, - DitherMode.FLOYD_STEINBERG, + { mode: DitherMode.FLOYD_STEINBERG }, ); // Render result @@ -65,10 +66,10 @@ Standard `ColorScheme` values use ideal sRGB colors (e.g. white = 255,255,255). import { ditherImage, SPECTRA_7_3_6COLOR, BWRY_3_97 } from '@opendisplay/epaper-dithering'; // Automatically applies tone compression to fit the display's actual dynamic range -const dithered = ditherImage(imageBuffer, SPECTRA_7_3_6COLOR, DitherMode.BURKES); +const dithered = ditherImage(imageBuffer, SPECTRA_7_3_6COLOR, { mode: DitherMode.BURKES }); ``` -Available measured palettes: `SPECTRA_7_3_6COLOR`, `BWRY_3_97`, `MONO_4_26`, `BWRY_4_2`, `SOLUM_BWR`, `HANSHOW_BWR`, `HANSHOW_BWY`. +Available measured palettes: `SPECTRA_7_3_6COLOR_V2`, `SPECTRA_7_3_6COLOR`, `BWRY_3_97`, `MONO_4_26`, `BWRY_4_2`, `SOLUM_BWR`, `HANSHOW_BWR`, `HANSHOW_BWY`. ### Node.js (with sharp) @@ -84,7 +85,7 @@ const { data, info } = await sharp('photo.jpg') const dithered = ditherImage( { width: info.width, height: info.height, data: new Uint8ClampedArray(data) }, ColorScheme.BWR, - DitherMode.BURKES, + { mode: DitherMode.BURKES }, ); const rgbaBuffer = Buffer.alloc(dithered.width * dithered.height * 4); @@ -101,14 +102,24 @@ await sharp(rgbaBuffer, { raw: { width: dithered.width, height: dithered.height, ## API Reference -### `ditherImage(image, colorScheme, mode?, serpentine?)` +### `ditherImage(image, colorScheme, options?)` -| Parameter | Type | Default | Description | +```typescript +ditherImage(image: ImageBuffer, palette: ColorScheme | ColorPalette, options?: DitherOptions): PaletteImageBuffer +``` + +| `options` field | Type | Default | Description | |---|---|---|---| -| `image` | `ImageBuffer` | — | RGBA input image | -| `colorScheme` | `ColorScheme \| ColorPalette` | — | Target palette (enum or measured) | | `mode` | `DitherMode` | `BURKES` | Dithering algorithm | | `serpentine` | `boolean` | `true` | Alternate row direction to reduce artifacts | +| `exposure` | `number` | `1.0` | Linear-RGB exposure multiplier. `2.0` = +1 stop, `0.5` = −1 stop | +| `saturation` | `number` | `1.0` | OKLab saturation multiplier. `0.0` = grayscale, `>1` = boost. Hue-preserving | +| `shadows` | `number` | `0.0` | Shadow lift strength (S-curve lower half). `0.0` = off, `1.0` = strong | +| `highlights` | `number` | `0.0` | Highlight compression strength (S-curve upper half). `0.0` = off, `1.0` = strong | +| `tone` | `number \| 'auto' \| 'off'` | `'auto'` | Dynamic range compression. `'auto'` = histogram-based; numeric = fixed strength. Ignored for `ColorScheme` | +| `gamut` | `number \| 'auto' \| 'off'` | `'auto'` | Pre-dither gamut compression. `'auto'` = activate when image exceeds palette gamut; numeric = fixed. Ignored for `ColorScheme` | + +Pre-processing pipeline: `exposure → saturation → shadows/highlights → tone → gamut → dither`. Each step is a no-op at its identity value. Returns `PaletteImageBuffer`. @@ -178,7 +189,20 @@ bun run dev Features: drag & drop or paste from clipboard, live re-render on every setting change, timing display, palette swatch preview. +## Development + +```bash +bun install + +# When Rust source changes, rebuild the WASM (from repo root): +wasm-pack build packages/rust/wasm --target bundler --out-dir ../../javascript/src/wasm-core + +bun run test # vitest +bun run build # tsup → dist/ +bun run type-check +``` + ## Related Projects -- **Python**: [`epaper-dithering`](https://pypi.org/project/epaper-dithering/) — Python implementation (feature superset) +- **Python**: [`epaper-dithering`](https://pypi.org/project/epaper-dithering/) — Python package, shares the same Rust core - **OpenDisplay**: [`py-opendisplay`](https://github.com/OpenDisplay-org/py-opendisplay) — Python library for OpenDisplay BLE devices diff --git a/packages/javascript/dev.html b/packages/javascript/dev.html index 4b1069c..ccf669a 100644 --- a/packages/javascript/dev.html +++ b/packages/javascript/dev.html @@ -184,6 +184,65 @@ color: var(--text); } + /* Slider */ + .slider-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + } + + .slider-row input[type=range] { + flex: 1; + -webkit-appearance: none; + appearance: none; + height: 3px; + background: var(--border-hi); + outline: none; + cursor: pointer; + } + + .slider-row input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + background: var(--accent); + cursor: pointer; + border-radius: 0; + } + + .slider-row input[type=range]:disabled { + opacity: 0.3; + cursor: not-allowed; + } + + .slider-row input[type=range]:disabled::-webkit-slider-thumb { + cursor: not-allowed; + } + + .slider-val { + font-size: 10px; + color: var(--accent); + width: 32px; + text-align: right; + flex-shrink: 0; + } + + .slider-val.muted { color: var(--muted); } + + /* Compression section hidden when not applicable */ + #compressionSection { display: none; } + + .sub-group-label { + font-size: 9px; + font-weight: 500; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 6px; + margin-top: 10px; + } + .toggle { position: relative; width: 30px; height: 16px; flex-shrink: 0; } .toggle input { opacity: 0; width: 0; height: 0; position: absolute; } @@ -506,6 +565,7 @@
+ @@ -534,7 +594,7 @@ - +
Options
@@ -546,6 +606,59 @@
+ +
+
Pre-dither
+
Exposure
+
+ + 1.00 +
+
Saturation
+
+ + 1.00 +
+
Shadows
+
+ + 0.00 +
+
Highlights
+
+ + 0.00 +
+
+ + +
+
Tone Compression
+
+ Auto levels + +
+
+ + auto +
+
Gamut Compression
+
+ Auto gamut + +
+
+ + auto +
+
+
@@ -607,6 +720,7 @@ ColorScheme, DitherMode, SPECTRA_7_3_6COLOR, + SPECTRA_7_3_6COLOR_V2, BWRY_3_97, MONO_4_26, BWRY_4_2, @@ -627,6 +741,7 @@ GRAYSCALE_8: ColorScheme.GRAYSCALE_8, GRAYSCALE_16: ColorScheme.GRAYSCALE_16, SPECTRA_7_3_6COLOR, + SPECTRA_7_3_6COLOR_V2, BWRY_3_97, MONO_4_26, BWRY_4_2, @@ -658,6 +773,21 @@ const schemeSelect = document.getElementById('schemeSelect'); const modeSelect = document.getElementById('modeSelect'); const serpentine = document.getElementById('serpentineToggle'); +const compressionSection = document.getElementById('compressionSection'); +const toneAutoToggle = document.getElementById('toneAutoToggle'); +const toneSlider = document.getElementById('toneSlider'); +const toneVal = document.getElementById('toneVal'); +const gamutAutoToggle = document.getElementById('gamutAutoToggle'); +const gamutSlider = document.getElementById('gamutSlider'); +const gamutVal = document.getElementById('gamutVal'); +const exposureSlider = document.getElementById('exposureSlider'); +const exposureVal = document.getElementById('exposureVal'); +const saturationSlider = document.getElementById('saturationSlider'); +const saturationVal = document.getElementById('saturationVal'); +const shadowsSlider = document.getElementById('shadowsSlider'); +const shadowsVal = document.getElementById('shadowsVal'); +const highlightsSlider = document.getElementById('highlightsSlider'); +const highlightsVal = document.getElementById('highlightsVal'); const origCanvas = document.getElementById('origCanvas'); const dithCanvas = document.getElementById('dithCanvas'); const canvasRow = document.getElementById('canvasRow'); @@ -689,6 +819,33 @@ } } +// ── Compression helpers ────────────────────────────────────────── + +function isMeasuredPalette() { + return typeof SCHEMES[schemeSelect.value] !== 'number'; +} + +function updateCompressionSection() { + compressionSection.style.display = isMeasuredPalette() ? 'block' : 'none'; +} + +function getToneCompression() { + if (!isMeasuredPalette()) return 'auto'; + return toneAutoToggle.checked ? 'auto' : toneSlider.value / 100; +} + +function getGamutCompression() { + if (!isMeasuredPalette()) return 'auto'; + return gamutAutoToggle.checked ? 'auto' : gamutSlider.value / 100; +} + +// ── Pre-dither helpers ─────────────────────────────────────────── + +function getExposure() { return exposureSlider.value / 100; } +function getSaturation() { return saturationSlider.value / 100; } +function getShadows() { return shadowsSlider.value / 100; } +function getHighlights() { return highlightsSlider.value / 100; } + // ── Core: dither and render ────────────────────────────────────── function runDither() { @@ -697,6 +854,8 @@ const schemeKey = schemeSelect.value; const modeKey = modeSelect.value; const useSerpentine = serpentine.checked; + const toneComp = getToneCompression(); + const gamutComp = getGamutCompression(); const scheme = SCHEMES[schemeKey]; const mode = MODES[modeKey]; @@ -709,7 +868,17 @@ const result = ditherImage( { width: currentImageData.width, height: currentImageData.height, data: currentImageData.data }, - scheme, mode, useSerpentine, + scheme, + { + mode, + serpentine: useSerpentine, + exposure: getExposure(), + saturation: getSaturation(), + shadows: getShadows(), + highlights: getHighlights(), + tone: toneComp, + gamut: gamutComp, + }, ); const elapsed = performance.now() - t0; @@ -778,6 +947,7 @@ statSize.textContent = `${img.naturalWidth}×${img.naturalHeight}`; document.title = `Dither Lab — ${file.name}`; + updateCompressionSection(); runDither(); }; img.src = e.target.result; @@ -796,9 +966,45 @@ uploadZone.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', (e) => loadFile(e.target.files[0])); -schemeSelect.addEventListener('change', onSettingChange); +schemeSelect.addEventListener('change', () => { updateCompressionSection(); onSettingChange(); }); modeSelect.addEventListener('change', onSettingChange); serpentine.addEventListener('change', onSettingChange); +toneAutoToggle.addEventListener('change', () => { + const isAuto = toneAutoToggle.checked; + toneSlider.disabled = isAuto; + toneVal.textContent = isAuto ? 'auto' : `${toneSlider.value}%`; + toneVal.className = 'slider-val' + (isAuto ? ' muted' : ''); + onSettingChange(); +}); +toneSlider.addEventListener('input', () => { + toneVal.textContent = `${toneSlider.value}%`; + runDither(); +}); +gamutAutoToggle.addEventListener('change', () => { + const isAuto = gamutAutoToggle.checked; + gamutSlider.disabled = isAuto; + gamutVal.textContent = isAuto ? 'auto' : `${gamutSlider.value}%`; + gamutVal.className = 'slider-val' + (isAuto ? ' muted' : ''); + onSettingChange(); +}); +gamutSlider.addEventListener('input', () => { + gamutVal.textContent = `${gamutSlider.value}%`; + runDither(); +}); + +// Pre-dither sliders. Mute the value label when at identity (no effect). +function wireFloatSlider(slider, label, identityValue) { + slider.addEventListener('input', () => { + const v = slider.value / 100; + label.textContent = v.toFixed(2); + label.className = 'slider-val' + (v === identityValue ? ' muted' : ''); + runDither(); + }); +} +wireFloatSlider(exposureSlider, exposureVal, 1.0); +wireFloatSlider(saturationSlider, saturationVal, 1.0); +wireFloatSlider(shadowsSlider, shadowsVal, 0.0); +wireFloatSlider(highlightsSlider, highlightsVal, 0.0); uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('over'); }); uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('over')); diff --git a/packages/javascript/package.json b/packages/javascript/package.json index dd5fe1d..4e69852 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -1,6 +1,6 @@ { "name": "@opendisplay/epaper-dithering", - "version": "2.2.1", + "version": "3.0.0", "description": "Dithering algorithms for e-paper/e-ink displays (JavaScript/TypeScript)", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/javascript/src/algorithms.ts b/packages/javascript/src/algorithms.ts deleted file mode 100644 index b5c525d..0000000 --- a/packages/javascript/src/algorithms.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { SRGB_TO_LINEAR_LUT } from './color_space'; -import { precomputePaletteLab, matchPixelLch } from './color_space_lab'; -import { autoCompressDynamicRange, compressDynamicRange } from './tone_map'; -import type { RGB, ImageBuffer, PaletteImageBuffer, ColorPalette } from './types'; -import { ColorScheme, getPalette } from './palettes'; - -// Bayer 4×4 matrix normalized to [-0.5, 0.5] — matches Python's _BAYER_4X4 -const BAYER_4X4: Float32Array = new Float32Array([ - 0, 8, 2, 10, - 12, 4, 14, 6, - 3, 11, 1, 9, - 15, 7, 13, 5, -].map(v => v / 16.0 - 0.5)); - -interface ErrorKernel { - dx: number; - dy: number; - weight: number; // pre-normalized (already divided by divisor) -} - -// ============================================================================= -// Internal helpers -// ============================================================================= - -function resolvePalette(scheme: ColorScheme | ColorPalette): ColorPalette { - return typeof scheme === 'number' ? getPalette(scheme) : scheme; -} - -/** Convert ColorPalette sRGB colors to linear [0,1] tuples. */ -function paletteToLinear(palette: ColorPalette): { - paletteRgb: RGB[]; - paletteLinear: Array<[number, number, number]>; -} { - const paletteRgb = Object.values(palette.colors); - const paletteLinear = paletteRgb.map(c => [ - SRGB_TO_LINEAR_LUT[c.r], - SRGB_TO_LINEAR_LUT[c.g], - SRGB_TO_LINEAR_LUT[c.b], - ] as [number, number, number]); - return { paletteRgb, paletteLinear }; -} - -/** - * Build a Float32Array linear pixel buffer from RGBA input, compositing on white. - * Alpha compositing in linear space: channel = LUT[srgb] * alpha + (1 - alpha) - * (white = 1.0 in linear; compositing is done in one pass, no separate alpha step) - */ -function buildLinearBuffer(image: ImageBuffer): Float32Array { - const n = image.width * image.height; - const pixels = new Float32Array(n * 3); - const { data } = image; - - for (let i = 0; i < n; i++) { - const base4 = i * 4; - const base3 = i * 3; - const alpha = data[base4 + 3] / 255.0; - const inv = 1.0 - alpha; - pixels[base3] = SRGB_TO_LINEAR_LUT[data[base4]] * alpha + inv; - pixels[base3 + 1] = SRGB_TO_LINEAR_LUT[data[base4 + 1]] * alpha + inv; - pixels[base3 + 2] = SRGB_TO_LINEAR_LUT[data[base4 + 2]] * alpha + inv; - } - - return pixels; -} - -// ============================================================================= -// Error diffusion -// ============================================================================= - -function errorDiffusionDither( - image: ImageBuffer, - scheme: ColorScheme | ColorPalette, - kernel: ErrorKernel[], - serpentine: boolean, - toneCompression: number | 'auto' = 'auto', -): PaletteImageBuffer { - const { width, height } = image; - const palette = resolvePalette(scheme); - const { paletteRgb, paletteLinear } = paletteToLinear(palette); - const numColors = paletteRgb.length; - - // Build linear pixel buffer with RGBA compositing - const pixels = buildLinearBuffer(image); - - // Tone compression for measured palettes only - if (typeof scheme !== 'number') { - if (toneCompression === 'auto') { - autoCompressDynamicRange(pixels, width, height, paletteLinear); - } else if (toneCompression > 0) { - compressDynamicRange(pixels, width, height, paletteLinear, toneCompression); - } - } - - // Pre-compute palette LAB for the hot loop - const { L: palL, a: palA, b: palB, C: palC } = precomputePaletteLab(paletteLinear); - - // Palette linear RGB as flat typed arrays for error computation - const palR = new Float64Array(numColors); - const palG = new Float64Array(numColors); - const palBl = new Float64Array(numColors); - for (let i = 0; i < numColors; i++) { - palR[i] = paletteLinear[i][0]; - palG[i] = paletteLinear[i][1]; - palBl[i] = paletteLinear[i][2]; - } - - const indices = new Uint8Array(width * height); - - for (let y = 0; y < height; y++) { - // Serpentine: alternate row direction to reduce directional artifacts - const leftToRight = !serpentine || (y % 2 === 0); - const xStart = leftToRight ? 0 : width - 1; - const xEnd = leftToRight ? width : -1; - const xStep = leftToRight ? 1 : -1; - - for (let x = xStart; x !== xEnd; x += xStep) { - const pixIdx = (y * width + x) * 3; - - // Clamp accumulated error to valid range before matching - const r = Math.max(0.0, Math.min(1.0, pixels[pixIdx])); - const g = Math.max(0.0, Math.min(1.0, pixels[pixIdx + 1])); - const b = Math.max(0.0, Math.min(1.0, pixels[pixIdx + 2])); - - // LCH-weighted LAB color matching - const newIdx = matchPixelLch(r, g, b, palL, palA, palB, palC); - indices[y * width + x] = newIdx; - - // Quantization error in linear space - const errR = r - palR[newIdx]; - const errG = g - palG[newIdx]; - const errB = b - palBl[newIdx]; - - // Distribute error to neighbors - for (let k = 0; k < kernel.length; k++) { - const kEntry = kernel[k]; - // Flip horizontal offset on right-to-left rows (serpentine) - const nx = x + (leftToRight ? kEntry.dx : -kEntry.dx); - const ny = y + kEntry.dy; - - if (nx >= 0 && nx < width && ny >= 0 && ny < height) { - const nIdx = (ny * width + nx) * 3; - pixels[nIdx] += errR * kEntry.weight; - pixels[nIdx + 1] += errG * kEntry.weight; - pixels[nIdx + 2] += errB * kEntry.weight; - } - } - } - } - - return { width, height, indices, palette: paletteRgb }; -} - -// ============================================================================= -// Non-error-diffusion algorithms -// ============================================================================= - -export function directPaletteMap( - image: ImageBuffer, - scheme: ColorScheme | ColorPalette, - toneCompression: number | 'auto' = 'auto', -): PaletteImageBuffer { - const { width, height } = image; - const palette = resolvePalette(scheme); - const { paletteRgb, paletteLinear } = paletteToLinear(palette); - - const pixels = buildLinearBuffer(image); - - if (typeof scheme !== 'number') { - if (toneCompression === 'auto') { - autoCompressDynamicRange(pixels, width, height, paletteLinear); - } else if (toneCompression > 0) { - compressDynamicRange(pixels, width, height, paletteLinear, toneCompression); - } - } - - const { L: palL, a: palA, b: palB, C: palC } = precomputePaletteLab(paletteLinear); - const indices = new Uint8Array(width * height); - const n = width * height; - - for (let i = 0; i < n; i++) { - const base = i * 3; - const r = Math.max(0.0, Math.min(1.0, pixels[base])); - const g = Math.max(0.0, Math.min(1.0, pixels[base + 1])); - const b = Math.max(0.0, Math.min(1.0, pixels[base + 2])); - indices[i] = matchPixelLch(r, g, b, palL, palA, palB, palC); - } - - return { width, height, indices, palette: paletteRgb }; -} - -export function orderedDither( - image: ImageBuffer, - scheme: ColorScheme | ColorPalette, - toneCompression: number | 'auto' = 'auto', -): PaletteImageBuffer { - const { width, height } = image; - const palette = resolvePalette(scheme); - const { paletteRgb, paletteLinear } = paletteToLinear(palette); - - const pixels = buildLinearBuffer(image); - - if (typeof scheme !== 'number') { - if (toneCompression === 'auto') { - autoCompressDynamicRange(pixels, width, height, paletteLinear); - } else if (toneCompression > 0) { - compressDynamicRange(pixels, width, height, paletteLinear, toneCompression); - } - } - - const { L: palL, a: palA, b: palB, C: palC } = precomputePaletteLab(paletteLinear); - const indices = new Uint8Array(width * height); - - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const base = (y * width + x) * 3; - const threshold = BAYER_4X4[(y % 4) * 4 + (x % 4)]; - - const r = Math.max(0.0, Math.min(1.0, pixels[base] + threshold)); - const g = Math.max(0.0, Math.min(1.0, pixels[base + 1] + threshold)); - const b = Math.max(0.0, Math.min(1.0, pixels[base + 2] + threshold)); - - indices[y * width + x] = matchPixelLch(r, g, b, palL, palA, palB, palC); - } - } - - return { width, height, indices, palette: paletteRgb }; -} - -// ============================================================================= -// Error diffusion algorithm wrappers -// Kernel weights are pre-normalized (divided by divisor) to eliminate per-pixel division. -// ============================================================================= - -export function floydSteinbergDither( - image: ImageBuffer, - scheme: ColorScheme | ColorPalette, - serpentine: boolean = true, -): PaletteImageBuffer { - return errorDiffusionDither(image, scheme, [ - { dx: 1, dy: 0, weight: 7 / 16 }, - { dx: -1, dy: 1, weight: 3 / 16 }, - { dx: 0, dy: 1, weight: 5 / 16 }, - { dx: 1, dy: 1, weight: 1 / 16 }, - ], serpentine); -} - -export function burkesDither( - image: ImageBuffer, - scheme: ColorScheme | ColorPalette, - serpentine: boolean = true, -): PaletteImageBuffer { - // Correct Burkes kernel: divisor 32 (not 200) - return errorDiffusionDither(image, scheme, [ - { dx: 1, dy: 0, weight: 8 / 32 }, - { dx: 2, dy: 0, weight: 4 / 32 }, - { dx: -2, dy: 1, weight: 2 / 32 }, - { dx: -1, dy: 1, weight: 4 / 32 }, - { dx: 0, dy: 1, weight: 8 / 32 }, - { dx: 1, dy: 1, weight: 4 / 32 }, - { dx: 2, dy: 1, weight: 2 / 32 }, - ], serpentine); -} - -export function sierraDither( - image: ImageBuffer, - scheme: ColorScheme | ColorPalette, - serpentine: boolean = true, -): PaletteImageBuffer { - return errorDiffusionDither(image, scheme, [ - { dx: 1, dy: 0, weight: 5 / 32 }, - { dx: 2, dy: 0, weight: 3 / 32 }, - { dx: -2, dy: 1, weight: 2 / 32 }, - { dx: -1, dy: 1, weight: 4 / 32 }, - { dx: 0, dy: 1, weight: 5 / 32 }, - { dx: 1, dy: 1, weight: 4 / 32 }, - { dx: 2, dy: 1, weight: 2 / 32 }, - { dx: -1, dy: 2, weight: 2 / 32 }, - { dx: 0, dy: 2, weight: 3 / 32 }, - { dx: 1, dy: 2, weight: 2 / 32 }, - ], serpentine); -} - -export function sierraLiteDither( - image: ImageBuffer, - scheme: ColorScheme | ColorPalette, - serpentine: boolean = true, -): PaletteImageBuffer { - return errorDiffusionDither(image, scheme, [ - { dx: 1, dy: 0, weight: 2 / 4 }, - { dx: -1, dy: 1, weight: 1 / 4 }, - { dx: 0, dy: 1, weight: 1 / 4 }, - ], serpentine); -} - -export function atkinsonDither( - image: ImageBuffer, - scheme: ColorScheme | ColorPalette, - serpentine: boolean = true, -): PaletteImageBuffer { - return errorDiffusionDither(image, scheme, [ - { dx: 1, dy: 0, weight: 1 / 8 }, - { dx: 2, dy: 0, weight: 1 / 8 }, - { dx: -1, dy: 1, weight: 1 / 8 }, - { dx: 0, dy: 1, weight: 1 / 8 }, - { dx: 1, dy: 1, weight: 1 / 8 }, - { dx: 0, dy: 2, weight: 1 / 8 }, - ], serpentine); -} - -export function stuckiDither( - image: ImageBuffer, - scheme: ColorScheme | ColorPalette, - serpentine: boolean = true, -): PaletteImageBuffer { - return errorDiffusionDither(image, scheme, [ - { dx: 1, dy: 0, weight: 8 / 42 }, - { dx: 2, dy: 0, weight: 4 / 42 }, - { dx: -2, dy: 1, weight: 2 / 42 }, - { dx: -1, dy: 1, weight: 4 / 42 }, - { dx: 0, dy: 1, weight: 8 / 42 }, - { dx: 1, dy: 1, weight: 4 / 42 }, - { dx: 2, dy: 1, weight: 2 / 42 }, - { dx: -2, dy: 2, weight: 1 / 42 }, - { dx: -1, dy: 2, weight: 2 / 42 }, - { dx: 0, dy: 2, weight: 4 / 42 }, - { dx: 1, dy: 2, weight: 2 / 42 }, - { dx: 2, dy: 2, weight: 1 / 42 }, - ], serpentine); -} - -export function jarvisJudiceNinkeDither( - image: ImageBuffer, - scheme: ColorScheme | ColorPalette, - serpentine: boolean = true, -): PaletteImageBuffer { - return errorDiffusionDither(image, scheme, [ - { dx: 1, dy: 0, weight: 7 / 48 }, - { dx: 2, dy: 0, weight: 5 / 48 }, - { dx: -2, dy: 1, weight: 3 / 48 }, - { dx: -1, dy: 1, weight: 5 / 48 }, - { dx: 0, dy: 1, weight: 7 / 48 }, - { dx: 1, dy: 1, weight: 5 / 48 }, - { dx: 2, dy: 1, weight: 3 / 48 }, - { dx: -2, dy: 2, weight: 1 / 48 }, - { dx: -1, dy: 2, weight: 3 / 48 }, - { dx: 0, dy: 2, weight: 5 / 48 }, - { dx: 1, dy: 2, weight: 3 / 48 }, - { dx: 2, dy: 2, weight: 1 / 48 }, - ], serpentine); -} diff --git a/packages/javascript/src/color_space.ts b/packages/javascript/src/color_space.ts deleted file mode 100644 index 720ebdf..0000000 --- a/packages/javascript/src/color_space.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * sRGB ↔ linear RGB conversion (IEC 61966-2-1). - * - * Key optimization: SRGB_TO_LINEAR_LUT is a 256-entry lookup table built once - * at module load. Since input pixels are always integers 0–255 (Uint8ClampedArray), - * conversion is a single array index — no Math.pow per pixel. - */ - -// 256-entry LUT: index = sRGB byte value, value = linear [0, 1] -export const SRGB_TO_LINEAR_LUT: Float64Array = (() => { - const lut = new Float64Array(256); - for (let i = 0; i < 256; i++) { - const s = i / 255.0; - lut[i] = s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); - } - return lut; -})(); - -/** - * Convert a single linear [0, 1] value to a rounded sRGB byte [0, 255]. - * Only needed for palette color encoding — not called per pixel. - */ -export function linearToSrgbByte(v: number): number { - const s = v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1.0 / 2.4) - 0.055; - return Math.max(0, Math.min(255, Math.round(s * 255))); -} diff --git a/packages/javascript/src/color_space_lab.ts b/packages/javascript/src/color_space_lab.ts deleted file mode 100644 index e6e0d74..0000000 --- a/packages/javascript/src/color_space_lab.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * CIELAB color space conversion and LCH-weighted color matching. - * - * Why LCH weighting for dithering: - * Error diffusion compensates for lightness errors spatially (mixing pixels), - * but CANNOT compensate for hue errors. So hue must be matched accurately on - * the first try. Weights: WL=0.5 (de-emphasized), WC=1.0, WH=2.0 (emphasized). - * - * Uses the CIE identity: da² + db² = dC² + dH² - * This decomposes a·b plane distance into chroma+hue without atan2/angle wrapping. - */ - -// sRGB → XYZ matrix (D65 illuminant) — Brucelindbloom -// Stored as scalar constants to avoid array indexing overhead in hot path -const M00 = 0.4124564, M01 = 0.3575761, M02 = 0.1804375; -const M10 = 0.2126729, M11 = 0.7151522, M12 = 0.0721750; -const M20 = 0.0193339, M21 = 0.1191920, M22 = 0.9503041; - -// D65 white point -const XN = 0.95047, YN = 1.00000, ZN = 1.08883; - -// CIE LAB transfer function constants -const EPSILON = 216.0 / 24389.0; // 0.008856... -const KAPPA = 24389.0 / 27.0; // 903.296... - -// LCH distance weights — pre-squared to eliminate squaring in inner loop -// WL=0.5 → WL²=0.25, WC=1.0 → WC²=1.0, WH=2.0 → WH²=4.0 -const WL2 = 0.25; -const WC2 = 1.0; -const WH2 = 4.0; - -function labF(t: number): number { - return t > EPSILON ? Math.cbrt(t) : (KAPPA * t + 16.0) / 116.0; -} - -/** Convert linear RGB [0,1] to CIELAB. Inlined XYZ multiply for speed. */ -function rgbToLabScalar(r: number, g: number, b: number): [number, number, number] { - const x = M00 * r + M01 * g + M02 * b; - const y = M10 * r + M11 * g + M12 * b; - const z = M20 * r + M21 * g + M22 * b; - - const fx = labF(x / XN); - const fy = labF(y / YN); - const fz = labF(z / ZN); - - return [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]; -} - -/** Pre-computed palette LAB components for the error diffusion inner loop. */ -export interface PaletteLabArrays { - L: Float64Array; - a: Float64Array; - b: Float64Array; - C: Float64Array; // chroma = sqrt(a² + b²) -} - -/** - * Pre-compute palette LAB components before the dithering loop. - * Call once per ditherImage call, then pass results to matchPixelLch. - */ -export function precomputePaletteLab( - paletteLinear: Array<[number, number, number]>, -): PaletteLabArrays { - const n = paletteLinear.length; - const L = new Float64Array(n); - const a = new Float64Array(n); - const b = new Float64Array(n); - const C = new Float64Array(n); - - for (let i = 0; i < n; i++) { - const [pl, pa, pb] = rgbToLabScalar(paletteLinear[i][0], paletteLinear[i][1], paletteLinear[i][2]); - L[i] = pl; - a[i] = pa; - b[i] = pb; - C[i] = Math.sqrt(pa * pa + pb * pb); - } - - return { L, a, b, C }; -} - -/** - * Find the closest palette color for a single pixel using LCH-weighted LAB distance. - * - * Pure scalar arithmetic — no object allocation, no array creation. - * This is the innermost hot loop; called once per pixel. - */ -export function matchPixelLch( - r: number, - g: number, - b: number, - paletteL: Float64Array, - paletteA: Float64Array, - paletteB: Float64Array, - paletteC: Float64Array, -): number { - const [pL, pa, pb] = rgbToLabScalar(r, g, b); - const pC = Math.sqrt(pa * pa + pb * pb); - - let bestIdx = 0; - let bestDist = Infinity; - const n = paletteL.length; - - for (let i = 0; i < n; i++) { - const dL = pL - paletteL[i]; - const da = pa - paletteA[i]; - const db = pb - paletteB[i]; - const dC = pC - paletteC[i]; - let dHsq = da * da + db * db - dC * dC; - if (dHsq < 0.0) dHsq = 0.0; - - const dist = WL2 * dL * dL + WC2 * dC * dC + WH2 * dHsq; - if (dist < bestDist) { - bestDist = dist; - bestIdx = i; - } - } - - return bestIdx; -} diff --git a/packages/javascript/src/core.ts b/packages/javascript/src/core.ts index 1275a0f..75c3f4a 100644 --- a/packages/javascript/src/core.ts +++ b/packages/javascript/src/core.ts @@ -1,51 +1,113 @@ import type { ImageBuffer, PaletteImageBuffer, ColorPalette } from './types'; import { DitherMode } from './enums'; -import { ColorScheme } from './palettes'; -import * as algorithms from './algorithms'; +import { ColorScheme, getPalette } from './palettes'; +import { + dither_image as wasmDitherImage, + composite_rgba as wasmCompositeRgba, + __wbg_set_wasm, + __wbindgen_init_externref_table, + __wbindgen_cast_0000000000000001, +} from './wasm-core/epaper_dithering_wasm_bg.js'; +import wasmBytes from './wasm-core/epaper_dithering_wasm_bg.wasm'; + +// Synchronous WASM initialization — runs once at module load time. +const wasmModule = new WebAssembly.Module(wasmBytes as unknown as ArrayBuffer); +const wasmInstance = new WebAssembly.Instance(wasmModule, { + './epaper_dithering_wasm_bg.js': { + __wbindgen_init_externref_table, + __wbindgen_cast_0000000000000001, + }, +}); +__wbg_set_wasm(wasmInstance.exports); +(wasmInstance.exports as Record void>).__wbindgen_start?.(); /** - * Apply dithering algorithm to image for e-paper display. - * - * @param image - Input image buffer (RGBA format). Alpha is composited on white. - * @param colorScheme - Target color scheme (ColorScheme enum) or measured palette (ColorPalette) - * @param mode - Dithering algorithm (default: BURKES) - * @param serpentine - Alternate row direction to reduce artifacts (default: true) - * @returns Palette-indexed image buffer + * Options for `ditherImage`. All fields are optional — defaults are sensible. * - * @example - * ```typescript - * // Standard color scheme - * const dithered = ditherImage(imageBuffer, ColorScheme.BWR, DitherMode.FLOYD_STEINBERG); + * Pre-processing pipeline (each step is a no-op at its identity value): + * `exposure → saturation → shadows/highlights → tone → gamut → dither`. + */ +export interface DitherOptions { + /** Dithering algorithm. Default: `DitherMode.BURKES`. */ + mode?: DitherMode; + /** Alternate row scan direction for error diffusion. Default: `true`. */ + serpentine?: boolean; + /** Linear-RGB exposure multiplier. 1.0 = no change, 2.0 = +1 stop. Default: `1.0`. */ + exposure?: number; + /** OKLab saturation multiplier. 1.0 = no change, 0.0 = grayscale. Default: `1.0`. */ + saturation?: number; + /** Shadow lift strength (S-curve lower half). 0.0 = off, 1.0 = strong. Default: `0.0`. */ + shadows?: number; + /** Highlight compression strength (S-curve upper half). 0.0 = off, 1.0 = strong. Default: `0.0`. */ + highlights?: number; + /** Dynamic-range compression: `'auto'` | `'off'` | 0.0–1.0. Default: `'auto'`. */ + tone?: number | 'auto' | 'off'; + /** Gamut compression: `'auto'` | `'off'` | 0.0–1.0. Default: `'auto'`. */ + gamut?: number | 'auto' | 'off'; +} + +/** + * Apply dithering to an RGBA image for an e-paper display. * - * // Measured palette for accurate color matching - * const dithered = ditherImage(imageBuffer, SPECTRA_7_3_6COLOR); - * ``` + * @param image Input RGBA image. Alpha is composited on white. + * @param palette Target palette: `ColorScheme` enum (idealized) or `ColorPalette` (measured). + * @param options Per-call overrides — see {@link DitherOptions}. + * @returns Palette-indexed image buffer. */ export function ditherImage( image: ImageBuffer, - colorScheme: ColorScheme | ColorPalette, - mode: DitherMode = DitherMode.BURKES, - serpentine: boolean = true, + palette: ColorScheme | ColorPalette, + options: DitherOptions = {}, ): PaletteImageBuffer { - switch (mode) { - case DitherMode.NONE: - return algorithms.directPaletteMap(image, colorScheme); - case DitherMode.ORDERED: - return algorithms.orderedDither(image, colorScheme); - case DitherMode.FLOYD_STEINBERG: - return algorithms.floydSteinbergDither(image, colorScheme, serpentine); - case DitherMode.ATKINSON: - return algorithms.atkinsonDither(image, colorScheme, serpentine); - case DitherMode.STUCKI: - return algorithms.stuckiDither(image, colorScheme, serpentine); - case DitherMode.SIERRA: - return algorithms.sierraDither(image, colorScheme, serpentine); - case DitherMode.SIERRA_LITE: - return algorithms.sierraLiteDither(image, colorScheme, serpentine); - case DitherMode.JARVIS_JUDICE_NINKE: - return algorithms.jarvisJudiceNinkeDither(image, colorScheme, serpentine); - case DitherMode.BURKES: - default: - return algorithms.burkesDither(image, colorScheme, serpentine); + const { + mode = DitherMode.BURKES, + serpentine = true, + exposure = 1.0, + saturation = 1.0, + shadows = 0.0, + highlights = 0.0, + tone = 'auto', + gamut = 'auto', + } = options; + + const rgba = new Uint8Array(image.data.buffer, image.data.byteOffset, image.data.byteLength); + const pixels = wasmCompositeRgba(rgba); + + // Idealized schemes don't have a measured display range, so tone/gamut don't apply. + const isScheme = typeof palette === 'number'; + const toneArg = isScheme ? 0.0 : parseCompression(tone); + const gamutArg = isScheme ? 0.0 : parseCompression(gamut); + + let schemeId = 0; + let paletteBytes: Uint8Array; + let accentIdx = 0; + let outputColors: { r: number; g: number; b: number }[]; + + if (isScheme) { + schemeId = palette as number; + paletteBytes = new Uint8Array(0); + outputColors = Object.values(getPalette(palette).colors); + } else { + const colors = Object.values(palette.colors); + paletteBytes = new Uint8Array(colors.flatMap(c => [c.r, c.g, c.b])); + accentIdx = Object.keys(palette.colors).indexOf(palette.accent); + outputColors = colors; } + + const indices = wasmDitherImage( + pixels, image.width, + schemeId, paletteBytes, accentIdx, + mode as number, serpentine, + exposure, saturation, shadows, highlights, + toneArg, gamutArg, + ); + + return { width: image.width, height: image.height, indices, palette: outputColors }; +} + +/** Map `'auto'` → `undefined` (Rust None = auto), `'off'` → 0.0, number → pass through. */ +function parseCompression(v: number | 'auto' | 'off'): number | undefined { + if (v === 'auto') return undefined; + if (v === 'off') return 0.0; + return v; } diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 0eee936..16166c0 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -1,4 +1,5 @@ export { ditherImage } from './core'; +export type { DitherOptions } from './core'; export { DitherMode } from './enums'; export { ColorScheme, @@ -7,6 +8,7 @@ export { fromValue, // Measured palettes SPECTRA_7_3_6COLOR, + SPECTRA_7_3_6COLOR_V2, MONO_4_26, BWRY_4_2, SOLUM_BWR, diff --git a/packages/javascript/src/palettes.ts b/packages/javascript/src/palettes.ts index d0d3c1b..1d20f21 100644 --- a/packages/javascript/src/palettes.ts +++ b/packages/javascript/src/palettes.ts @@ -133,7 +133,13 @@ export function fromValue(value: number): ColorScheme { // import { ditherImage, SPECTRA_7_3_6COLOR } from '@opendisplay/epaper-dithering'; // const result = ditherImage(imageBuffer, SPECTRA_7_3_6COLOR); // -// Color order MUST match the corresponding ColorScheme palette order. +// NOTE: RGB values are defined in packages/rust/core/src/measured_palettes.rs +// (single source of truth). The Python package derives its constants from Rust +// via FFI at import time. TypeScript cannot do the same (WASM init order), so +// values here must be kept in sync manually with the Rust source. +// The WASM `measured_palettes()` function is exposed for future tooling. +// +// TO ADD A NEW DISPLAY: update measured_palettes.rs + add the constant below. // ============================================================================= // 7.3" Spectra™ 6-color (BWGBRY scheme) @@ -202,6 +208,21 @@ export const HANSHOW_BWY: ColorPalette = { }; // 3.97" BWRY — EP397YR_800x480 (BWRY scheme) +// 7.3" Spectra™ 6-color (BWGBRY scheme) — v2 measurement +// Measured: 2026-03-15, iPhone 15 Pro Max RAW + Affinity (v3), A4 paper white reference +// Method: DNG with linear tone curve, WB from A4 paper, uniform ×2.4 scale +export const SPECTRA_7_3_6COLOR_V2: ColorPalette = { + colors: { + black: { r: 31, g: 24, b: 41 }, + white: { r: 168, g: 180, b: 182 }, + yellow: { r: 180, g: 173, b: 0 }, + red: { r: 113, g: 24, b: 19 }, + blue: { r: 36, g: 70, b: 139 }, + green: { r: 50, g: 84, b: 60 }, + }, + accent: 'red', +}; + // Measured: 2026-03-06, iPhone RAW // Paper reference RGB(205,205,205); normalization: value × (255/205) export const BWRY_3_97: ColorPalette = { diff --git a/packages/javascript/src/tone_map.ts b/packages/javascript/src/tone_map.ts deleted file mode 100644 index fdb78ce..0000000 --- a/packages/javascript/src/tone_map.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Dynamic range compression for measured e-paper palettes. - * - * Real e-paper displays reflect much less light than pure sRGB colors suggest. - * Measured white is ~185,202,205 vs pure 255,255,255 — a significant dynamic - * range reduction. Compressing the image into the display's actual range before - * dithering reduces large quantization errors at highlights and shadows. - * - * Only applied when using measured ColorPalette instances, never for ColorScheme enums. - */ - -// ITU-R BT.709 / sRGB luminance coefficients -const LUM_R = 0.2126729; -const LUM_G = 0.7151522; -const LUM_B = 0.0721750; - -/** - * Compress image dynamic range to match display capabilities. - * - * Remaps pixel luminance from [0, 1] to the display's actual [black_Y, white_Y], - * scaling RGB channels proportionally to preserve hue. - * - * @param pixels - Linear RGB flat buffer [r,g,b, r,g,b, ...], modified in-place - * @param width - Image width - * @param height - Image height - * @param paletteLinear - Palette in linear RGB: index 0 = black, index 1 = white - * @param strength - Blend factor: 0.0 = no compression, 1.0 = full compression - */ -export function compressDynamicRange( - pixels: Float32Array, - width: number, - height: number, - paletteLinear: Array<[number, number, number]>, - strength: number = 1.0, -): void { - if (strength <= 0.0) return; - - const [br, bg, bb] = paletteLinear[0]; - const [wr, wg, wb] = paletteLinear[1]; - const blackY = LUM_R * br + LUM_G * bg + LUM_B * bb; - const whiteY = LUM_R * wr + LUM_G * wg + LUM_B * wb; - const displayRange = whiteY - blackY; - if (displayRange <= 0) return; - - const n = width * height; - const blackLevel = blackY * strength; - - for (let i = 0; i < n; i++) { - const base = i * 3; - const r = pixels[base]; - const g = pixels[base + 1]; - const b = pixels[base + 2]; - const Y = LUM_R * r + LUM_G * g + LUM_B * b; - - if (Y > 1e-6) { - const compressedY = blackY + Y * displayRange; - const targetY = strength < 1.0 ? Y + strength * (compressedY - Y) : compressedY; - const scale = targetY / Y; - pixels[base] = Math.max(0, Math.min(1, r * scale)); - pixels[base + 1] = Math.max(0, Math.min(1, g * scale)); - pixels[base + 2] = Math.max(0, Math.min(1, b * scale)); - } else { - pixels[base] = blackLevel; - pixels[base + 1] = blackLevel; - pixels[base + 2] = blackLevel; - } - } -} - -/** - * Auto-levels dynamic range compression fitted to the image's actual content. - * - * Uses 2nd/98th percentile luminance to ignore outliers, maximizing contrast - * within the display's capabilities. Falls back to full compressDynamicRange - * for uniform images. - * - * @param pixels - Linear RGB flat buffer, modified in-place - * @param width - Image width - * @param height - Image height - * @param paletteLinear - Palette in linear RGB: index 0 = black, index 1 = white - */ -export function autoCompressDynamicRange( - pixels: Float32Array, - width: number, - height: number, - paletteLinear: Array<[number, number, number]>, -): void { - const [br, bg, bb] = paletteLinear[0]; - const [wr, wg, wb] = paletteLinear[1]; - const blackY = LUM_R * br + LUM_G * bg + LUM_B * bb; - const whiteY = LUM_R * wr + LUM_G * wg + LUM_B * wb; - const displayRange = whiteY - blackY; - if (displayRange <= 0) return; - - const n = width * height; - - // First pass: compute per-pixel luminance - const luminances = new Float32Array(n); - for (let i = 0; i < n; i++) { - const base = i * 3; - luminances[i] = LUM_R * pixels[base] + LUM_G * pixels[base + 1] + LUM_B * pixels[base + 2]; - } - - // Sort to find percentiles - luminances.sort(); - const pLow = luminances[Math.floor(n * 0.02)]; - const pHigh = luminances[Math.floor(n * 0.98)]; - const imageRange = pHigh - pLow; - - if (imageRange < 1e-6) { - // Uniform image: fall back to standard linear compression - compressDynamicRange(pixels, width, height, paletteLinear, 1.0); - return; - } - - // Second pass: remap [pLow, pHigh] → [blackY, whiteY] - for (let i = 0; i < n; i++) { - const base = i * 3; - const r = pixels[base]; - const g = pixels[base + 1]; - const b = pixels[base + 2]; - const Y = LUM_R * r + LUM_G * g + LUM_B * b; - - if (Y > 1e-6) { - const normalizedY = (Y - pLow) / imageRange; - const targetY = blackY + normalizedY * displayRange; - const scale = targetY / Y; - pixels[base] = Math.max(0, Math.min(1, r * scale)); - pixels[base + 1] = Math.max(0, Math.min(1, g * scale)); - pixels[base + 2] = Math.max(0, Math.min(1, b * scale)); - } else { - pixels[base] = blackY; - pixels[base + 1] = blackY; - pixels[base + 2] = blackY; - } - } -} diff --git a/packages/javascript/src/wasm-core/.gitignore b/packages/javascript/src/wasm-core/.gitignore new file mode 100644 index 0000000..72e8ffc --- /dev/null +++ b/packages/javascript/src/wasm-core/.gitignore @@ -0,0 +1 @@ +* diff --git a/packages/javascript/src/wasm.d.ts b/packages/javascript/src/wasm.d.ts new file mode 100644 index 0000000..ddf5a14 --- /dev/null +++ b/packages/javascript/src/wasm.d.ts @@ -0,0 +1,25 @@ +/** esbuild binary loader inlines .wasm files as Uint8Array */ +declare module '*.wasm' { + const bytes: Uint8Array; + export default bytes; +} + +/** + * Hand-written types for the wasm-bindgen-generated `_bg.js` shim. wasm-pack only + * emits `.d.ts` for the main module; the `_bg.js` exports we need for synchronous + * initialization are declared here. + */ +declare module '*epaper_dithering_wasm_bg.js' { + export function composite_rgba(rgba: Uint8Array): Uint8Array; + export function dither_image( + pixels: Uint8Array, width: number, + scheme_id: number, palette_bytes: Uint8Array, accent_idx: number, + mode_id: number, serpentine: boolean, + exposure: number, saturation: number, shadows: number, highlights: number, + tone?: number, gamut?: number, + ): Uint8Array; + export function measured_palettes(): string; + export function __wbg_set_wasm(wasm: unknown): void; + export function __wbindgen_init_externref_table(): void; + export function __wbindgen_cast_0000000000000001(arg0: number, arg1: number): unknown; +} diff --git a/packages/javascript/tests/color_space.test.ts b/packages/javascript/tests/color_space.test.ts deleted file mode 100644 index 7ddc060..0000000 --- a/packages/javascript/tests/color_space.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { SRGB_TO_LINEAR_LUT, linearToSrgbByte } from '../src/color_space'; - -describe('SRGB_TO_LINEAR_LUT', () => { - it('black stays black', () => { - expect(SRGB_TO_LINEAR_LUT[0]).toBe(0.0); - }); - - it('white stays white', () => { - expect(SRGB_TO_LINEAR_LUT[255]).toBeCloseTo(1.0, 10); - }); - - it('128 is approximately 0.2158 (nonlinear midpoint)', () => { - expect(SRGB_TO_LINEAR_LUT[128]).toBeCloseTo(0.2158, 3); - }); - - it('is monotonically non-decreasing', () => { - for (let i = 1; i < 256; i++) { - expect(SRGB_TO_LINEAR_LUT[i]).toBeGreaterThanOrEqual(SRGB_TO_LINEAR_LUT[i - 1]); - } - }); - - it('has 256 entries', () => { - expect(SRGB_TO_LINEAR_LUT.length).toBe(256); - }); -}); - -describe('linearToSrgbByte', () => { - it('0.0 → 0', () => { - expect(linearToSrgbByte(0.0)).toBe(0); - }); - - it('1.0 → 255', () => { - expect(linearToSrgbByte(1.0)).toBe(255); - }); - - it('0.5 → ~188', () => { - const v = linearToSrgbByte(0.5); - expect(v).toBeGreaterThanOrEqual(185); - expect(v).toBeLessThanOrEqual(189); - }); - - it('roundtrip: linearToSrgbByte(LUT[i]) ≈ i for all 0–255', () => { - for (let i = 0; i < 256; i++) { - const roundtripped = linearToSrgbByte(SRGB_TO_LINEAR_LUT[i]); - expect(Math.abs(roundtripped - i)).toBeLessThanOrEqual(1); - } - }); -}); diff --git a/packages/javascript/tests/color_space_lab.test.ts b/packages/javascript/tests/color_space_lab.test.ts deleted file mode 100644 index 6f926ad..0000000 --- a/packages/javascript/tests/color_space_lab.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { precomputePaletteLab, matchPixelLch } from '../src/color_space_lab'; -import { SRGB_TO_LINEAR_LUT } from '../src/color_space'; - -function srgbToLinear(r: number, g: number, b: number): [number, number, number] { - return [SRGB_TO_LINEAR_LUT[r], SRGB_TO_LINEAR_LUT[g], SRGB_TO_LINEAR_LUT[b]]; -} - -describe('precomputePaletteLab', () => { - it('returns arrays of correct length', () => { - const palette: Array<[number, number, number]> = [ - [0, 0, 0], [1, 1, 1], [1, 0, 0], - ]; - const result = precomputePaletteLab(palette); - expect(result.L.length).toBe(3); - expect(result.a.length).toBe(3); - expect(result.b.length).toBe(3); - expect(result.C.length).toBe(3); - }); - - it('black has L≈0', () => { - const result = precomputePaletteLab([[0, 0, 0]]); - expect(result.L[0]).toBeCloseTo(0, 1); - }); - - it('white has L≈100', () => { - const result = precomputePaletteLab([[1, 1, 1]]); - expect(result.L[0]).toBeCloseTo(100, 1); - }); - - it('chroma is non-negative', () => { - const palette: Array<[number, number, number]> = [ - [0, 0, 0], [1, 1, 1], [1, 0, 0], [0, 1, 0], [0, 0, 1], - ]; - const result = precomputePaletteLab(palette); - for (let i = 0; i < palette.length; i++) { - expect(result.C[i]).toBeGreaterThanOrEqual(0); - } - }); -}); - -describe('matchPixelLch', () => { - const bwrPaletteLinear: Array<[number, number, number]> = [ - [0, 0, 0], // black - [1, 1, 1], // white - [1, 0, 0], // red (linear) - ]; - - it('pure black maps to black index', () => { - const { L, a, b, C } = precomputePaletteLab(bwrPaletteLinear); - expect(matchPixelLch(0, 0, 0, L, a, b, C)).toBe(0); - }); - - it('pure white maps to white index', () => { - const { L, a, b, C } = precomputePaletteLab(bwrPaletteLinear); - expect(matchPixelLch(1, 1, 1, L, a, b, C)).toBe(1); - }); - - it('pure red maps to red index', () => { - const { L, a, b, C } = precomputePaletteLab(bwrPaletteLinear); - expect(matchPixelLch(1, 0, 0, L, a, b, C)).toBe(2); - }); - - it('green input on BWR palette maps to black or white — not red (hue preservation)', () => { - const { L, a, b, C } = precomputePaletteLab(bwrPaletteLinear); - // Green in linear: sRGB(0, 200, 0) - const [r, g, bl] = srgbToLinear(0, 200, 0); - const idx = matchPixelLch(r, g, bl, L, a, b, C); - // Must NOT match red (index 2) — green hue is closer to black/white than red - expect(idx).not.toBe(2); - }); - - it('green input on SPECTRA_7_3_6COLOR maps to green index (critical LCH regression)', () => { - // SPECTRA palette: black=0, white=1, yellow=2, red=3, blue=4, green=5 - const spectraLinear: Array<[number, number, number]> = [ - srgbToLinear(26, 13, 35 ), // 0: black - srgbToLinear(185, 202, 205), // 1: white - srgbToLinear(202, 184, 0 ), // 2: yellow - srgbToLinear(121, 9, 0 ), // 3: red - srgbToLinear(0, 69, 139), // 4: blue - srgbToLinear(40, 82, 57 ), // 5: green - ]; - const { L, a, b, C } = precomputePaletteLab(spectraLinear); - - // Bright saturated green — should map to green (index 5), not yellow (index 2) - const [r, g, bl] = srgbToLinear(100, 255, 40); - const idx = matchPixelLch(r, g, bl, L, a, b, C); - expect(idx).toBe(5); - expect(idx).not.toBe(2); // not yellow - }); -}); diff --git a/packages/javascript/tests/dithering.test.ts b/packages/javascript/tests/dithering.test.ts index 483aa8a..7f0381f 100644 --- a/packages/javascript/tests/dithering.test.ts +++ b/packages/javascript/tests/dithering.test.ts @@ -28,7 +28,7 @@ describe('Dithering Algorithms', () => { 'works with color scheme %s', (scheme) => { const image = createTestImage(10, 10, { r: 128, g: 128, b: 128 }); - const result = ditherImage(image, scheme as ColorScheme, DitherMode.BURKES); + const result = ditherImage(image, scheme as ColorScheme, { mode: DitherMode.BURKES }); expect(result.palette.length).toBeGreaterThan(0); } @@ -36,7 +36,7 @@ describe('Dithering Algorithms', () => { it('handles RGBA input correctly', () => { const image = createTestImage(10, 10, { r: 128, g: 128, b: 128 }); - const result = ditherImage(image, ColorScheme.BWR, DitherMode.BURKES); + const result = ditherImage(image, ColorScheme.BWR, { mode: DitherMode.BURKES }); expect(result).toBeDefined(); expect(result.width).toBe(10); @@ -46,8 +46,8 @@ describe('Dithering Algorithms', () => { it('produces different output for different algorithms', () => { const image = createGradient(100, 100); - const burkes = ditherImage(image, ColorScheme.MONO, DitherMode.BURKES); - const floydSteinberg = ditherImage(image, ColorScheme.MONO, DitherMode.FLOYD_STEINBERG); + const burkes = ditherImage(image, ColorScheme.MONO, { mode: DitherMode.BURKES }); + const floydSteinberg = ditherImage(image, ColorScheme.MONO, { mode: DitherMode.FLOYD_STEINBERG }); let differences = 0; for (let i = 0; i < burkes.indices.length; i++) { @@ -61,7 +61,7 @@ describe('Dithering Algorithms', () => { const image = createTestImage(10, 10, { r: 128, g: 128, b: 128 }); const withDefault = ditherImage(image, ColorScheme.BWR); - const withBurkes = ditherImage(image, ColorScheme.BWR, DitherMode.BURKES); + const withBurkes = ditherImage(image, ColorScheme.BWR, { mode: DitherMode.BURKES }); expect(withDefault.indices).toEqual(withBurkes.indices); }); @@ -69,8 +69,8 @@ describe('Dithering Algorithms', () => { it('serpentine=true and serpentine=false produce different results on gradient', () => { const image = createGradient(50, 50); - const withSerpentine = ditherImage(image, ColorScheme.MONO, DitherMode.FLOYD_STEINBERG, true); - const withoutSerpentine = ditherImage(image, ColorScheme.MONO, DitherMode.FLOYD_STEINBERG, false); + const withSerpentine = ditherImage(image, ColorScheme.MONO, { mode: DitherMode.FLOYD_STEINBERG, serpentine: true }); + const withoutSerpentine = ditherImage(image, ColorScheme.MONO, { mode: DitherMode.FLOYD_STEINBERG, serpentine: false }); let differences = 0; for (let i = 0; i < withSerpentine.indices.length; i++) { @@ -82,7 +82,7 @@ describe('Dithering Algorithms', () => { it('alpha compositing: fully transparent red is treated as white', () => { // Fully transparent red (alpha=0) should composite to white const image = createTransparentTestImage(4, 4, { r: 255, g: 0, b: 0 }, 0); - const result = ditherImage(image, ColorScheme.MONO, DitherMode.NONE); + const result = ditherImage(image, ColorScheme.MONO, { mode: DitherMode.NONE }); // All pixels should be white (index 1 in MONO: black=0, white=1) for (let i = 0; i < result.indices.length; i++) { @@ -96,8 +96,8 @@ describe('Dithering Algorithms', () => { // Very low alpha black → composites nearly to white → maps to white (index 1) const nearlyTransparent = createTransparentTestImage(4, 4, { r: 0, g: 0, b: 0 }, 10); - const resultOpaque = ditherImage(opaque, ColorScheme.MONO, DitherMode.NONE); - const resultNearly = ditherImage(nearlyTransparent, ColorScheme.MONO, DitherMode.NONE); + const resultOpaque = ditherImage(opaque, ColorScheme.MONO, { mode: DitherMode.NONE }); + const resultNearly = ditherImage(nearlyTransparent, ColorScheme.MONO, { mode: DitherMode.NONE }); expect(resultOpaque.indices[0]).toBe(0); // black expect(resultNearly.indices[0]).toBe(1); // white @@ -113,7 +113,7 @@ describe('Dithering Algorithms', () => { it('measured palette palette indices are within range', () => { const image = createGradient(20, 20); - const result = ditherImage(image, SPECTRA_7_3_6COLOR, DitherMode.FLOYD_STEINBERG); + const result = ditherImage(image, SPECTRA_7_3_6COLOR, { mode: DitherMode.FLOYD_STEINBERG }); for (let i = 0; i < result.indices.length; i++) { expect(result.indices[i]).toBeGreaterThanOrEqual(0); diff --git a/packages/javascript/tests/tone_map.test.ts b/packages/javascript/tests/tone_map.test.ts deleted file mode 100644 index 3cfc844..0000000 --- a/packages/javascript/tests/tone_map.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { compressDynamicRange, autoCompressDynamicRange } from '../src/tone_map'; - -const LUM_R = 0.2126729; -const LUM_G = 0.7151522; -const LUM_B = 0.0721750; - -function luminance(r: number, g: number, b: number): number { - return LUM_R * r + LUM_G * g + LUM_B * b; -} - -// Measured SPECTRA palette in linear (approximate) -const SPECTRA_LINEAR: Array<[number, number, number]> = [ - [0.008, 0.002, 0.017], // black - [0.498, 0.593, 0.617], // white -]; - -describe('compressDynamicRange', () => { - it('strength=0 leaves buffer unchanged', () => { - const pixels = new Float32Array([0.5, 0.5, 0.5, 0.2, 0.8, 0.1]); - const original = new Float32Array(pixels); - compressDynamicRange(pixels, 2, 1, SPECTRA_LINEAR, 0.0); - for (let i = 0; i < pixels.length; i++) { - expect(pixels[i]).toBeCloseTo(original[i], 10); - } - }); - - it('output luminance stays within [black_Y, white_Y]', () => { - const blackY = luminance(...SPECTRA_LINEAR[0]); - const whiteY = luminance(...SPECTRA_LINEAR[1]); - - const pixels = new Float32Array(3 * 9); - // Cover full range: dark, mid, bright - for (let i = 0; i < 3; i++) { - const v = i * 0.4; - pixels[i * 3] = v; - pixels[i * 3 + 1] = v; - pixels[i * 3 + 2] = v; - } - - compressDynamicRange(pixels, 3, 1, SPECTRA_LINEAR, 1.0); - - for (let i = 0; i < 3; i++) { - const base = i * 3; - const Y = luminance(pixels[base], pixels[base + 1], pixels[base + 2]); - expect(Y).toBeGreaterThanOrEqual(blackY - 1e-5); - expect(Y).toBeLessThanOrEqual(whiteY + 1e-5); - } - }); - - it('pure black/white palette leaves full-range images essentially unchanged', () => { - const pureLinear: Array<[number, number, number]> = [[0, 0, 0], [1, 1, 1]]; - const pixels = new Float32Array([0.3, 0.3, 0.3]); - const before = pixels[0]; - compressDynamicRange(pixels, 1, 1, pureLinear, 1.0); - expect(pixels[0]).toBeCloseTo(before, 5); - }); -}); - -describe('autoCompressDynamicRange', () => { - it('does not throw on uniform image', () => { - const pixels = new Float32Array(3 * 4).fill(0.5); - expect(() => autoCompressDynamicRange(pixels, 2, 2, SPECTRA_LINEAR)).not.toThrow(); - }); - - it('output values are in [0, 1]', () => { - const pixels = new Float32Array(3 * 100); - for (let i = 0; i < 100; i++) { - const v = i / 99; - pixels[i * 3] = v; - pixels[i * 3 + 1] = v; - pixels[i * 3 + 2] = v; - } - autoCompressDynamicRange(pixels, 100, 1, SPECTRA_LINEAR); - for (let i = 0; i < pixels.length; i++) { - expect(pixels[i]).toBeGreaterThanOrEqual(-1e-5); - expect(pixels[i]).toBeLessThanOrEqual(1 + 1e-5); - } - }); - - it('narrow-range image produces wider luminance spread than input', () => { - // Image only uses midtones [0.4, 0.6] - const n = 50; - const pixels = new Float32Array(n * 3); - for (let i = 0; i < n; i++) { - const v = 0.4 + (i / (n - 1)) * 0.2; - pixels[i * 3] = v; - pixels[i * 3 + 1] = v; - pixels[i * 3 + 2] = v; - } - const blackY = luminance(...SPECTRA_LINEAR[0]); - const whiteY = luminance(...SPECTRA_LINEAR[1]); - - autoCompressDynamicRange(pixels, n, 1, SPECTRA_LINEAR); - - const outMin = Math.min(...Array.from({ length: n }, (_, i) => - luminance(pixels[i * 3], pixels[i * 3 + 1], pixels[i * 3 + 2]) - )); - const outMax = Math.max(...Array.from({ length: n }, (_, i) => - luminance(pixels[i * 3], pixels[i * 3 + 1], pixels[i * 3 + 2]) - )); - - // Auto mode should stretch midtones to fill [blackY, whiteY] - expect(outMin).toBeCloseTo(blackY, 2); - expect(outMax).toBeCloseTo(whiteY, 2); - }); -}); diff --git a/packages/javascript/tsconfig.json b/packages/javascript/tsconfig.json index f7b9663..ce24cdb 100644 --- a/packages/javascript/tsconfig.json +++ b/packages/javascript/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2022", "module": "ESNext", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "moduleResolution": "bundler", "declaration": true, "declarationMap": true, diff --git a/packages/javascript/tsup.config.ts b/packages/javascript/tsup.config.ts index 4bf1796..2180551 100644 --- a/packages/javascript/tsup.config.ts +++ b/packages/javascript/tsup.config.ts @@ -11,4 +11,5 @@ export default defineConfig({ treeshake: true, target: 'es2022', platform: 'neutral', // Works in both browser and Node.js + loader: { '.wasm': 'binary' }, // Inline WASM binary as Uint8Array }); \ No newline at end of file diff --git a/packages/javascript/vitest.config.ts b/packages/javascript/vitest.config.ts index e1b9a33..02a862e 100644 --- a/packages/javascript/vitest.config.ts +++ b/packages/javascript/vitest.config.ts @@ -1,6 +1,19 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig, type Plugin } from 'vitest/config'; +import fs from 'fs'; + +/** Inline .wasm files as Uint8Array — mirrors esbuild binary loader used by tsup. */ +const wasmBinaryPlugin: Plugin = { + name: 'wasm-binary', + enforce: 'pre', // Must run before Vite's built-in WASM ESM handler + load(id) { + if (!id.endsWith('.wasm')) return null; + const bytes = Array.from(fs.readFileSync(id)); + return { code: `export default new Uint8Array([${bytes.join(',')}]);`, map: null }; + }, +}; export default defineConfig({ + plugins: [wasmBinaryPlugin], test: { globals: true, environment: 'node', @@ -10,4 +23,4 @@ export default defineConfig({ include: ['src/**/*.ts'], }, }, -}); \ No newline at end of file +}); diff --git a/packages/python/Cargo.lock b/packages/python/Cargo.lock new file mode 100644 index 0000000..fe1f360 --- /dev/null +++ b/packages/python/Cargo.lock @@ -0,0 +1,192 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "epaper-dithering" +version = "3.0.0" +dependencies = [ + "epaper-dithering-core", + "pyo3", +] + +[[package]] +name = "epaper-dithering-core" +version = "3.0.0" +dependencies = [ + "rayon", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf85e27e86080aafd5a22eae58a162e133a589551542b3e5cee4beb27e54f8e1" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf94ee265674bf76c09fa430b0e99c26e319c945d96ca0d5a8215f31bf81cf7" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "491aa5fc66d8059dd44a75f4580a2962c1862a1c2945359db36f6c2818b748dc" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d671734e9d7a43449f8480f8b38115df67bef8d21f76837fa75ee7aaa5e52e" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22faaa1ce6c430a1f71658760497291065e6450d7b5dc2bcf254d49f66ee700a" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/packages/python/Cargo.toml b/packages/python/Cargo.toml new file mode 100644 index 0000000..7d826c6 --- /dev/null +++ b/packages/python/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "epaper-dithering" +version = "3.0.0" +edition = "2024" + +[lib] +name = "epaper_dithering_rs" +crate-type = ["cdylib"] + +[dependencies] +epaper-dithering-core = { path = "../rust/core" } +pyo3 = { version = "0.28.2", features = ["extension-module"] } diff --git a/packages/python/README.md b/packages/python/README.md index 6b8a006..beb5db7 100644 --- a/packages/python/README.md +++ b/packages/python/README.md @@ -22,10 +22,11 @@ pip install epaper-dithering ## Features -- **Perceptually Correct**: Uses linear RGB color space with gamma correction for accurate error diffusion -- **8 Dithering Algorithms**: From simple ordered dithering to high-quality Jarvis-Judice-Ninke +- **Rust Core**: All dithering runs in a compiled Rust extension — fast enough for 800×480 images in ~30ms +- **Perceptually Correct**: Weighted Cartesian OKLab color matching — preserves hue without the achromatic-attractor bug that plagues LCH-weighted approaches +- **9 Dithering Algorithms**: From simple ordered dithering to high-quality Jarvis-Judice-Ninke - **8 Color Schemes**: Support for mono, 3-color, 4-color, 6-color, and grayscale e-paper displays -- **Tone Mapping**: Dynamic range compression maps image luminance to the display's actual range for smoother dithering +- **Pre-dither Adjustments**: Per-image exposure, saturation, shadows, highlights, dynamic-range compression, and gamut compression — all orthogonal knobs you can mix freely - **Serpentine Scanning**: Reduces directional artifacts in error diffusion (enabled by default) - **RGBA Support**: Automatic compositing on white background for transparent images @@ -39,12 +40,29 @@ from epaper_dithering import dither_image, ColorScheme, DitherMode image = Image.open("photo.jpg") # Apply dithering for a black/white/red display -dithered = dither_image(image, ColorScheme.BWR, DitherMode.FLOYD_STEINBERG) +dithered = dither_image(image, ColorScheme.BWR, mode=DitherMode.FLOYD_STEINBERG) # Save result dithered.save("output.png") ``` +All arguments after `color_scheme` are keyword-only: + +```python +dither_image( + image, palette, + *, + mode=DitherMode.BURKES, # algorithm + serpentine=True, # alternate row scan direction + exposure=1.0, # linear-RGB multiplier (1.0 = no change) + saturation=1.0, # OKLab saturation (1.0 = no change, 0.0 = grayscale) + shadows=0.0, # shadow lift, S-curve lower half + highlights=0.0, # highlight compression, S-curve upper half + tone="auto", # dynamic-range compression: "auto" | 0.0–1.0 + gamut="auto", # gamut compression: "auto" | 0.0–1.0 +) +``` + ## Supported Color Schemes - **MONO** - Black and white (1-bit) @@ -78,19 +96,15 @@ dithered.save("output.png") from PIL import Image from epaper_dithering import dither_image, ColorScheme, DitherMode -# Load image img = Image.open("photo.jpg") -# Apply Floyd-Steinberg dithering for BWR display -result = dither_image(img, ColorScheme.BWR, DitherMode.FLOYD_STEINBERG) +result = dither_image(img, ColorScheme.BWR, mode=DitherMode.FLOYD_STEINBERG) result.save("dithered.png") ``` ### All Color Schemes ```python -from epaper_dithering import ColorScheme - # Black and white only dithered = dither_image(img, ColorScheme.MONO) @@ -112,10 +126,10 @@ By default, error diffusion algorithms use serpentine scanning (alternating scan ```python # Default: serpentine scanning (recommended for best quality) -result = dither_image(img, ColorScheme.BWR, DitherMode.FLOYD_STEINBERG, serpentine=True) +result = dither_image(img, ColorScheme.BWR, mode=DitherMode.FLOYD_STEINBERG, serpentine=True) # Disable serpentine for raster scanning (left-to-right only) -result = dither_image(img, ColorScheme.BWR, DitherMode.FLOYD_STEINBERG, serpentine=False) +result = dither_image(img, ColorScheme.BWR, mode=DitherMode.FLOYD_STEINBERG, serpentine=False) ``` Note: The `serpentine` parameter only affects error diffusion algorithms (Floyd-Steinberg, Burkes, Atkinson, Sierra, Sierra Lite, Stucki, Jarvis-Judice-Ninke). It has no effect on NONE and ORDERED modes. @@ -124,7 +138,7 @@ Note: The `serpentine` parameter only affects error diffusion algorithms (Floyd- E-paper displays can't reproduce the full luminance range of digital images. Pure white on a display is much darker than (255, 255, 255), and pure black is lighter than (0, 0, 0). Without tone compression, dithering tries to represent unreachable brightness levels, causing large accumulated errors and noisy output. -Tone compression remaps image luminance to the display's actual range before dithering. Based on [`fast_compress_dynamic_range()`](https://github.com/aitjcize/esp32-photoframe) from esp32-photoframe by aitjcize. It is enabled by default (`tone_compression="auto"`) and only applies when using measured `ColorPalette` instances: +Tone compression remaps image luminance to the display's actual range before dithering. Based on [`fast_compress_dynamic_range()`](https://github.com/aitjcize/esp32-photoframe) from esp32-photoframe by aitjcize. It is enabled by default (`tone="auto"`) and only applies when using measured `ColorPalette` instances: - **`"auto"`** (default): Analyzes the image histogram and remaps its actual luminance range to the display range. Maximizes contrast by stretching only the used range. - **`0.0-1.0`**: Fixed linear compression strength. `1.0` maps the full [0,1] range to the display range. `0.0` disables compression. @@ -133,16 +147,54 @@ Tone compression remaps image luminance to the display's actual range before dit from epaper_dithering import dither_image, SPECTRA_7_3_6COLOR, DitherMode # Default: auto tone compression (recommended) -result = dither_image(img, SPECTRA_7_3_6COLOR, DitherMode.FLOYD_STEINBERG) +result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.FLOYD_STEINBERG) # Fixed linear compression -result = dither_image(img, SPECTRA_7_3_6COLOR, DitherMode.FLOYD_STEINBERG, tone_compression=1.0) +result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.FLOYD_STEINBERG, tone=1.0) # Disable tone compression -result = dither_image(img, SPECTRA_7_3_6COLOR, DitherMode.FLOYD_STEINBERG, tone_compression=0.0) +result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.FLOYD_STEINBERG, tone=0.0) +``` + +Note: `tone` has no effect when using theoretical `ColorScheme` palettes (e.g., `ColorScheme.BWR`), since their black/white values already span the full range. + +#### Gamut Compression + +Some images contain highly saturated colors that a limited palette simply cannot reproduce (e.g. vivid purple on a BWGBRY display). Without gamut compression, the ditherer tries to mix palette colors to approximate the hue — often producing muddy results. Gamut compression pre-blends out-of-gamut pixels toward the nearest palette color before dithering, giving error diffusion a better starting point. + +```python +# Default: auto gamut compression (activates only when image exceeds palette gamut) +result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.BURKES) + +# Fixed strength (0.7–0.9 recommended for very saturated images) +result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.BURKES, gamut=0.8) + +# Disable +result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.BURKES, gamut=0.0) +``` + +Note: `gamut` also has no effect for theoretical `ColorScheme` palettes. + +#### Per-Image Tonal Adjustments + +`exposure`, `saturation`, `shadows`, and `highlights` let you tweak the image *before* tone/gamut compression. Each is independent — set just the ones you want. All default to identity (no effect). + +```python +# Brighten and boost saturation for vivid output +result = dither_image(img, SPECTRA_7_3_6COLOR, exposure=1.3, saturation=1.4) + +# Lift shadows on a dark image +result = dither_image(img, SPECTRA_7_3_6COLOR, shadows=0.5) + +# Compress highlights on an overexposed image +result = dither_image(img, SPECTRA_7_3_6COLOR, highlights=0.7) + +# Combine for a "vivid photo" look +result = dither_image(img, SPECTRA_7_3_6COLOR, + exposure=1.1, saturation=1.3, shadows=0.3, highlights=0.5) ``` -Note: `tone_compression` has no effect when using theoretical `ColorScheme` palettes (e.g., `ColorScheme.BWR`), since their black/white values already span the full range. +Pipeline order: `exposure → saturation → shadows/highlights → tone → gamut → dither`. #### RGBA Images @@ -175,18 +227,20 @@ The library includes measured palettes for common displays: from epaper_dithering import dither_image, SPECTRA_7_3_6COLOR, DitherMode # Use measured palette for Spectra 7.3" 6-color display -result = dither_image(img, SPECTRA_7_3_6COLOR, DitherMode.FLOYD_STEINBERG) +result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.FLOYD_STEINBERG) ``` **Available measured palettes:** -- `SPECTRA_7_3_6COLOR` - 7.3" Spectra™ 6-color (BWGBRY) +- `SPECTRA_7_3_6COLOR` - 7.3" Spectra™ 6-color (BWGBRY), v1 measurement +- `SPECTRA_7_3_6COLOR_V2` - 7.3" Spectra™ 6-color (BWGBRY), v2 measurement (recommended) - `MONO_4_26` - 4.26" Monochrome - `BWRY_4_2` - 4.2" BWRY +- `BWRY_3_97` - 3.97" BWRY - `SOLUM_BWR` - Solum BWR - `HANSHOW_BWR` - Hanshow BWR - `HANSHOW_BWY` - Hanshow BWY -**Note**: Pre-defined palettes start with theoretical values. See [CALIBRATION.md](docs/CALIBRATION.md) for measuring your specific display. +See [CALIBRATION.md](docs/CALIBRATION.md) for measuring your specific display. ### Creating Custom Measured Palettes @@ -206,7 +260,7 @@ my_display = ColorPalette( ) # Use it directly -result = dither_image(img, my_display, DitherMode.FLOYD_STEINBERG) +result = dither_image(img, my_display, mode=DitherMode.FLOYD_STEINBERG) ``` ### Measurement Quick Start @@ -222,9 +276,12 @@ See [docs/CALIBRATION.md](docs/CALIBRATION.md) for detailed measurement procedur ## Development ```bash -# Install with dev dependencies +# Install dependencies (requires Rust toolchain: https://rustup.rs) uv sync --all-extras +# Build and install the Rust extension (required before running tests) +uv run maturin develop --release + # Run tests uv run pytest tests/ -v diff --git a/packages/python/pyproject.toml b/packages/python/pyproject.toml index 4cc8aa8..dbf68cd 100644 --- a/packages/python/pyproject.toml +++ b/packages/python/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" [project] name = "epaper-dithering" -version = "0.6.4" +version = "3.0.0" description = "Dithering algorithms for e-paper/e-ink displays" readme = "README.md" license = "MIT" @@ -28,17 +28,18 @@ classifiers = [ dependencies = [ "pillow>=10.0.0", - "numpy>=1.24.0", ] [project.optional-dependencies] test = [ "pytest>=9.0.2", - "pytest-cov>=7.0.0" + "pytest-cov>=7.0.0", + "numpy>=1.24.0", ] dev = [ + "maturin>=1.0,<2.0", "mypy>=1.19.1", - "ruff>=0.14.10" + "ruff>=0.14.10", ] [project.urls] @@ -46,8 +47,10 @@ Homepage = "https://opendisplay.org" Repository = "https://github.com/OpenDisplay-org/epaper-dithering" Documentation = "https://github.com/OpenDisplay-org/epaper-dithering#readme" -[tool.hatch.build.targets.wheel] -packages = ["src/epaper_dithering"] +[tool.maturin] +module-name = "epaper_dithering._rs" +python-source = "src" +features = ["pyo3/extension-module"] [tool.ruff] line-length = 120 @@ -63,6 +66,9 @@ warn_return_any = true warn_unused_configs = true +[tool.pylint.main] +ignore-patterns = [".*\\.pyi"] + [tool.pylint.format] max-line-length = 120 @@ -83,12 +89,11 @@ max-statements = 60 [tool.pylint.messages_control] disable = [ "fixme", # TODO comments are fine - "duplicate-code", # algorithmic variants share structure by design - "consider-using-enumerate", # numpy indexing patterns don't benefit from enumerate - "consider-using-max-builtin", # explicit comparison is clearer for float edge cases "import-outside-toplevel", # pytest lazy imports in test methods "too-few-public-methods", # single-test test classes are fine "too-many-function-args", # false positives from pylint path resolution in monorepo + "consider-using-from-import", # import epaper_dithering._rs as _rs is intentional (mypy submodule resolution) + "c-extension-no-member", # compiled extension members are not visible to pylint static analysis ] [tool.pytest.ini_options] diff --git a/packages/python/src/epaper_dithering/__init__.py b/packages/python/src/epaper_dithering/__init__.py index d875fed..37bbd4c 100644 --- a/packages/python/src/epaper_dithering/__init__.py +++ b/packages/python/src/epaper_dithering/__init__.py @@ -19,7 +19,7 @@ ColorScheme, ) -__version__ = "0.6.4" +__version__ = "3.0.0" __all__ = [ "dither_image", diff --git a/packages/python/src/epaper_dithering/_rs.pyi b/packages/python/src/epaper_dithering/_rs.pyi new file mode 100644 index 0000000..c1ef419 --- /dev/null +++ b/packages/python/src/epaper_dithering/_rs.pyi @@ -0,0 +1,18 @@ +def dither_image( + pixels: bytes, + width: int, + height: int, + *, + scheme_id: int | None = ..., + palette_bytes: bytes | None = ..., + accent_idx: int = ..., + mode_id: int = ..., + serpentine: bool = ..., + exposure: float = ..., + saturation: float = ..., + shadows: float = ..., + highlights: float = ..., + tone: float | None = ..., + gamut: float | None = ..., +) -> bytes: ... +def measured_palettes() -> list[tuple[str, list[int], list[str], int]]: ... diff --git a/packages/python/src/epaper_dithering/algorithms.py b/packages/python/src/epaper_dithering/algorithms.py deleted file mode 100644 index 7c5930c..0000000 --- a/packages/python/src/epaper_dithering/algorithms.py +++ /dev/null @@ -1,676 +0,0 @@ -"""Dithering algorithm implementations for e-paper displays.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np -from PIL import Image - -from .color_space import srgb_to_linear -from .color_space_lab import ( - _match_pixel_lch, - find_closest_palette_color_lab, - precompute_palette_lab, -) -from .palettes import ColorPalette, ColorScheme -from .tone_map import auto_compress_dynamic_range, auto_gamut_compress, compress_dynamic_range, gamut_compress - - -@dataclass(frozen=True) -class ErrorDiffusionKernel: - """Error diffusion kernel specification. - - Defines how quantization error is distributed to neighboring pixels. - Each kernel is characterized by a name, divisor, and list of offset weights. - - Attributes: - name: Human-readable kernel name - divisor: Normalization divisor for weights - offsets: List of (dx, dy, weight) tuples where: - - dx: horizontal offset (positive = right) - - dy: vertical offset (positive = down) - - weight: error weight (will be divided by divisor) - Note: (0, 0) is current pixel (already processed) - """ - - name: str - divisor: float - offsets: list[tuple[int, int, float]] - - -# Error diffusion kernel definitions -# Each kernel represents a different error distribution strategy - -FLOYD_STEINBERG = ErrorDiffusionKernel( - name="Floyd-Steinberg", - divisor=16, - offsets=[ - (1, 0, 7), # Right: 7/16 - (-1, 1, 3), # Down-left: 3/16 - (0, 1, 5), # Down: 5/16 - (1, 1, 1), # Down-right: 1/16 - ], -) - -BURKES = ErrorDiffusionKernel( - name="Burkes", - divisor=32, - offsets=[ - (1, 0, 8), - (2, 0, 4), # Current row - (-2, 1, 2), - (-1, 1, 4), - (0, 1, 8), - (1, 1, 4), - (2, 1, 2), # Next row - ], -) - -SIERRA = ErrorDiffusionKernel( - name="Sierra", - divisor=32, - offsets=[ - (1, 0, 5), - (2, 0, 3), # Current row - (-2, 1, 2), - (-1, 1, 4), - (0, 1, 5), - (1, 1, 4), - (2, 1, 2), # Row +1 - (-1, 2, 2), - (0, 2, 3), - (1, 2, 2), # Row +2 - ], -) - -SIERRA_LITE = ErrorDiffusionKernel( - name="Sierra Lite", - divisor=4, - offsets=[ - (1, 0, 2), # Right: 2/4 - (-1, 1, 1), # Down-left: 1/4 - (0, 1, 1), # Down: 1/4 - ], -) - -ATKINSON = ErrorDiffusionKernel( - name="Atkinson", - divisor=8, - offsets=[ - (1, 0, 1), - (2, 0, 1), # Current row - (-1, 1, 1), - (0, 1, 1), - (1, 1, 1), # Row +1 - (0, 2, 1), # Row +2 - ], -) - -STUCKI = ErrorDiffusionKernel( - name="Stucki", - divisor=42, - offsets=[ - (1, 0, 8), - (2, 0, 4), # Current row - (-2, 1, 2), - (-1, 1, 4), - (0, 1, 8), - (1, 1, 4), - (2, 1, 2), # Row +1 - (-2, 2, 1), - (-1, 2, 2), - (0, 2, 4), - (1, 2, 2), - (2, 2, 1), # Row +2 - ], -) - -JARVIS_JUDICE_NINKE = ErrorDiffusionKernel( - name="Jarvis-Judice-Ninke", - divisor=48, - offsets=[ - (1, 0, 7), - (2, 0, 5), # Current row - (-2, 1, 3), - (-1, 1, 5), - (0, 1, 7), - (1, 1, 5), - (2, 1, 3), # Row +1 - (-2, 2, 1), - (-1, 2, 3), - (0, 2, 5), - (1, 2, 3), - (2, 2, 1), # Row +2 - ], -) - -# Bayer 4x4 matrix normalized to [-0.5, 0.5] (centered around 0) -_BAYER_4X4 = np.array([[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]], dtype=np.float32) / 16.0 - 0.5 - - -def get_palette_colors(color_scheme: ColorScheme | ColorPalette) -> list[tuple[int, int, int]]: - """Get RGB palette for color scheme or custom palette. - - Args: - color_scheme: Display color scheme enum OR custom ColorPalette - - Returns: - List of RGB tuples for palette (order matters for encoding) - """ - if isinstance(color_scheme, ColorScheme): - return list(color_scheme.palette.colors.values()) - return list(color_scheme.colors.values()) - - -def error_diffusion_dither( - image: Image.Image, - color_scheme: ColorScheme | ColorPalette, - kernel: ErrorDiffusionKernel, - serpentine: bool = True, - tone_compression: float | str = "auto", - gamut_compression: float | str = 0.0, -) -> Image.Image: - """Generic error diffusion dithering with any kernel. - - This function handles all aspects of error diffusion dithering: - - Image preprocessing (RGBA → RGB on white, gamma correction) - - Palette conversion to linear space - - Serpentine or raster scanning - - Error diffusion using provided kernel - - Output assembly - - Working in linear RGB space ensures that error distribution is - perceptually correct. Errors are calculated and propagated in - linear light values, not gamma-encoded sRGB. - - Args: - image: Input image (any PIL mode) - color_scheme: Target color scheme - kernel: Error diffusion kernel specification - serpentine: Use serpentine scanning to reduce directional artifacts - - Returns: - Dithered palette image in sRGB - - Notes: - - RGBA images are composited on WHITE background (e-paper assumption) - - Error buffer is unbounded during processing (can go negative or >1.0) - - Clamping only occurs when reading pixels for quantization - - Serpentine scanning alternates row direction to eliminate worm artifacts - """ - # ===== Image Preprocessing ===== - # Convert to RGB, handling alpha channel properly - if image.mode == "RGBA": - # Composite on WHITE background (e-paper displays have white base) - background = Image.new("RGB", image.size, (255, 255, 255)) - background.paste(image, mask=image.split()[3]) # Use alpha as mask - image = background - elif image.mode != "RGB": - image = image.convert("RGB") - - # ===== Color Space Conversion ===== - # Convert from sRGB [0-255] to linear RGB [0.0-1.0] - pixels_srgb = np.array(image, dtype=np.uint8) - pixels_linear = srgb_to_linear(pixels_srgb.astype(np.float32)) - height, width = pixels_linear.shape[:2] - - # Convert palette to linear space - palette_srgb = get_palette_colors(color_scheme) - palette_linear = srgb_to_linear(np.array(palette_srgb, dtype=np.float32)) - - # Compress dynamic range for measured palettes - if isinstance(color_scheme, ColorPalette) and tone_compression != 0: - if tone_compression == "auto": - pixels_linear = auto_compress_dynamic_range(pixels_linear, palette_linear) - elif isinstance(tone_compression, float): - pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression) - - # Gamut compression: auto only for measured palettes; explicit strength works on all - if gamut_compression == "auto": - if isinstance(color_scheme, ColorPalette): - pixels_linear = auto_gamut_compress(pixels_linear, palette_linear) - elif isinstance(gamut_compression, float) and gamut_compression > 0.0: - pixels_linear = gamut_compress(pixels_linear, palette_linear, gamut_compression) - - # Pre-compute palette LAB components for scalar per-pixel matching - palette_L, palette_a, palette_b, palette_C = precompute_palette_lab(palette_linear) - - # Build the error-diffusion buffer in sRGB space. - # Colour matching uses linear+LAB (perceptually accurate), but error is - # accumulated in sRGB so that mid-tone brightness matches human perception. - # A pixel at sRGB 128 needs ~50% dithering dots, not ~21% (what linear gives). - _lc = np.clip(pixels_linear, 0.0, 1.0) - pixels_srgb_float: np.ndarray = ( - np.where( - _lc <= 0.0031308, - _lc * 12.92, - 1.055 * np.power(np.maximum(_lc, 0.0), 1.0 / 2.4) - 0.055, - ) - * 255.0 - ) # float32, range [0, 255] - - # Pre-extract palette sRGB as Python floats for error computation - palette_srgb_f = [(float(r), float(g), float(b)) for r, g, b in palette_srgb] - - # LUT: sRGB integer [0-255] → linear float [0-1], avoids per-pixel power calls - _lut = [i / (255.0 * 12.92) if i / 255.0 <= 0.04045 else ((i / 255.0 + 0.055) / 1.055) ** 2.4 for i in range(256)] - - # Pre-normalize kernel weights (eliminates division per pixel) - normalized_offsets = [(dx, dy, weight / kernel.divisor) for dx, dy, weight in kernel.offsets] - - # ===== Output Preparation ===== - output = Image.new("P", (width, height)) - output_pixels = np.zeros((height, width), dtype=np.uint8) - - # ===== Error Diffusion Loop ===== - for y in range(height): - # Serpentine scanning: alternate direction each row - if serpentine and y % 2 == 1: - x_range = range(width - 1, -1, -1) # Right to left - else: - x_range = range(width) # Left to right - - for x in x_range: - # Read sRGB value with accumulated error, clamped to [0, 255] - r_s = max(0.0, min(255.0, float(pixels_srgb_float[y, x, 0]))) - g_s = max(0.0, min(255.0, float(pixels_srgb_float[y, x, 1]))) - b_s = max(0.0, min(255.0, float(pixels_srgb_float[y, x, 2]))) - - # Convert to linear for LCH-weighted LAB colour matching via LUT - r_lin = _lut[int(r_s)] - g_lin = _lut[int(g_s)] - b_lin = _lut[int(b_s)] - - # Find closest palette color using LCH-weighted LAB distance - new_idx = _match_pixel_lch(r_lin, g_lin, b_lin, palette_L, palette_a, palette_b, palette_C) - - # Store palette index - output_pixels[y, x] = new_idx - - # Calculate quantization error in sRGB space - pr, pg, pb = palette_srgb_f[new_idx] - err_r = r_s - pr - err_g = g_s - pg - err_b = b_s - pb - - # Distribute error using pre-normalized kernel weights - for dx, dy, nw in normalized_offsets: - # Flip horizontal offset if serpentine on odd row - if serpentine and y % 2 == 1: - dx = -dx - - nx, ny = x + dx, y + dy - - # Check bounds and distribute error - if 0 <= nx < width and 0 <= ny < height: - pixels_srgb_float[ny, nx, 0] += err_r * nw - pixels_srgb_float[ny, nx, 1] += err_g * nw - pixels_srgb_float[ny, nx, 2] += err_b * nw - - # ===== Output Assembly ===== - output.putdata(output_pixels.flatten()) - - # Set palette (in sRGB) - flat_palette = [c for rgb in palette_srgb for c in rgb] - output.putpalette(flat_palette) - - return output - - -# ============================================================================= -# Individual Error Diffusion Algorithms (Thin Wrappers) -# ============================================================================= -# Each function below is a thin wrapper around error_diffusion_dither() -# with a specific kernel. This eliminates code duplication while maintaining -# the original API. - - -def floyd_steinberg_dither( - image: Image.Image, - color_scheme: ColorScheme | ColorPalette, - serpentine: bool = True, - tone_compression: float | str = "auto", - gamut_compression: float | str = 0.0, -) -> Image.Image: - """Apply Floyd-Steinberg error diffusion dithering. - - Floyd-Steinberg kernel (divisor 16): - X 7 - 3 5 1 - - Most popular error diffusion algorithm, good balance of quality and speed. - - Args: - image: Input image - color_scheme: Target color scheme - serpentine: Use serpentine scanning (reduces artifacts) - tone_compression: Dynamic range compression strength (0.0-1.0) - gamut_compression: Blend out-of-gamut colors toward palette (0.0-1.0) - - Returns: - Dithered image - """ - return error_diffusion_dither(image, color_scheme, FLOYD_STEINBERG, serpentine, tone_compression, gamut_compression) - - -def burkes_dither( - image: Image.Image, - color_scheme: ColorScheme | ColorPalette, - serpentine: bool = True, - tone_compression: float | str = "auto", - gamut_compression: float | str = 0.0, -) -> Image.Image: - """Apply Burkes error diffusion dithering. - - Burkes kernel (divisor 32): - X 8 4 - 2 4 8 4 2 - - Args: - image: Input image - color_scheme: Target color scheme - serpentine: Use serpentine scanning (reduces artifacts) - tone_compression: Dynamic range compression strength (0.0-1.0) - gamut_compression: Blend out-of-gamut colors toward palette (0.0-1.0) - - Returns: - Dithered image - """ - return error_diffusion_dither(image, color_scheme, BURKES, serpentine, tone_compression, gamut_compression) - - -def sierra_dither( - image: Image.Image, - color_scheme: ColorScheme | ColorPalette, - serpentine: bool = True, - tone_compression: float | str = "auto", - gamut_compression: float | str = 0.0, -) -> Image.Image: - """Apply Sierra error diffusion dithering. - - Sierra kernel (divisor 32): - X 5 3 - 2 4 5 4 2 - 2 3 2 - - Sierra-2-4A variant, balanced quality and performance. - - Args: - image: Input image - color_scheme: Target color scheme - serpentine: Use serpentine scanning (reduces artifacts) - tone_compression: Dynamic range compression strength (0.0-1.0) - - Returns: - Dithered image - """ - return error_diffusion_dither(image, color_scheme, SIERRA, serpentine, tone_compression, gamut_compression) - - -def sierra_lite_dither( - image: Image.Image, - color_scheme: ColorScheme | ColorPalette, - serpentine: bool = True, - tone_compression: float | str = "auto", - gamut_compression: float | str = 0.0, -) -> Image.Image: - """Apply Sierra Lite error diffusion dithering. - - Sierra Lite kernel (divisor 4): - X 2 - 1 1 - - Fast, simple 3-neighbor algorithm. - - Args: - image: Input image - color_scheme: Target color scheme - serpentine: Use serpentine scanning (reduces artifacts) - tone_compression: Dynamic range compression strength (0.0-1.0) - - Returns: - Dithered image - """ - return error_diffusion_dither(image, color_scheme, SIERRA_LITE, serpentine, tone_compression, gamut_compression) - - -def atkinson_dither( - image: Image.Image, - color_scheme: ColorScheme | ColorPalette, - serpentine: bool = True, - tone_compression: float | str = "auto", - gamut_compression: float | str = 0.0, -) -> Image.Image: - """Apply Atkinson error diffusion dithering. - - Atkinson kernel (divisor 8): - X 1 1 - 1 1 1 - 1 - - Designed for early Macintosh computers, produces distinct artistic look. - - Args: - image: Input image - color_scheme: Target color scheme - serpentine: Use serpentine scanning (reduces artifacts) - tone_compression: Dynamic range compression strength (0.0-1.0) - - Returns: - Dithered image - """ - return error_diffusion_dither(image, color_scheme, ATKINSON, serpentine, tone_compression, gamut_compression) - - -def stucki_dither( - image: Image.Image, - color_scheme: ColorScheme | ColorPalette, - serpentine: bool = True, - tone_compression: float | str = "auto", - gamut_compression: float | str = 0.0, -) -> Image.Image: - """Apply Stucki error diffusion dithering. - - Stucki kernel (divisor 42): - X 8 4 - 2 4 8 4 2 - 1 2 4 2 1 - - High quality algorithm with wide error distribution. - - Args: - image: Input image - color_scheme: Target color scheme - serpentine: Use serpentine scanning (reduces artifacts) - tone_compression: Dynamic range compression strength (0.0-1.0) - - Returns: - Dithered image - """ - return error_diffusion_dither(image, color_scheme, STUCKI, serpentine, tone_compression, gamut_compression) - - -def jarvis_judice_ninke_dither( - image: Image.Image, - color_scheme: ColorScheme | ColorPalette, - serpentine: bool = True, - tone_compression: float | str = "auto", - gamut_compression: float | str = 0.0, -) -> Image.Image: - """Apply Jarvis-Judice-Ninke error diffusion dithering. - - Jarvis-Judice-Ninke kernel (divisor 48): - X 7 5 - 3 5 7 5 3 - 1 3 5 3 1 - - Highest quality algorithm with symmetrical error distribution. - - Args: - image: Input image - color_scheme: Target color scheme - serpentine: Use serpentine scanning (reduces artifacts) - tone_compression: Dynamic range compression strength (0.0-1.0) - - Returns: - Dithered image - """ - return error_diffusion_dither( - image, color_scheme, JARVIS_JUDICE_NINKE, serpentine, tone_compression, gamut_compression - ) - - -# ============================================================================= -# Non-Error-Diffusion Algorithms -# ============================================================================= - - -def direct_palette_map( - image: Image.Image, - color_scheme: ColorScheme | ColorPalette, - tone_compression: float | str = "auto", - gamut_compression: float | str = 0.0, -) -> Image.Image: - """Map image colors directly to palette without dithering. - - Args: - image: Input image - color_scheme: Target color scheme OR custom ColorPalette - tone_compression: Dynamic range compression strength (0.0-1.0) - - Returns: - Image with palette colors - """ - # Handle alpha channel properly (composite on white) - if image.mode == "RGBA": - background = Image.new("RGB", image.size, (255, 255, 255)) - background.paste(image, mask=image.split()[3]) - image = background - elif image.mode != "RGB": - image = image.convert("RGB") - - palette_srgb = get_palette_colors(color_scheme) - pixels_srgb = np.array(image, dtype=np.uint8) - height, width = pixels_srgb.shape[:2] - - # ===== VECTORIZED PALETTE MAPPING ===== - - # Convert to linear space for perceptual accuracy - pixels_linear = srgb_to_linear(pixels_srgb.astype(np.float32)) - - # Convert palette to linear space - palette_linear = srgb_to_linear(np.array(palette_srgb, dtype=np.float32)) - - # Compress dynamic range for measured palettes - if isinstance(color_scheme, ColorPalette) and tone_compression != 0: - if tone_compression == "auto": - pixels_linear = auto_compress_dynamic_range(pixels_linear, palette_linear) - elif isinstance(tone_compression, float): - pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression) - - # Gamut compression: auto only for measured palettes; explicit strength works on all - if gamut_compression == "auto": - if isinstance(color_scheme, ColorPalette): - pixels_linear = auto_gamut_compress(pixels_linear, palette_linear) - elif isinstance(gamut_compression, float) and gamut_compression > 0.0: - pixels_linear = gamut_compress(pixels_linear, palette_linear, gamut_compression) - - # Find closest palette color for ALL pixels at once using LAB - output_pixels = find_closest_palette_color_lab(pixels_linear, palette_linear) - - # ===== Output Assembly ===== - output = Image.new("P", (width, height)) - output.putdata(output_pixels.flatten()) - flat_palette = [c for rgb in palette_srgb for c in rgb] - output.putpalette(flat_palette) - - return output - - -def ordered_dither( - image: Image.Image, - color_scheme: ColorScheme | ColorPalette, - tone_compression: float | str = "auto", - gamut_compression: float | str = 0.0, -) -> Image.Image: - """Apply ordered (Bayer) dithering with full vectorization. - - Uses a normalized 4x4 Bayer matrix to add spatially-distributed noise - before quantization. Unlike error diffusion, ordered dithering does not - propagate errors between pixels, making it ideal for vectorization. - - This implementation works in linear RGB space with proper gamma correction - and uses small centered threshold offsets (not the broken 0-240 bias from - the previous version). - - Args: - image: Input image (any PIL mode) - color_scheme: Target color scheme - - Returns: - Dithered palette image - - Notes: - - Bayer matrix normalized to [-0.5, 0.5] centered around 0 - - RGBA images composited on white background - - Uses perceptual color distance (ITU-R BT.601 luma weights) - - Works in linear RGB space for correct quantization - """ - # Bayer 4x4 matrix normalized to [-0.5, 0.5] (centered around 0) - bayer_matrix = ( - np.array([[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]], dtype=np.float32) / 16.0 - 0.5 - ) - - # ===== Image Preprocessing ===== - if image.mode == "RGBA": - background = Image.new("RGB", image.size, (255, 255, 255)) - background.paste(image, mask=image.split()[3]) - image = background - elif image.mode != "RGB": - image = image.convert("RGB") - - # ===== Color Space Conversion ===== - pixels_srgb = np.array(image, dtype=np.uint8) - pixels_linear = srgb_to_linear(pixels_srgb.astype(np.float32)) - height, width = pixels_linear.shape[:2] - - # Convert palette to linear space - palette_srgb = get_palette_colors(color_scheme) - palette_linear = srgb_to_linear(np.array(palette_srgb, dtype=np.float32)) - - # Compress dynamic range for measured palettes - if isinstance(color_scheme, ColorPalette) and tone_compression != 0: - if tone_compression == "auto": - pixels_linear = auto_compress_dynamic_range(pixels_linear, palette_linear) - elif isinstance(tone_compression, float): - pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression) - - # Gamut compression: auto only for measured palettes; explicit strength works on all - if gamut_compression == "auto": - if isinstance(color_scheme, ColorPalette): - pixels_linear = auto_gamut_compress(pixels_linear, palette_linear) - elif isinstance(gamut_compression, float) and gamut_compression > 0.0: - pixels_linear = gamut_compress(pixels_linear, palette_linear, gamut_compression) - - # ===== VECTORIZED ORDERED DITHERING ===== - - # Create threshold matrix for entire image using broadcasting - y_indices = np.arange(height)[:, np.newaxis] % 4 # Shape: (height, 1) - x_indices = np.arange(width)[np.newaxis, :] % 4 # Shape: (1, width) - threshold_matrix = bayer_matrix[y_indices, x_indices] # Shape: (height, width) - - # Add threshold to all pixels at once - dithered_pixels = pixels_linear + threshold_matrix[:, :, np.newaxis] - dithered_pixels = np.clip(dithered_pixels, 0.0, 1.0) - - # Find closest palette color for ALL pixels at once using LAB - output_pixels = find_closest_palette_color_lab(dithered_pixels, palette_linear) - - # ===== Output Assembly ===== - output = Image.new("P", (width, height)) - output.putdata(output_pixels.flatten()) - flat_palette = [c for rgb in palette_srgb for c in rgb] - output.putpalette(flat_palette) - - return output diff --git a/packages/python/src/epaper_dithering/color_space.py b/packages/python/src/epaper_dithering/color_space.py deleted file mode 100644 index 91d8eb8..0000000 --- a/packages/python/src/epaper_dithering/color_space.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Color space conversion utilities for e-paper dithering. - -This module provides functions for converting between sRGB (gamma-encoded) -and linear RGB color spaces. Proper gamma correction is essential for -perceptually correct error diffusion dithering. - -The functions implement the IEC 61966-2-1 sRGB standard with piecewise -transfer functions to avoid numerical issues with very dark values. -""" - -from __future__ import annotations - -import numpy as np - - -def srgb_to_linear(srgb: np.ndarray) -> np.ndarray: - """Convert sRGB [0-255] to linear RGB [0.0-1.0]. - - Implements the IEC 61966-2-1 sRGB standard inverse transfer function. - This is essential for correct error diffusion dithering, as error - calculations only make physical sense in linear light space. - - The function uses a piecewise approach: - - For dark values (≤ 0.04045): linear section (value / 12.92) - - For other values: power function ((value + 0.055) / 1.055) ^ 2.4 - - Args: - srgb: Array of sRGB values in range [0, 255]. Can be any shape. - - Returns: - Array of linear RGB values in range [0.0, 1.0], same shape as input. - - Examples: - >>> srgb = np.array([0, 128, 255], dtype=np.float32) - >>> linear = srgb_to_linear(srgb) - >>> linear[0] # Black stays black - 0.0 - >>> linear[2] # White stays white - 1.0 - >>> linear[1] # Middle gray is much darker in sRGB - 0.2158... - - References: - - IEC 61966-2-1:1999 standard - - https://en.wikipedia.org/wiki/SRGB#Specification_of_the_transformation - """ - # Normalize to [0, 1] - normalized = srgb / 255.0 - - # Apply inverse sRGB gamma (piecewise) - # The threshold 0.04045 corresponds to linear value 0.0031308 - linear = np.where( - normalized <= 0.04045, - normalized / 12.92, # Linear section for dark values - np.power((normalized + 0.055) / 1.055, 2.4), # Gamma section - ) - - return linear - - -def linear_to_srgb(linear: np.ndarray) -> np.ndarray: - """Convert linear RGB [0.0-1.0] to sRGB [0-255]. - - Implements the IEC 61966-2-1 sRGB standard transfer function. - This converts from linear light values back to gamma-encoded sRGB - for display or storage. - - The function uses a piecewise approach: - - For dark values (≤ 0.0031308): linear section (value * 12.92) - - For other values: power function (1.055 * value ^ (1/2.4) - 0.055) - - Args: - linear: Array of linear RGB values in range [0.0, 1.0]. Can be any shape. - - Returns: - Array of sRGB values in range [0, 255], same shape as input. - Values are rounded (not truncated) to nearest integer. - - Examples: - >>> linear = np.array([0.0, 0.5, 1.0], dtype=np.float32) - >>> srgb = linear_to_srgb(linear) - >>> srgb[0] # Black stays black - 0 - >>> srgb[2] # White stays white - 255 - >>> srgb[1] # 50% linear light is bright in sRGB - 188 - - Notes: - - Uses np.round() instead of int() to avoid truncation bias - - Output is uint8 for compatibility with PIL Image - - References: - - IEC 61966-2-1:1999 standard - - https://en.wikipedia.org/wiki/SRGB#Specification_of_the_transformation - """ - # Apply sRGB gamma (piecewise) - # The threshold 0.0031308 corresponds to sRGB value 0.04045 - normalized = np.where( - linear <= 0.0031308, - linear * 12.92, # Linear section - 1.055 * np.power(linear, 1.0 / 2.4) - 0.055, # Gamma section - ) - - # Convert to [0, 255] and round (not truncate!) - # Rounding prevents systematic darkening bias from truncation - return np.round(normalized * 255.0).astype(np.uint8) diff --git a/packages/python/src/epaper_dithering/color_space_lab.py b/packages/python/src/epaper_dithering/color_space_lab.py deleted file mode 100644 index 3e08736..0000000 --- a/packages/python/src/epaper_dithering/color_space_lab.py +++ /dev/null @@ -1,237 +0,0 @@ -"""OKLab color space conversions and LCH-weighted color matching for dithering. - -OKLab (Ottosson 2020) is a perceptually uniform color space with better hue -linearity than CIELAB. Equal distances in OKLab represent more consistent -perceived color differences across all hue angles, including the yellow/purple -regions where CIELAB is known to warp. - -Why LCH Weighting for Dithering: ---------------------------------- -Standard perceptual distance (Delta E) weights lightness, chroma, and hue -equally. But for error-diffusion dithering, hue preservation matters MORE -than lightness accuracy because: -- Error diffusion compensates for lightness by mixing dark+light pixels spatially -- Error diffusion CANNOT compensate for hue errors (no way to mix green from - non-green palette colors) - -The LCH decomposition uses the identity: da^2 + db^2 = dC^2 + dH^2, -allowing us to weight the three perceptual dimensions independently: -- Lightness (WL=0.5): de-emphasized, error diffusion handles this -- Chroma (WC=3.0): scaled up for OKLab's smaller C range [0, ~0.4] -- Hue (WH=6.0): emphasized, prevents cross-hue errors like green->yellow - -Note: gamut compression uses plain Euclidean OKLab (not LCH-weighted) since -it operates on the image before dithering and targets a different goal. - -References: ----------- -- Ottosson, B. — "A perceptual color space for image processing", 2020 - https://bottosson.github.io/posts/oklab/ -- http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html -""" - -from __future__ import annotations - -import math - -import numpy as np - -# ============================================================================= -# Constants -# ============================================================================= - -# sRGB to XYZ matrix (D65 illuminant, sRGB primaries) -# From http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html -_M_RGB_TO_XYZ = np.array( - [ - [0.4124564, 0.3575761, 0.1804375], - [0.2126729, 0.7151522, 0.0721750], - [0.0193339, 0.1191920, 0.9503041], - ], - dtype=np.float64, -) - -# OKLab matrices (Ottosson 2020) -# M1: XYZ → LMS (Hunt-Pointer-Estevez with Bradford adaptation) -_M1 = np.array( - [ - [0.8189330101, 0.3618667424, -0.1288597137], - [0.0329845436, 0.9293118715, 0.0361456387], - [0.0482003018, 0.2643662691, 0.6338517070], - ], - dtype=np.float64, -) - -# M2: cbrt(LMS) → OKLab -_M2 = np.array( - [ - [0.2104542553, 0.7936177850, -0.0040720468], - [1.9779984951, -2.4285922050, 0.4505937099], - [0.0259040371, 0.7827717662, -0.8086757660], - ], - dtype=np.float64, -) - - -# LCH distance weights for dithering -_WL = 0.5 # lightness: de-emphasized (error diffusion compensates) -_WC = 3.0 # chroma: scaled up for OKLab's smaller C range [0, ~0.4] -_WH = 6.0 # hue: emphasized (error diffusion cannot compensate) - - -# ============================================================================= -# Vectorized Functions (for batch operations: direct mapping, ordered dither) -# ============================================================================= - - -def rgb_to_lab(rgb: np.ndarray) -> np.ndarray: - """Convert linear RGB to OKLab color space. - - Args: - rgb: Linear RGB values in [0, 1] range. Shape: (..., 3) - - Returns: - OKLab values. L in [0, 1], a and b in [-0.5, 0.5]. Shape: (..., 3) - """ - xyz = rgb @ _M_RGB_TO_XYZ.T - lms = xyz @ _M1.T - lms_ = np.cbrt(lms) - return lms_ @ _M2.T - - -def find_closest_palette_color_lab( - rgb_linear: np.ndarray, - palette_linear: np.ndarray, -) -> np.ndarray: - """Find closest palette color using LCH-weighted OKLab distance. - - Optimized for batch operations (entire image at once). Uses numpy - broadcasting to compute distances for all pixels simultaneously. - - Args: - rgb_linear: Linear RGB values. Shape: - - (3,) for single pixel - - (height, width, 3) for entire image - palette_linear: Palette colors in linear space. Shape: (num_colors, 3) - - Returns: - Palette indices. Shape matches input without last dimension. - """ - lab_pixels = rgb_to_lab(rgb_linear) - lab_palette = rgb_to_lab(palette_linear) - - # Chroma of each palette color - C_palette = np.sqrt(lab_palette[:, 1] ** 2 + lab_palette[:, 2] ** 2) - - # Chroma of each pixel - C_pixels = np.sqrt(lab_pixels[..., 1] ** 2 + lab_pixels[..., 2] ** 2) - - # Broadcast differences: (..., 1, 3) - (num_colors, 3) -> (..., num_colors, 3) - diff = lab_pixels[..., np.newaxis, :] - lab_palette[np.newaxis, :, :] - dL = diff[..., 0] - da = diff[..., 1] - db = diff[..., 2] - - # LCH decomposition: da^2 + db^2 = dC^2 + dH^2 - dC = C_pixels[..., np.newaxis] - C_palette[np.newaxis, :] - dH_sq = np.maximum(0.0, da**2 + db**2 - dC**2) - - # Weighted distance - distances = (_WL * dL) ** 2 + (_WC * dC) ** 2 + _WH**2 * dH_sq - - return np.argmin(distances, axis=-1) # type: ignore[no-any-return] - - -# ============================================================================= -# Scalar Functions (for per-pixel error diffusion — no numpy overhead) -# ============================================================================= - - -def _rgb_to_lab_scalar(r: float, g: float, b: float) -> tuple[float, float, float]: - """Convert a single linear RGB pixel to OKLab (scalar, no numpy).""" - # RGB -> XYZ (inline matrix multiply) - x = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b - y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b - z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b - - # XYZ -> LMS (M1) - l = 0.8189330101 * x + 0.3618667424 * y + (-0.1288597137) * z # noqa: E741 - m = 0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z - s = 0.0482003018 * x + 0.2643662691 * y + 0.6338517070 * z - - # Cube root - l_ = math.cbrt(l) - m_ = math.cbrt(m) - s_ = math.cbrt(s) - - # cbrt(LMS) -> OKLab (M2) - L = 0.2104542553 * l_ + 0.7936177850 * m_ + (-0.0040720468) * s_ - a = 1.9779984951 * l_ + (-2.4285922050) * m_ + 0.4505937099 * s_ - b_val = 0.0259040371 * l_ + 0.7827717662 * m_ + (-0.8086757660) * s_ - return L, a, b_val - - -def _match_pixel_lch( - r: float, - g: float, - b: float, - palette_L: tuple[float, ...], - palette_a: tuple[float, ...], - palette_b: tuple[float, ...], - palette_C: tuple[float, ...], -) -> int: - """Find closest palette color for a single pixel using LCH distance. - - Pure Python (no numpy) for minimal per-call overhead in error diffusion. - - Args: - r, g, b: Pixel in linear RGB [0, 1] - palette_L, palette_a, palette_b: Pre-computed OKLab components of palette - palette_C: Pre-computed chroma of palette colors - - Returns: - Index of closest palette color - """ - pL, pa, pb = _rgb_to_lab_scalar(r, g, b) - pC = math.sqrt(pa * pa + pb * pb) - - best_idx = 0 - best_dist = float("inf") - - for i in range(len(palette_L)): - dL = pL - palette_L[i] - da = pa - palette_a[i] - db = pb - palette_b[i] - dC = pC - palette_C[i] - dH_sq = da * da + db * db - dC * dC - if dH_sq < 0.0: - dH_sq = 0.0 - - dist = (_WL * dL) ** 2 + (_WC * dC) ** 2 + _WH * _WH * dH_sq - if dist < best_dist: - best_dist = dist - best_idx = i - - return best_idx - - -def precompute_palette_lab( - palette_linear: np.ndarray, -) -> tuple[tuple[float, ...], tuple[float, ...], tuple[float, ...], tuple[float, ...]]: - """Pre-compute palette OKLab components for scalar matching. - - Call once before the error diffusion loop, then pass results to - _match_pixel_lch() for each pixel. - - Args: - palette_linear: Palette in linear RGB. Shape: (num_colors, 3) - - Returns: - (palette_L, palette_a, palette_b, palette_C) as tuples of floats - """ - lab = rgb_to_lab(palette_linear) - L = tuple(float(x) for x in lab[:, 0]) - a = tuple(float(x) for x in lab[:, 1]) - b = tuple(float(x) for x in lab[:, 2]) - C = tuple(math.sqrt(float(ai) ** 2 + float(bi) ** 2) for ai, bi in zip(lab[:, 1], lab[:, 2])) - return L, a, b, C diff --git a/packages/python/src/epaper_dithering/core.py b/packages/python/src/epaper_dithering/core.py index 50818f5..bcbb2aa 100644 --- a/packages/python/src/epaper_dithering/core.py +++ b/packages/python/src/epaper_dithering/core.py @@ -6,73 +6,115 @@ from PIL import Image -from . import algorithms +import epaper_dithering._rs as _rs + from .enums import DitherMode from .palettes import ColorPalette, ColorScheme _LOGGER = logging.getLogger(__name__) -def dither_image( +def _to_rgb_bytes(image: Image.Image) -> tuple[bytes, int, int]: + """Convert PIL image to flat RGB bytes. Composites RGBA on white.""" + if image.mode == "RGBA": + bg = Image.new("RGB", image.size, (255, 255, 255)) + bg.paste(image, mask=image.split()[3]) + img_rgb = bg + else: + img_rgb = image.convert("RGB") + width, height = img_rgb.size + return img_rgb.tobytes(), width, height + + +def _compression(v: float | str) -> float | None: + """Map Python compression param to Rust Option: 'auto' → None, float → Some.""" + return None if v == "auto" else float(v) + + +def dither_image( # pylint: disable=too-many-arguments image: Image.Image, color_scheme: ColorScheme | ColorPalette, + *, mode: DitherMode = DitherMode.BURKES, serpentine: bool = True, - tone_compression: float | str = "auto", - gamut_compression: float | str = "auto", + exposure: float = 1.0, + saturation: float = 1.0, + shadows: float = 0.0, + highlights: float = 0.0, + tone: float | str = "auto", + gamut: float | str = "auto", ) -> Image.Image: - """Apply dithering to image for e-paper display. + """Apply dithering to an image for e-paper display. Args: - image: Input image (RGB or RGBA) - color_scheme: Target display color scheme OR measured ColorPalette - mode: Dithering algorithm (default: BURKES) - serpentine: Use serpentine scanning for error diffusion (default: True). - Alternates scan direction each row to reduce directional artifacts. - Only applies to error diffusion algorithms, ignored for NONE and ORDERED. - tone_compression: Dynamic range compression (default: "auto"). - "auto" = analyze image histogram and fit to display range. - 0.0 = disabled, 0.0-1.0 = fixed linear compression strength. - Only applies to measured ColorPalette. - gamut_compression: Pre-dithering gamut compression (default: "auto"). - Blends out-of-gamut pixels toward their nearest palette color before - dithering. Useful for images with highly saturated colors the palette - cannot reproduce (e.g. vivid purple on a BWGBRY display). - "auto" = only compress when image content genuinely exceeds the - palette gamut (p95 nearest-palette distance > 0.25 in OKLab); - auto mode only activates for measured ColorPalette, not ColorScheme. - 0.0 = disabled, 0.7-0.9 = fixed strength (works on all palette types). + image: Input image (RGB or RGBA). RGBA is composited on white. + color_scheme: Target display palette — `ColorScheme` enum (idealized) or + measured `ColorPalette` instance. + + Keyword Args: + mode: Dithering algorithm (default: BURKES). + serpentine: Alternate row scan direction for error diffusion (default: True). + Ignored for NONE and ORDERED modes. + exposure: Linear-RGB exposure multiplier. 1.0 = no change, 2.0 = +1 stop. + saturation: OKLab saturation multiplier. 1.0 = no change, 0.0 = grayscale. + Hue-preserving. + shadows: Shadow lift strength (S-curve lower half). 0.0 = off, 1.0 = strong. + highlights: Highlight compression strength (S-curve upper half). 0.0 = off, 1.0 = strong. + tone: Dynamic-range compression. "auto" = histogram-based fit to display range, + 0.0 = off, 0.0–1.0 = fixed strength. Only meaningful for measured palettes. + gamut: Gamut compression for out-of-gamut pixels. "auto" = full strength on + out-of-gamut pixels (smoothstep), 0.0 = off, 0.0–1.0 = fixed strength. Returns: - Dithered palette image matching color scheme + Dithered palette-mode (`"P"`) PIL Image matching the color scheme. """ - if not isinstance(tone_compression, (float, str)): - raise TypeError(f"tone_compression must be float or 'auto', got {type(tone_compression).__name__}") - if not isinstance(gamut_compression, (float, str)): - raise TypeError(f"gamut_compression must be float or 'auto', got {type(gamut_compression).__name__}") + if not isinstance(tone, (float, int, str)): + raise TypeError(f"tone must be float or 'auto', got {type(tone).__name__}") + if not isinstance(gamut, (float, int, str)): + raise TypeError(f"gamut must be float or 'auto', got {type(gamut).__name__}") - # Log color scheme name if available scheme_name = color_scheme.name if isinstance(color_scheme, ColorScheme) else "custom" _LOGGER.debug("Applying %s dithering for %s palette", mode.name, scheme_name) - tc = tone_compression - gc = gamut_compression - match mode: - case DitherMode.NONE: - return algorithms.direct_palette_map(image, color_scheme, tc, gc) - case DitherMode.ORDERED: - return algorithms.ordered_dither(image, color_scheme, tc, gc) - case DitherMode.FLOYD_STEINBERG: - return algorithms.floyd_steinberg_dither(image, color_scheme, serpentine, tc, gc) - case DitherMode.ATKINSON: - return algorithms.atkinson_dither(image, color_scheme, serpentine, tc, gc) - case DitherMode.STUCKI: - return algorithms.stucki_dither(image, color_scheme, serpentine, tc, gc) - case DitherMode.SIERRA: - return algorithms.sierra_dither(image, color_scheme, serpentine, tc, gc) - case DitherMode.SIERRA_LITE: - return algorithms.sierra_lite_dither(image, color_scheme, serpentine, tc, gc) - case DitherMode.JARVIS_JUDICE_NINKE: - return algorithms.jarvis_judice_ninke_dither(image, color_scheme, serpentine, tc, gc) - case _: # BURKES or fallback - return algorithms.burkes_dither(image, color_scheme, serpentine, tc, gc) + pixels, width, height = _to_rgb_bytes(image) + + common_kwargs: dict[str, object] = { + "mode_id": int(mode), + "serpentine": serpentine, + "exposure": exposure, + "saturation": saturation, + "shadows": shadows, + "highlights": highlights, + "tone": _compression(tone), + "gamut": _compression(gamut), + } + + if isinstance(color_scheme, ColorScheme): + # Idealized scheme: tone/gamut auto don't apply; force them off. + common_kwargs["tone"] = 0.0 + common_kwargs["gamut"] = 0.0 + indices = _rs.dither_image( + pixels, + width, + height, + scheme_id=color_scheme.value, # type: ignore[arg-type] # non-standard enum: _value_ is int + **common_kwargs, # type: ignore[arg-type] + ) + palette_colors = list(color_scheme.palette.colors.values()) + else: + palette_colors = list(color_scheme.colors.values()) + palette_bytes = bytes(c for rgb in palette_colors for c in rgb) + accent_idx = list(color_scheme.colors.keys()).index(color_scheme.accent) + indices = _rs.dither_image( + pixels, + width, + height, + palette_bytes=palette_bytes, + accent_idx=accent_idx, + **common_kwargs, # type: ignore[arg-type] + ) + + out = Image.new("P", (width, height)) + out.putdata(indices) + out.putpalette([c for rgb in palette_colors for c in rgb]) + return out diff --git a/packages/python/src/epaper_dithering/palettes.py b/packages/python/src/epaper_dithering/palettes.py index b73e0c4..f5187cc 100644 --- a/packages/python/src/epaper_dithering/palettes.py +++ b/packages/python/src/epaper_dithering/palettes.py @@ -184,130 +184,37 @@ def from_value(cls, value: int) -> ColorScheme: # Measured Palettes for Specific E-Paper Displays # ============================================================================ # -# These constants provide measured RGB values from real e-paper displays -# for more accurate dithering. Pure RGB colors (e.g., White=255,255,255, -# Red=255,0,0) are much brighter than real displays, which are typically -# 30-87% darker due to reflective screen technology. +# These constants are derived from the Rust core at import time — RGB values +# are defined once in packages/rust/core/src/measured_palettes.rs. # -# USAGE: -# from epaper_dithering import dither_image, SPECTRA_7_3_6COLOR -# result = dither_image(img, SPECTRA_7_3_6COLOR) +# To add a new display palette: +# 1. Add a new `pub static` palette + CATALOG entry in measured_palettes.rs +# 2. Add the constant name here and export it in __init__.py +# No RGB values needed in Python. # -# TO ADD YOUR DISPLAY: -# 1. Measure colors following docs/CALIBRATION.md -# 2. Create ColorPalette with measured values -# 3. Add constant here -# 4. Export in __init__.py -# -# IMPORTANT: Color names and order MUST match the corresponding ColorScheme! -# Reordering colors will break palette encoding compatibility. -# -# STATUS: All values below are THEORETICAL/PLACEHOLDER until measured. -# See docs/CALIBRATION.md for measurement procedures. # ============================================================================ -# 7.3" Spectra™ 6-color (BWGBRY scheme) -# Measured: 2026-02-03 -# Equipment: iPhone 15 Pro Max RAW + Hue Play bars @ 6500K (154 mireds) -# Method: Photographed calibration patches with white paper reference -# Raw values in colors.txt, paper reference RGB(215,217,218) -# Normalization: per-channel scaling value × (255 / paper_channel) -SPECTRA_7_3_6COLOR = ColorPalette( - colors={ - "black": (26, 13, 35), - "white": (185, 202, 205), - "yellow": (202, 184, 0), - "red": (121, 9, 0), - "blue": (0, 69, 139), - "green": (40, 82, 57), - }, - accent="red", -) - -# 7.3" Spectra™ 6-color (BWGBRY scheme) — v2 measurement -# Measured: 2026-03-15 -# Equipment: iPhone 15 Pro Max RAW + Affinity (v3), A4 paper white reference -# Method: DNG with linear tone curve, WB from A4 paper, uniform ×2.4 scale -# (paper measured at 100,100,100 → target 240 ≈ 88% A4 reflectance) -SPECTRA_7_3_6COLOR_V2 = ColorPalette( - colors={ - "black": (31, 24, 41), - "white": (168, 180, 182), - "yellow": (180, 173, 0), - "red": (113, 24, 19), - "blue": (36, 70, 139), - "green": (50, 84, 60), - }, - accent="red", -) - -# 4.26" Monochrome (MONO scheme) -# TODO: Measure actual display -MONO_4_26 = ColorPalette( - colors={ - "black": (5, 5, 5), # Measure: likely darker than pure black - "white": (220, 220, 220), # Measure: real displays ~200-230, not 255 - }, - accent="black", -) - -# 4.2" BWRY (BWRY scheme) -# TODO: Measure actual display -BWRY_4_2 = ColorPalette( - colors={ - "black": (5, 5, 5), # Measure - "white": (200, 200, 200), # Measure - "yellow": (200, 180, 0), # Measure - "red": (120, 15, 5), # Measure - }, - accent="red", -) - -# Solum BWR (harvested display, BWR scheme) -# TODO: Measure actual display -SOLUM_BWR = ColorPalette( - colors={ - "black": (5, 5, 5), # Measure - "white": (200, 200, 200), # Measure - "red": (120, 15, 5), # Measure - }, - accent="red", -) - -# Hanshow BWR (harvested display, BWR scheme) -# TODO: Measure actual display -HANSHOW_BWR = ColorPalette( - colors={ - "black": (5, 5, 5), # Measure - "white": (200, 200, 200), # Measure - "red": (120, 15, 5), # Measure - }, - accent="red", -) - -# Hanshow BWY (harvested display, BWY scheme) -# TODO: Measure actual display -HANSHOW_BWY = ColorPalette( - colors={ - "black": (5, 5, 5), # Measure - "white": (200, 200, 200), # Measure - "yellow": (200, 180, 0), # Measure - }, - accent="yellow", -) - -# 3.97" BWRY — EP397YR_800x480 (panel_ic_type=0x37 / 55), BWRY scheme -# Measured: 2026-03-06 -# Equipment: iPhone RAW -# Method: Photographed calibration patches with white paper reference -# Paper reference RGB(205,205,205); normalization: value × (255/205) per channel -# Yellow blue channel clipped to 0 (expected for yellow; kept as-is) -BWRY_3_97 = ColorPalette( - colors={ - "black": (10, 7, 14), - "white": (173, 178, 174), - "yellow": (172, 128, 0), - "red": (85, 24, 14), - }, - accent="red", -) + +def _load_measured_palettes() -> dict[str, "ColorPalette"]: + from . import _rs # noqa: PLC0415 (local import avoids circular dependency at module level) + + result: dict[str, ColorPalette] = {} + for name, rgb_bytes, color_names, accent_idx in _rs.measured_palettes(): + colors = { + color_names[i]: (rgb_bytes[i * 3], rgb_bytes[i * 3 + 1], rgb_bytes[i * 3 + 2]) + for i in range(len(color_names)) + } + result[name] = ColorPalette(colors=colors, accent=color_names[accent_idx]) + return result + + +_MEASURED = _load_measured_palettes() + +SPECTRA_7_3_6COLOR: ColorPalette = _MEASURED["SPECTRA_7_3_6COLOR"] +SPECTRA_7_3_6COLOR_V2: ColorPalette = _MEASURED["SPECTRA_7_3_6COLOR_V2"] +MONO_4_26: ColorPalette = _MEASURED["MONO_4_26"] +BWRY_4_2: ColorPalette = _MEASURED["BWRY_4_2"] +BWRY_3_97: ColorPalette = _MEASURED["BWRY_3_97"] +SOLUM_BWR: ColorPalette = _MEASURED["SOLUM_BWR"] +HANSHOW_BWR: ColorPalette = _MEASURED["HANSHOW_BWR"] +HANSHOW_BWY: ColorPalette = _MEASURED["HANSHOW_BWY"] diff --git a/packages/python/src/epaper_dithering/tone_map.py b/packages/python/src/epaper_dithering/tone_map.py deleted file mode 100644 index 8040d73..0000000 --- a/packages/python/src/epaper_dithering/tone_map.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Dynamic range compression for measured e-paper palettes. - -Maps image luminance from full [0, 1] to the display's actual [black_Y, white_Y] -range before dithering. This prevents large quantization errors at highlights and -shadows, producing smoother dithered output. - -Based on fast_compress_dynamic_range() from esp32-photoframe by aitjcize. -""" - -from __future__ import annotations - -import numpy as np - -from .color_space_lab import rgb_to_lab - -# ITU-R BT.709 luminance coefficients (same as sRGB) -_LUM_R = 0.2126729 -_LUM_G = 0.7151522 -_LUM_B = 0.0721750 - - -def compress_dynamic_range( - pixels_linear: np.ndarray, - palette_linear: np.ndarray, - strength: float = 1.0, -) -> np.ndarray: - """Compress image dynamic range to match display capabilities. - - Remaps pixel luminance from [0, 1] to the display's actual [black_Y, white_Y] - range, preserving hue. RGB channels are scaled proportionally by the luminance - ratio so colors stay correct. - - Args: - pixels_linear: Image in linear RGB, shape (H, W, 3), values in [0, 1]. - palette_linear: Palette in linear RGB, shape (N, 3). Row 0 = black, row 1 = white. - strength: Blend factor. 0.0 = no compression, 1.0 = full compression. - - Returns: - Modified pixels_linear array with compressed dynamic range. - """ - if strength <= 0.0: - return pixels_linear - - # Display black/white luminance from measured palette - black_Y = _LUM_R * palette_linear[0, 0] + _LUM_G * palette_linear[0, 1] + _LUM_B * palette_linear[0, 2] - white_Y = _LUM_R * palette_linear[1, 0] + _LUM_G * palette_linear[1, 1] + _LUM_B * palette_linear[1, 2] - display_range = white_Y - black_Y - - if display_range <= 0: - return pixels_linear - - # Per-pixel luminance - Y = _LUM_R * pixels_linear[:, :, 0] + _LUM_G * pixels_linear[:, :, 1] + _LUM_B * pixels_linear[:, :, 2] - - # Compressed luminance mapped to display range - compressed_Y = black_Y + Y * display_range - - # Blend between original and compressed based on strength - if strength < 1.0: - target_Y = Y + strength * (compressed_Y - Y) - else: - target_Y = compressed_Y - - # Scale RGB proportionally to preserve hue - # For near-black pixels (Y < 1e-6), set to display black level - safe_Y = np.where(Y > 1e-6, Y, 1.0) - scale = np.where(Y > 1e-6, target_Y / safe_Y, 0.0) - - result = pixels_linear.copy() - result[:, :, 0] *= scale - result[:, :, 1] *= scale - result[:, :, 2] *= scale - - # Near-black pixels: set to display black luminance - near_black = Y <= 1e-6 - if np.any(near_black): - black_level = black_Y * strength - result[near_black, 0] = black_level - result[near_black, 1] = black_level - result[near_black, 2] = black_level - - clipped: np.ndarray = np.clip(result, 0.0, 1.0) - return clipped - - -def auto_compress_dynamic_range( - pixels_linear: np.ndarray, - palette_linear: np.ndarray, -) -> np.ndarray: - """Conditionally compress dynamic range to display capabilities. - - Analyzes the image's actual luminance distribution (2nd/98th percentiles) - and only applies compression when the image content genuinely exceeds the - display's reproducible range. Images that already fit within the display's - [black_Y, white_Y] range are returned unchanged (ICC Black Point - Compensation style). - - When compression is needed, strength is derived from the Reinhard 2004 - log-histogram skewness — the position of the geometric mean luminance - within the log luminance range. A balanced image (log-average near center) - gets partial compression, preserving perceived contrast. A heavily skewed - image (dark scene with bright highlights) gets full compression. - - This avoids the over-compression that occurs when unconditionally stretching - a well-exposed image to fill the display range — which washes out colors - by pushing already-correct highlights above the display white point. - - Args: - pixels_linear: Image in linear RGB, shape (H, W, 3), values in [0, 1]. - palette_linear: Palette in linear RGB, shape (N, 3). Row 0 = black, row 1 = white. - - Returns: - Modified pixels_linear array with compressed dynamic range. - """ - # Display black/white luminance from measured palette - black_Y = _LUM_R * palette_linear[0, 0] + _LUM_G * palette_linear[0, 1] + _LUM_B * palette_linear[0, 2] - white_Y = _LUM_R * palette_linear[1, 0] + _LUM_G * palette_linear[1, 1] + _LUM_B * palette_linear[1, 2] - display_range = white_Y - black_Y - - if display_range <= 0: - return pixels_linear - - # Per-pixel luminance - Y = _LUM_R * pixels_linear[:, :, 0] + _LUM_G * pixels_linear[:, :, 1] + _LUM_B * pixels_linear[:, :, 2] - - # Image luminance percentiles (ignore 2% outliers at each end) - p_low = float(np.percentile(Y, 2)) - p_high = float(np.percentile(Y, 98)) - image_range = p_high - p_low - - if image_range < 1e-6: - # Uniform image: fall back to standard linear compression - return compress_dynamic_range(pixels_linear, palette_linear, 1.0) - - # Only compress if the image content genuinely exceeds the display range. - # Allow 10% of display_range as tolerance to avoid compressing images that - # merely approach the display limits without meaningfully clipping. - TOLERANCE = 0.10 - fits_shadows = p_low >= black_Y - TOLERANCE * display_range - fits_highlights = p_high <= white_Y + TOLERANCE * display_range - - if fits_shadows and fits_highlights: - # Image already fits within the display's reproducible range — no change. - return pixels_linear - - # Derive compression strength from Reinhard 2004 log-histogram skewness. - # - # Skewness = where the geometric mean (log-average) luminance sits within - # the log luminance range [log(p_low), log(p_high)]: - # 0 = log-average at the bright end → balanced/bright image → less compression - # 1 = log-average at the dark end → dark scene, bright highlights → full compression - # - # strength = clip(skew ^ 1.4, 0, 1) — the 1.4 exponent from Reinhard 2004 - # makes the response non-linear, more sensitive at extremes. - Y_nonzero = Y.ravel() - Y_nonzero = Y_nonzero[Y_nonzero > 1e-6] - if len(Y_nonzero) > 0: - L_lav = float(np.exp(np.mean(np.log(Y_nonzero + 1e-5)))) - log_min = float(np.log(max(p_low, 1e-5))) - log_max = float(np.log(max(p_high, 1e-5))) - log_range = log_max - log_min - if log_range > 1e-6: - skew = (log_max - float(np.log(L_lav + 1e-5))) / log_range - strength = float(np.clip(skew**1.4, 0.0, 1.0)) - else: - strength = 1.0 - else: - strength = 1.0 - - # Remap: [p_low, p_high] → [black_Y, white_Y], blended at computed strength. - # At strength=0: original luminance preserved. - # At strength=1: full content-adaptive remap (original behaviour). - normalized_Y = (Y - p_low) / image_range - target_Y_full = black_Y + normalized_Y * display_range - target_Y = Y + strength * (target_Y_full - Y) - - # Scale RGB proportionally to preserve hue - safe_Y = np.where(Y > 1e-6, Y, 1.0) - scale = np.where(Y > 1e-6, target_Y / safe_Y, 0.0) - - result = pixels_linear.copy() - result[:, :, 0] *= scale - result[:, :, 1] *= scale - result[:, :, 2] *= scale - - # Near-black pixels: set to display black luminance - near_black = Y <= 1e-6 - if np.any(near_black): - result[near_black, 0] = black_Y - result[near_black, 1] = black_Y - result[near_black, 2] = black_Y - - clipped: np.ndarray = np.clip(result, 0.0, 1.0) - return clipped - - -def gamut_compress( - pixels_linear: np.ndarray, - palette_linear: np.ndarray, - strength: float = 1.0, -) -> np.ndarray: - """Blend out-of-gamut pixels toward the nearest point on the palette hull. - - For each palette edge (pair of palette colors), finds the nearest point on - that segment in 3D OKLab space. The overall nearest hull point across all - edges and vertices is used as the compression target. - - A smoothstep blend factor ramps from 0 at _THRESHOLD to 1 at _THRESHOLD_MAX, - so only pixels far outside the gamut are significantly affected. - - Args: - pixels_linear: Image in linear RGB, shape (H, W, 3), values in [0, 1]. - palette_linear: Palette in linear RGB, shape (N, 3). - strength: 0.0 = no compression, 1.0 = full blend toward nearest hull point. - - Returns: - Modified pixels_linear array with out-of-gamut colors compressed. - """ - if strength <= 0.0: - return pixels_linear - - lab_pixels = rgb_to_lab(pixels_linear) # (H, W, 3) - lab_palette = rgb_to_lab(palette_linear) # (N, 3) - n_colors = len(palette_linear) - - best_dist_sq = np.full(pixels_linear.shape[:2], np.inf) - best_target_rgb = np.zeros_like(pixels_linear) - - # For each palette edge: nearest-point-on-segment in 3D OKLab - for i in range(n_colors): - for j in range(i + 1, n_colors): - edge = lab_palette[j] - lab_palette[i] # (3,) - edge_len_sq = float(np.dot(edge, edge)) - if edge_len_sq > 1e-10: - diff_from_i = lab_pixels - lab_palette[i] # (H, W, 3) - seg_t = np.clip( - np.einsum("hwc,c->hw", diff_from_i, edge) / edge_len_sq, - 0.0, - 1.0, - ) - else: - seg_t = np.zeros(pixels_linear.shape[:2]) - - nearest_lab = lab_palette[i] + seg_t[..., np.newaxis] * (lab_palette[j] - lab_palette[i]) - dist_sq = np.sum((lab_pixels - nearest_lab) ** 2, axis=-1) - target_rgb = palette_linear[i] + seg_t[..., np.newaxis] * (palette_linear[j] - palette_linear[i]) - better = dist_sq < best_dist_sq - best_dist_sq = np.where(better, dist_sq, best_dist_sq) - best_target_rgb = np.where(better[..., np.newaxis], target_rgb, best_target_rgb) - - # Also consider nearest palette vertex (catches pixels nearer to a vertex - # than to any edge segment) - diff_v = lab_pixels[..., np.newaxis, :] - lab_palette[np.newaxis, :, :] # (H, W, N, 3) - dist_sq_v = np.sum(diff_v**2, axis=-1) # (H, W, N) - nearest_v_idx = np.argmin(dist_sq_v, axis=-1) # (H, W) - nearest_v_dist_sq = np.take_along_axis(dist_sq_v, nearest_v_idx[..., np.newaxis], axis=-1).squeeze(-1) - nearest_v_rgb = palette_linear[nearest_v_idx] - better_v = nearest_v_dist_sq < best_dist_sq - best_dist_sq = np.where(better_v, nearest_v_dist_sq, best_dist_sq) - best_target_rgb = np.where(better_v[..., np.newaxis], nearest_v_rgb, best_target_rgb) - - nearest_dist = np.sqrt(best_dist_sq) - - # Smoothstep: no effect below _THRESHOLD, full effect at _THRESHOLD_MAX. - # Distances in OKLab units (L ∈ [0,1], a/b ∈ [-0.5, 0.5]): - # Natural photos / near-gamut colors: dist < 0.10 → unaffected - # sRGB primaries on a 6-color display: dist ≈ 0.19–0.22 → slight blend - # Vivid purple on BWGBRY display: dist ≈ 0.29 → meaningful blend - # Magenta: dist ≈ 0.35+ → strong blend - _THRESHOLD = 0.15 - _THRESHOLD_MAX = 0.45 - blend_t = np.clip((nearest_dist - _THRESHOLD) / (_THRESHOLD_MAX - _THRESHOLD), 0.0, 1.0) - blend_factor = blend_t * blend_t * (3.0 - 2.0 * blend_t) * strength # smoothstep × strength - - result = pixels_linear + blend_factor[..., np.newaxis] * (best_target_rgb - pixels_linear) - - clipped: np.ndarray = np.clip(result, 0.0, 1.0) - return clipped - - -def auto_gamut_compress( - pixels_linear: np.ndarray, - palette_linear: np.ndarray, -) -> np.ndarray: - """Apply moderate gamut compression suitable for most images. - - Uses a fixed strength of 0.5, which compresses strongly out-of-gamut colors - (vivid purple, magenta) while leaving near-gamut colors nearly unchanged. - Pass an explicit strength to gamut_compress() for finer control. - - Args: - pixels_linear: Image in linear RGB, shape (H, W, 3), values in [0, 1]. - palette_linear: Palette in linear RGB, shape (N, 3). - - Returns: - Modified pixels_linear array with gamut compression applied. - """ - return gamut_compress(pixels_linear, palette_linear, strength=1.0) diff --git a/packages/python/src/lib.rs b/packages/python/src/lib.rs new file mode 100644 index 0000000..b32cb58 --- /dev/null +++ b/packages/python/src/lib.rs @@ -0,0 +1,113 @@ +use epaper_dithering_core::{ + dither, DitherConfig, + enums::{DitherMode, GamutCompression, ToneCompression}, + measured_palettes::CATALOG, + palettes::{ColorScheme, Palette}, + types::ImageBuffer, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +fn parse_mode(v: u8) -> PyResult { + DitherMode::try_from(v).map_err(|e| PyValueError::new_err(e.to_string())) +} + +fn parse_tone(v: Option) -> ToneCompression { + match v { + None => ToneCompression::Auto, + Some(s) => ToneCompression::Fixed(s), + } +} + +fn parse_gamut(v: Option) -> GamutCompression { + match v { + None => GamutCompression::Auto, + Some(s) if s > 0.0 => GamutCompression::Fixed(s), + _ => GamutCompression::None, + } +} + +/// Dither a flat RGB image. +/// +/// Pass either `scheme_id` (idealized color scheme) or `palette_bytes` + `accent_idx` +/// (measured palette). If both are given, `palette_bytes` wins. +#[pyfunction] +#[pyo3(signature = ( + pixels, width, height, *, + scheme_id=None, palette_bytes=None, accent_idx=0, + mode_id=1, serpentine=true, + exposure=1.0, saturation=1.0, shadows=0.0, highlights=0.0, + tone=None, gamut=None, +))] +#[allow(clippy::too_many_arguments)] +fn dither_image( + pixels: &[u8], + width: usize, + height: usize, + scheme_id: Option, + palette_bytes: Option<&[u8]>, + accent_idx: usize, + mode_id: u8, + serpentine: bool, + exposure: f64, + saturation: f64, + shadows: f64, + highlights: f64, + tone: Option, + gamut: Option, +) -> PyResult> { + let _ = height; + let img = ImageBuffer::new(pixels, width); + let config = DitherConfig { + mode: parse_mode(mode_id)?, + serpentine, + exposure, + saturation, + shadows, + highlights, + tone: parse_tone(tone), + gamut: parse_gamut(gamut), + }; + + match (palette_bytes, scheme_id) { + (Some(bytes), _) => { + if !bytes.len().is_multiple_of(3) { + return Err(PyValueError::new_err("palette_bytes length must be a multiple of 3")); + } + let colors: Vec<[u8; 3]> = bytes.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(); + let palette = Palette::new(colors, accent_idx); + Ok(dither(&img, palette, config)) + } + (None, Some(id)) => { + let scheme = ColorScheme::try_from(id) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + Ok(dither(&img, scheme.palette(), config)) + } + (None, None) => Err(PyValueError::new_err("must provide either scheme_id or palette_bytes")), + } +} + +/// Returns all measured palettes from the Rust catalog. +/// +/// Each entry is `(id, rgb_bytes, color_names, accent_idx)`. +#[pyfunction] +fn measured_palettes() -> Vec<(String, Vec, Vec, usize)> { + CATALOG + .iter() + .map(|e| { + ( + e.id.to_string(), + e.palette.colors.iter().flatten().copied().collect(), + e.color_names.iter().map(|&s| s.to_string()).collect(), + e.palette.accent_idx, + ) + }) + .collect() +} + +#[pymodule] +fn _rs(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(dither_image, m)?)?; + m.add_function(wrap_pyfunction!(measured_palettes, m)?)?; + Ok(()) +} diff --git a/packages/python/tests/test_color_matching.py b/packages/python/tests/test_color_matching.py deleted file mode 100644 index 8d88621..0000000 --- a/packages/python/tests/test_color_matching.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Tests for LAB color matching and perceptual color science.""" - -import numpy as np -import pytest -from epaper_dithering import ColorScheme, DitherMode, dither_image -from epaper_dithering.color_space import srgb_to_linear -from epaper_dithering.color_space_lab import rgb_to_lab -from epaper_dithering.tone_map import auto_compress_dynamic_range, compress_dynamic_range -from PIL import Image - - -class TestLABConversion: - """Test RGB to LAB conversion accuracy.""" - - def test_white_converts_to_l100(self): - """Pure white in linear RGB should produce L=1, a=0, b=0 (OKLab).""" - white = np.array([1.0, 1.0, 1.0]) - lab = rgb_to_lab(white) - assert lab[0] == pytest.approx(1.0, abs=1e-4) - assert lab[1] == pytest.approx(0.0, abs=1e-3) - assert lab[2] == pytest.approx(0.0, abs=1e-3) - - def test_black_converts_to_l0(self): - """Pure black should produce L*=0, a=0, b=0.""" - black = np.array([0.0, 0.0, 0.0]) - lab = rgb_to_lab(black) - assert lab[0] == pytest.approx(0.0, abs=0.1) - assert lab[1] == pytest.approx(0.0, abs=0.5) - assert lab[2] == pytest.approx(0.0, abs=0.5) - - def test_midgray_lightness(self): - """50% linear gray should produce L around 0.79 in OKLab (cbrt(0.5) ≈ 0.794).""" - gray = np.array([0.5, 0.5, 0.5]) - lab = rgb_to_lab(gray) - assert 0.75 < lab[0] < 0.85, f"50% linear gray L should be ~0.79, got {lab[0]:.3f}" - - def test_red_has_positive_a(self): - """Pure red should have positive a (red-green axis in OKLab, range ~[-0.5, 0.5]).""" - red = np.array([1.0, 0.0, 0.0]) - lab = rgb_to_lab(red) - assert lab[1] > 0.1, f"Red should have positive a, got {lab[1]:.3f}" - - def test_batch_matches_single(self): - """Batch conversion should match individual conversions.""" - colors = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) - batch = rgb_to_lab(colors) - for i in range(3): - single = rgb_to_lab(colors[i]) - np.testing.assert_allclose(batch[i], single, atol=1e-10) - - -class TestColorMatchingAccuracy: - """Test LCH-weighted color matching on measured palettes.""" - - def test_bright_green_matches_green_not_yellow(self): - """Bright green should match palette green, not yellow. - - With the SPECTRA measured palette, green is very dark (L~31) while - yellow is bright (L~75). The LCH-weighted distance fixes this by - de-emphasizing lightness and emphasizing hue. - """ - from epaper_dithering import SPECTRA_7_3_6COLOR - - green_img = Image.new("RGB", (10, 10), (100, 255, 40)) - result = dither_image(green_img, SPECTRA_7_3_6COLOR, DitherMode.NONE) - - pixels = list(result.get_flattened_data()) - green_idx = 5 # SPECTRA order: black=0, white=1, yellow=2, red=3, blue=4, green=5 - assert all(p == green_idx for p in pixels), ( - f"Bright green should map to palette green (idx 5), got indices: {set(pixels)}" - ) - - def test_pure_blue_matches_blue_not_black(self): - """Blue should match palette blue, not black. - - The SPECTRA measured black has a slight blue tint (26,13,35). - """ - from epaper_dithering import SPECTRA_7_3_6COLOR - - blue_img = Image.new("RGB", (10, 10), (0, 0, 255)) - result = dither_image(blue_img, SPECTRA_7_3_6COLOR, DitherMode.NONE) - - pixels = list(result.get_flattened_data()) - blue_idx = 4 - assert all(p == blue_idx for p in pixels), ( - f"Pure blue should map to palette blue (idx 4), got indices: {set(pixels)}" - ) - - def test_measured_vs_pure_produces_different_output(self): - """Measured colors should produce different dithering than pure RGB.""" - from epaper_dithering import ColorPalette - - gradient = Image.new("RGB", (50, 50), (128, 128, 128)) - - pure = ColorScheme.BWR - measured = ColorPalette( - colors={"black": (5, 5, 5), "white": (180, 180, 170), "red": (115, 12, 2)}, accent="red" - ) - - result_pure = dither_image(gradient, pure, DitherMode.FLOYD_STEINBERG) - result_measured = dither_image(gradient, measured, DitherMode.FLOYD_STEINBERG) - - assert not np.array_equal(np.array(result_pure), np.array(result_measured)) - - -class TestCompressDynamicRange: - """Unit tests for dynamic range compression (tone mapping).""" - - def _make_palette_linear(self, black_srgb, white_srgb): - """Helper: convert black/white sRGB tuples to linear palette array.""" - palette_srgb = np.array([black_srgb, white_srgb], dtype=np.float32) - return srgb_to_linear(palette_srgb) - - def test_output_luminance_within_display_range(self): - """Compressed pixel luminance should fall within [black_Y, white_Y].""" - # Simulate a display with black=(30,30,30) and white=(200,200,200) - palette_linear = self._make_palette_linear([30, 30, 30], [200, 200, 200]) - black_Y = float( - 0.2126729 * palette_linear[0, 0] + 0.7151522 * palette_linear[0, 1] + 0.0721750 * palette_linear[0, 2] - ) - white_Y = float( - 0.2126729 * palette_linear[1, 0] + 0.7151522 * palette_linear[1, 1] + 0.0721750 * palette_linear[1, 2] - ) - - # Create a gradient from black to white in linear space - pixels = np.linspace(0.0, 1.0, 100).reshape(10, 10, 1).repeat(3, axis=2).astype(np.float32) - result = compress_dynamic_range(pixels, palette_linear, strength=1.0) - - # Compute luminance of result - result_Y = 0.2126729 * result[:, :, 0] + 0.7151522 * result[:, :, 1] + 0.0721750 * result[:, :, 2] - - assert result_Y.min() >= black_Y - 1e-5, ( - f"Min luminance {result_Y.min():.4f} should be >= black_Y {black_Y:.4f}" - ) - assert result_Y.max() <= white_Y + 1e-5, ( - f"Max luminance {result_Y.max():.4f} should be <= white_Y {white_Y:.4f}" - ) - - def test_strength_zero_is_identity(self): - """strength=0.0 should return pixels unchanged.""" - palette_linear = self._make_palette_linear([30, 30, 30], [200, 200, 200]) - pixels = np.random.default_rng(42).random((5, 5, 3)).astype(np.float32) - - result = compress_dynamic_range(pixels, palette_linear, strength=0.0) - np.testing.assert_array_equal(result, pixels) - - def test_strength_half_is_intermediate(self): - """strength=0.5 should produce values between original and fully compressed.""" - palette_linear = self._make_palette_linear([30, 30, 30], [200, 200, 200]) - pixels = np.full((5, 5, 3), 0.8, dtype=np.float32) - - full = compress_dynamic_range(pixels.copy(), palette_linear, strength=1.0) - half = compress_dynamic_range(pixels.copy(), palette_linear, strength=0.5) - - # Half-strength should be between original and full - assert np.all(half <= pixels + 1e-6) - assert np.all(half >= full - 1e-6) - - def test_pure_black_white_palette_is_near_identity(self): - """With black=(0,0,0) and white=(255,255,255), compression is near-identity.""" - palette_linear = self._make_palette_linear([0, 0, 0], [255, 255, 255]) - pixels = np.random.default_rng(42).random((5, 5, 3)).astype(np.float32) - - result = compress_dynamic_range(pixels.copy(), palette_linear, strength=1.0) - # black_Y ≈ 0.0, white_Y ≈ 1.0, so compressed ≈ 0 + Y * 1.0 = Y - np.testing.assert_allclose(result, pixels, atol=1e-5) - - def test_near_black_pixels_get_display_black(self): - """Pixels with near-zero luminance should be set to display black level.""" - palette_linear = self._make_palette_linear([30, 30, 30], [200, 200, 200]) - pixels = np.zeros((3, 3, 3), dtype=np.float32) # All black - - result = compress_dynamic_range(pixels, palette_linear, strength=1.0) - - black_Y = float( - 0.2126729 * palette_linear[0, 0] + 0.7151522 * palette_linear[0, 1] + 0.0721750 * palette_linear[0, 2] - ) - # Near-black pixels should be set to approximately display black luminance - assert result.mean() == pytest.approx(black_Y, abs=0.01) - - -class TestAutoCompressDynamicRange: - """Unit tests for auto (percentile-based) dynamic range compression.""" - - def _make_palette_linear(self, black_srgb, white_srgb): - """Helper: convert black/white sRGB tuples to linear palette array.""" - palette_srgb = np.array([black_srgb, white_srgb], dtype=np.float32) - return srgb_to_linear(palette_srgb) - - def _luminance(self, pixels): - """Compute per-pixel luminance.""" - return 0.2126729 * pixels[:, :, 0] + 0.7151522 * pixels[:, :, 1] + 0.0721750 * pixels[:, :, 2] - - def test_full_range_gradient_compresses_highlights(self): - """Full-range gradient should have lower p98 after auto compression than original.""" - palette_linear = self._make_palette_linear([30, 30, 30], [200, 200, 200]) - pixels = np.linspace(0.0, 1.0, 100).reshape(10, 10, 1).repeat(3, axis=2).astype(np.float32) - - auto_result = auto_compress_dynamic_range(pixels.copy(), palette_linear) - Y_orig = self._luminance(pixels) - Y_auto = self._luminance(auto_result) - - # Auto should reduce highlights — p98 of result < p98 of input - assert float(np.percentile(Y_auto, 98)) < float(np.percentile(Y_orig, 98)) - - def test_narrow_range_has_more_contrast(self): - """Narrow-range image should have more contrast with auto than fixed 1.0.""" - palette_linear = self._make_palette_linear([30, 30, 30], [200, 200, 200]) - # Image using only 30-70% of luminance range - pixels = np.linspace(0.3, 0.7, 100).reshape(10, 10, 1).repeat(3, axis=2).astype(np.float32) - - auto_result = auto_compress_dynamic_range(pixels.copy(), palette_linear) - linear_result = compress_dynamic_range(pixels.copy(), palette_linear, 1.0) - - # Auto should stretch to use more of the display range - auto_Y = self._luminance(auto_result) - linear_Y = self._luminance(linear_result) - auto_range = float(auto_Y.max() - auto_Y.min()) - linear_range = float(linear_Y.max() - linear_Y.min()) - - assert auto_range > linear_range, f"Auto range {auto_range:.4f} should exceed linear range {linear_range:.4f}" - - def test_uniform_image_falls_back(self): - """Uniform image should fall back to linear compression.""" - palette_linear = self._make_palette_linear([30, 30, 30], [200, 200, 200]) - pixels = np.full((5, 5, 3), 0.5, dtype=np.float32) - - result = auto_compress_dynamic_range(pixels, palette_linear) - expected = compress_dynamic_range(pixels.copy(), palette_linear, 1.0) - np.testing.assert_allclose(result, expected, atol=1e-5) - - def test_output_luminance_within_display_range(self): - """Auto-compressed output should be closer to display range than the raw input.""" - palette_linear = self._make_palette_linear([30, 30, 30], [200, 200, 200]) - white_Y = float( - 0.2126729 * palette_linear[1, 0] + 0.7151522 * palette_linear[1, 1] + 0.0721750 * palette_linear[1, 2] - ) - - pixels = np.linspace(0.0, 1.0, 100).reshape(10, 10, 1).repeat(3, axis=2).astype(np.float32) - result = auto_compress_dynamic_range(pixels, palette_linear) - result_Y = self._luminance(result) - - # Auto compression uses conservative strength — p98 should be reduced - # toward white_Y but won't necessarily reach it for a balanced gradient. - p98_orig = float(np.percentile(self._luminance(pixels), 98)) - p98_result = float(np.percentile(result_Y, 98)) - assert p98_result < p98_orig, "Auto compression should reduce highlights" - assert p98_result > white_Y, "Conservative compression leaves some overshoot" diff --git a/packages/python/tests/test_color_space.py b/packages/python/tests/test_color_space.py deleted file mode 100644 index 83a833d..0000000 --- a/packages/python/tests/test_color_space.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Tests for color space conversion functions.""" - -from __future__ import annotations - -import numpy as np -import pytest -from epaper_dithering.color_space import linear_to_srgb, srgb_to_linear - - -class TestGammaCorrection: - """Test gamma correction functions.""" - - def test_srgb_to_linear_black(self): - """Test black (0, 0, 0) stays black.""" - srgb = np.array([0, 0, 0], dtype=np.float32) - linear = srgb_to_linear(srgb) - assert np.allclose(linear, [0.0, 0.0, 0.0], atol=1e-6) - - def test_srgb_to_linear_white(self): - """Test white (255, 255, 255) stays white.""" - srgb = np.array([255, 255, 255], dtype=np.float32) - linear = srgb_to_linear(srgb) - assert np.allclose(linear, [1.0, 1.0, 1.0], atol=1e-6) - - def test_srgb_to_linear_midgray(self): - """Test middle gray (~50% linear) is much darker in sRGB. - - 50% linear light corresponds to approximately sRGB value 186-188. - Middle sRGB value (128) is only about 21.6% linear light. - This demonstrates the non-linearity of the sRGB encoding. - """ - # sRGB 186 should be close to 50% linear - srgb_50_linear = np.array([186], dtype=np.float32) - linear = srgb_to_linear(srgb_50_linear) - assert np.allclose(linear[0], 0.5, atol=0.01) - - # sRGB 128 (middle gray) is much darker than 50% linear - srgb_128 = np.array([128], dtype=np.float32) - linear_128 = srgb_to_linear(srgb_128) - assert linear_128[0] < 0.25 # Much darker than 50% - - def test_srgb_to_linear_shape_preserved(self): - """Test that output shape matches input shape.""" - # Test 1D array - srgb_1d = np.array([0, 128, 255], dtype=np.float32) - linear_1d = srgb_to_linear(srgb_1d) - assert linear_1d.shape == (3,) - - # Test 2D array (like an image) - srgb_2d = np.array([[0, 128], [255, 64]], dtype=np.float32) - linear_2d = srgb_to_linear(srgb_2d) - assert linear_2d.shape == (2, 2) - - # Test 3D array (like RGB image) - srgb_3d = np.zeros((10, 10, 3), dtype=np.float32) - srgb_3d[:, :, 0] = 128 # Red channel - linear_3d = srgb_to_linear(srgb_3d) - assert linear_3d.shape == (10, 10, 3) - - def test_srgb_to_linear_monotonic(self): - """Test that function is monotonically increasing.""" - srgb = np.arange(0, 256, dtype=np.float32) - linear = srgb_to_linear(srgb) - - # Check all differences are non-negative (monotonic) - diffs = np.diff(linear) - assert np.all(diffs >= 0) - - def test_srgb_to_linear_boundary(self): - """Test piecewise linear section boundary (0.04045 in sRGB).""" - # The boundary is at sRGB value 0.04045 * 255 ≈ 10.31 - srgb_boundary = np.array([10, 11], dtype=np.float32) - linear = srgb_to_linear(srgb_boundary) - - # Both should be small values in linear space - assert linear[0] < 0.005 - assert linear[1] < 0.005 - - # Should be continuous at boundary - assert abs(linear[1] - linear[0]) < 0.001 - - -class TestLinearToSRGB: - """Test linear to sRGB conversion.""" - - def test_linear_to_srgb_black(self): - """Test black (0.0) stays black.""" - linear = np.array([0.0, 0.0, 0.0], dtype=np.float32) - srgb = linear_to_srgb(linear) - assert np.array_equal(srgb, [0, 0, 0]) - - def test_linear_to_srgb_white(self): - """Test white (1.0) stays white.""" - linear = np.array([1.0, 1.0, 1.0], dtype=np.float32) - srgb = linear_to_srgb(linear) - assert np.array_equal(srgb, [255, 255, 255]) - - def test_linear_to_srgb_midpoint(self): - """Test 50% linear light is bright in sRGB (around 188).""" - linear = np.array([0.5], dtype=np.float32) - srgb = linear_to_srgb(linear) - - # 50% linear should be around 186-188 in sRGB - assert 185 <= srgb[0] <= 189 - - def test_linear_to_srgb_shape_preserved(self): - """Test that output shape matches input shape.""" - # Test 1D - linear_1d = np.array([0.0, 0.5, 1.0], dtype=np.float32) - srgb_1d = linear_to_srgb(linear_1d) - assert srgb_1d.shape == (3,) - - # Test 3D (image-like) - linear_3d = np.random.rand(10, 10, 3).astype(np.float32) - srgb_3d = linear_to_srgb(linear_3d) - assert srgb_3d.shape == (10, 10, 3) - assert srgb_3d.dtype == np.uint8 - - def test_linear_to_srgb_monotonic(self): - """Test that function is monotonically increasing.""" - linear = np.linspace(0, 1, 256, dtype=np.float32) - srgb = linear_to_srgb(linear) - - # Check all differences are non-negative (monotonic) - diffs = np.diff(srgb.astype(np.int16)) # Use int16 to detect negative - assert np.all(diffs >= 0) - - def test_linear_to_srgb_uses_rounding(self): - """Test that conversion uses rounding, not truncation. - - This prevents systematic darkening bias. For example: - - Truncation: 127.9 → 127 (always rounds down) - - Rounding: 127.9 → 128 (rounds to nearest) - """ - # Test that multiple similar values round correctly - # Values around 0.215 convert to sRGB values near 128 - test_values = np.linspace(0.215, 0.216, 10, dtype=np.float32) - results = linear_to_srgb(test_values) - - # All results should be in a small range (good rounding) - assert results.max() - results.min() <= 1 - - -class TestRoundtrip: - """Test roundtrip conversions.""" - - def test_roundtrip_identity(self): - """Test sRGB → linear → sRGB is approximately identity.""" - original = np.array([0, 32, 64, 96, 128, 160, 192, 224, 255], dtype=np.float32) - linear = srgb_to_linear(original) - roundtrip = linear_to_srgb(linear) - - # Allow 1 unit of error due to rounding - assert np.allclose(roundtrip, original, atol=1) - - def test_roundtrip_all_values(self): - """Test roundtrip for all possible sRGB values.""" - original = np.arange(256, dtype=np.float32) - linear = srgb_to_linear(original) - roundtrip = linear_to_srgb(linear) - - # Should be very close to original - max_error = np.abs(roundtrip.astype(np.float32) - original).max() - assert max_error <= 1 # At most 1 unit of rounding error - - def test_roundtrip_linear_identity(self): - """Test linear → sRGB → linear preserves key values.""" - # Test a few key linear values - original_linear = np.array([0.0, 0.25, 0.5, 0.75, 1.0], dtype=np.float32) - srgb = linear_to_srgb(original_linear) - roundtrip_linear = srgb_to_linear(srgb.astype(np.float32)) - - # Should be close (some error due to quantization to uint8) - assert np.allclose(roundtrip_linear, original_linear, atol=0.01) - - -class TestEdgeCases: - """Test edge cases and boundary conditions.""" - - def test_srgb_to_linear_negative_clamped(self): - """Test that negative values are handled gracefully.""" - # NumPy will produce negative values for negative input - srgb = np.array([-10, 0, 10], dtype=np.float32) - linear = srgb_to_linear(srgb) - - # Negative input produces negative output (mathematically correct) - assert linear[0] < 0 - assert linear[1] == 0 - assert linear[2] > 0 - - @pytest.mark.filterwarnings("ignore::RuntimeWarning") - def test_linear_to_srgb_out_of_range(self): - """Test that out-of-range values are handled.""" - # Values outside [0, 1] should still convert - linear = np.array([-0.1, 0.5, 1.5], dtype=np.float32) - srgb = linear_to_srgb(linear) - - # Should clip or wrap in some reasonable way - assert srgb.dtype == np.uint8 - assert len(srgb) == 3 - - def test_very_small_values(self): - """Test very small values near zero.""" - srgb = np.array([1, 2, 3], dtype=np.float32) - linear = srgb_to_linear(srgb) - - # Should all be very small but positive - assert np.all(linear > 0) - assert np.all(linear < 0.01) - - def test_very_large_values(self): - """Test values near maximum.""" - srgb = np.array([253, 254, 255], dtype=np.float32) - linear = srgb_to_linear(srgb) - - # Should all be close to 1.0 - assert np.all(linear > 0.98) - assert np.all(linear <= 1.0) diff --git a/packages/python/tests/test_dithering.py b/packages/python/tests/test_dithering.py index 4f555ed..fe7a3c4 100644 --- a/packages/python/tests/test_dithering.py +++ b/packages/python/tests/test_dithering.py @@ -12,7 +12,7 @@ class TestDitheringAlgorithms: @pytest.mark.parametrize("mode", list(DitherMode)) def test_all_modes_produce_valid_output(self, small_test_image, mode): """Test each dithering mode produces valid palette image.""" - result = dither_image(small_test_image, ColorScheme.BWR, mode) + result = dither_image(small_test_image, ColorScheme.BWR, mode=mode) assert result.mode == "P", f"Output should be palette mode, got {result.mode}" assert result.size == small_test_image.size, "Output size should match input" @@ -23,7 +23,7 @@ def test_all_modes_produce_valid_output(self, small_test_image, mode): @pytest.mark.parametrize("scheme", list(ColorScheme)) def test_all_color_schemes(self, small_test_image, scheme): """Test each color scheme works correctly.""" - result = dither_image(small_test_image, scheme, DitherMode.BURKES) + result = dither_image(small_test_image, scheme, mode=DitherMode.BURKES) assert result.mode == "P" palette = result.getpalette() @@ -31,13 +31,13 @@ def test_all_color_schemes(self, small_test_image, scheme): def test_output_image_type(self, small_test_image): """Test output is PIL Image.""" - result = dither_image(small_test_image, ColorScheme.MONO, DitherMode.FLOYD_STEINBERG) + result = dither_image(small_test_image, ColorScheme.MONO, mode=DitherMode.FLOYD_STEINBERG) assert isinstance(result, Image.Image) def test_rgba_input_handling(self): """Test RGBA images are handled correctly.""" rgba_img = Image.new("RGBA", (10, 10), color=(128, 128, 128, 255)) - result = dither_image(rgba_img, ColorScheme.BWR, DitherMode.BURKES) + result = dither_image(rgba_img, ColorScheme.BWR, mode=DitherMode.BURKES) assert result.mode == "P" assert result.size == rgba_img.size @@ -57,7 +57,7 @@ def test_output_contains_only_valid_indices(self, scheme): b = 128 pixels[x, y] = (r, g, b) - result = dither_image(gradient, scheme, DitherMode.FLOYD_STEINBERG) + result = dither_image(gradient, scheme, mode=DitherMode.FLOYD_STEINBERG) result_pixels = np.array(result) max_idx = result_pixels.max() assert max_idx < scheme.color_count, f"{scheme.name}: pixel index {max_idx} >= color count {scheme.color_count}" @@ -73,7 +73,7 @@ def test_gamma_correction_improves_midtones(self): for y in range(64): gradient.putpixel((x, y), (x, x, x)) - result = dither_image(gradient, ColorScheme.GRAYSCALE_4, DitherMode.FLOYD_STEINBERG) + result = dither_image(gradient, ColorScheme.GRAYSCALE_4, mode=DitherMode.FLOYD_STEINBERG) histogram = result.histogram()[:4] @@ -85,7 +85,7 @@ def test_gamma_correction_improves_midtones(self): def test_alpha_composites_on_white(self): """Test RGBA images composite on white background, not black.""" rgba_white = Image.new("RGBA", (10, 10), (255, 255, 255, 128)) - result_white = dither_image(rgba_white, ColorScheme.MONO, DitherMode.NONE) + result_white = dither_image(rgba_white, ColorScheme.MONO, mode=DitherMode.NONE) histogram_white = result_white.histogram() assert histogram_white[1] > histogram_white[0], ( @@ -93,7 +93,7 @@ def test_alpha_composites_on_white(self): ) rgba_transparent = Image.new("RGBA", (10, 10), (0, 0, 0, 0)) - result_transparent = dither_image(rgba_transparent, ColorScheme.MONO, DitherMode.NONE) + result_transparent = dither_image(rgba_transparent, ColorScheme.MONO, mode=DitherMode.NONE) histogram_transparent = result_transparent.histogram() assert histogram_transparent[1] == 100, ( @@ -109,8 +109,8 @@ def test_serpentine_parameter_works(self): gray_value = int(x * 255 / 99) pixels[x, y] = (gray_value, gray_value, gray_value) - result_serpentine = dither_image(gradient, ColorScheme.MONO, DitherMode.FLOYD_STEINBERG, serpentine=True) - result_raster = dither_image(gradient, ColorScheme.MONO, DitherMode.FLOYD_STEINBERG, serpentine=False) + result_serpentine = dither_image(gradient, ColorScheme.MONO, mode=DitherMode.FLOYD_STEINBERG, serpentine=True) + result_raster = dither_image(gradient, ColorScheme.MONO, mode=DitherMode.FLOYD_STEINBERG, serpentine=False) assert result_serpentine.mode == "P" assert result_raster.mode == "P" @@ -125,15 +125,15 @@ def test_deterministic_output(self): """Test that dithering produces identical output on repeated runs.""" img = Image.new("RGB", (50, 50), (128, 128, 128)) - result1 = dither_image(img, ColorScheme.BWR, DitherMode.FLOYD_STEINBERG) - result2 = dither_image(img, ColorScheme.BWR, DitherMode.FLOYD_STEINBERG) + result1 = dither_image(img, ColorScheme.BWR, mode=DitherMode.FLOYD_STEINBERG) + result2 = dither_image(img, ColorScheme.BWR, mode=DitherMode.FLOYD_STEINBERG) assert np.array_equal(np.array(result1), np.array(result2)), "Dithering should be deterministic" def test_ordered_dithering_uses_threshold_correctly(self): """Test ordered dithering produces reasonable distribution.""" gray = Image.new("RGB", (16, 16), (186, 186, 186)) - result = dither_image(gray, ColorScheme.MONO, DitherMode.ORDERED) + result = dither_image(gray, ColorScheme.MONO, mode=DitherMode.ORDERED) pixels = list(result.get_flattened_data()) @@ -161,10 +161,10 @@ def test_all_error_diffusion_with_serpentine(self): ] for mode in error_diffusion_modes: - result_true = dither_image(img, ColorScheme.MONO, mode, serpentine=True) + result_true = dither_image(img, ColorScheme.MONO, mode=mode, serpentine=True) assert result_true.mode == "P", f"{mode.name} should work with serpentine=True" - result_false = dither_image(img, ColorScheme.MONO, mode, serpentine=False) + result_false = dither_image(img, ColorScheme.MONO, mode=mode, serpentine=False) assert result_false.mode == "P", f"{mode.name} should work with serpentine=False" @@ -176,7 +176,7 @@ def test_tone_compression_with_measured_palette(self): from epaper_dithering import SPECTRA_7_3_6COLOR img = Image.new("RGB", (20, 20), (200, 200, 200)) - result = dither_image(img, SPECTRA_7_3_6COLOR, DitherMode.FLOYD_STEINBERG, tone_compression=1.0) + result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.FLOYD_STEINBERG, tone=1.0) assert result.mode == "P" assert result.size == (20, 20) @@ -187,19 +187,19 @@ def test_tone_compression_all_modes(self, mode): from epaper_dithering import SPECTRA_7_3_6COLOR img = Image.new("RGB", (10, 10), (128, 128, 128)) - result = dither_image(img, SPECTRA_7_3_6COLOR, mode, tone_compression=1.0) + result = dither_image(img, SPECTRA_7_3_6COLOR, mode=mode, tone=1.0) assert result.mode == "P" assert result.size == (10, 10) def test_tone_compression_zero_matches_no_compression(self): - """tone_compression=0.0 should produce same result as without compression.""" + """tone=0.0 should produce same result as without compression.""" from epaper_dithering import SPECTRA_7_3_6COLOR img = Image.new("RGB", (20, 20), (128, 128, 128)) - result_zero = dither_image(img, SPECTRA_7_3_6COLOR, DitherMode.NONE, tone_compression=0.0) + result_zero = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.NONE, tone=0.0) # With ColorScheme (not measured), compression is always skipped - result_scheme = dither_image(img, ColorScheme.BWGBRY, DitherMode.NONE, tone_compression=1.0) + result_scheme = dither_image(img, ColorScheme.BWGBRY, mode=DitherMode.NONE, tone=1.0) # Both should produce valid palette output assert result_zero.mode == "P" @@ -210,9 +210,9 @@ def test_tone_compression_skipped_for_color_scheme(self): img = Image.new("RGB", (20, 20), (128, 128, 128)) # These should produce identical output since ColorScheme bypasses compression - result_tc0 = dither_image(img, ColorScheme.MONO, DitherMode.NONE, tone_compression=0.0) - result_tc1 = dither_image(img, ColorScheme.MONO, DitherMode.NONE, tone_compression=1.0) - result_auto = dither_image(img, ColorScheme.MONO, DitherMode.NONE, tone_compression="auto") + result_tc0 = dither_image(img, ColorScheme.MONO, mode=DitherMode.NONE, tone=0.0) + result_tc1 = dither_image(img, ColorScheme.MONO, mode=DitherMode.NONE, tone=1.0) + result_auto = dither_image(img, ColorScheme.MONO, mode=DitherMode.NONE, tone="auto") assert np.array_equal(np.array(result_tc0), np.array(result_tc1)), ( "Tone compression should have no effect on theoretical ColorScheme" @@ -227,7 +227,7 @@ def test_auto_tone_compression_all_modes(self, mode): from epaper_dithering import SPECTRA_7_3_6COLOR img = Image.new("RGB", (10, 10), (128, 128, 128)) - result = dither_image(img, SPECTRA_7_3_6COLOR, mode) + result = dither_image(img, SPECTRA_7_3_6COLOR, mode=mode) assert result.mode == "P" assert result.size == (10, 10) @@ -244,8 +244,8 @@ def test_tone_compression_changes_measured_output(self): v = int(x * 255 / 49) pixels[x, y] = (v, v, v) - result_off = dither_image(gradient, SPECTRA_7_3_6COLOR, DitherMode.FLOYD_STEINBERG, tone_compression=0.0) - result_on = dither_image(gradient, SPECTRA_7_3_6COLOR, DitherMode.FLOYD_STEINBERG, tone_compression=1.0) + result_off = dither_image(gradient, SPECTRA_7_3_6COLOR, mode=DitherMode.FLOYD_STEINBERG, tone=0.0) + result_on = dither_image(gradient, SPECTRA_7_3_6COLOR, mode=DitherMode.FLOYD_STEINBERG, tone=1.0) assert not np.array_equal(np.array(result_off), np.array(result_on)), ( "Tone compression should produce different output than no compression" diff --git a/packages/python/tests/test_kernels.py b/packages/python/tests/test_kernels.py deleted file mode 100644 index ba9f260..0000000 --- a/packages/python/tests/test_kernels.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Tests for error diffusion kernel definitions.""" - -import pytest -from epaper_dithering.algorithms import ( - ATKINSON, - BURKES, - FLOYD_STEINBERG, - JARVIS_JUDICE_NINKE, - SIERRA, - SIERRA_LITE, - STUCKI, -) - -ALL_KERNELS = [ - FLOYD_STEINBERG, - BURKES, - SIERRA, - SIERRA_LITE, - STUCKI, - JARVIS_JUDICE_NINKE, -] - - -class TestKernelWeights: - """Validate error diffusion kernel weight correctness.""" - - @pytest.mark.parametrize("kernel", ALL_KERNELS, ids=lambda k: k.name) - def test_weights_sum_to_divisor(self, kernel): - """Kernel weights must sum to divisor for full error propagation. - - If weights don't sum to divisor, error is lost (sum < divisor) - or amplified (sum > divisor), causing brightness drift. - This would have caught the Burkes bug where hallucinated weights - summed to 104 with divisor 200 (52% propagation instead of 100%). - """ - weight_sum = sum(weight for _, _, weight in kernel.offsets) - assert weight_sum == kernel.divisor, f"{kernel.name}: weights sum to {weight_sum}, expected {kernel.divisor}" - - def test_atkinson_propagates_75_percent(self): - """Atkinson intentionally propagates only 6/8 (75%) of error. - - This gives it a distinctive lighter appearance. The divisor is 8 - but weights sum to 6. - """ - weight_sum = sum(weight for _, _, weight in ATKINSON.offsets) - assert ATKINSON.divisor == 8 - assert weight_sum == 6, f"Atkinson should propagate 6/8 of error, weights sum to {weight_sum}" - - @pytest.mark.parametrize("kernel", ALL_KERNELS + [ATKINSON], ids=lambda k: k.name) - def test_all_weights_positive(self, kernel): - """All kernel weights must be positive.""" - for dx, dy, weight in kernel.offsets: - assert weight > 0, f"{kernel.name}: weight at ({dx},{dy}) is {weight}" - - @pytest.mark.parametrize("kernel", ALL_KERNELS + [ATKINSON], ids=lambda k: k.name) - def test_offsets_are_forward_only(self, kernel): - """Kernel offsets must only distribute to unprocessed pixels. - - For left-to-right scanning: dx > 0 on same row (dy=0), - or any dx on future rows (dy > 0). - """ - for dx, dy, _weight in kernel.offsets: - assert dy >= 0, f"{kernel.name}: offset ({dx},{dy}) points backwards" - if dy == 0: - assert dx > 0, f"{kernel.name}: offset ({dx},{dy}) on current row must be forward" diff --git a/packages/python/tests/test_palettes.py b/packages/python/tests/test_palettes.py index 0130c38..0be512d 100644 --- a/packages/python/tests/test_palettes.py +++ b/packages/python/tests/test_palettes.py @@ -97,7 +97,7 @@ def test_dithering_accepts_colorpalette(self, small_test_image): measured = ColorPalette( colors={"black": (2, 2, 2), "white": (179, 182, 171), "red": (117, 10, 0)}, accent="red" ) - result = dither_image(small_test_image, measured, DitherMode.BURKES) + result = dither_image(small_test_image, measured, mode=DitherMode.BURKES) assert result.mode == "P" assert result.size == small_test_image.size @@ -111,13 +111,13 @@ def test_predefined_measured_palettes_work(self, small_test_image): """Test exported measured palette constants.""" from epaper_dithering import HANSHOW_BWR, MONO_4_26, SPECTRA_7_3_6COLOR - result = dither_image(small_test_image, SPECTRA_7_3_6COLOR, DitherMode.BURKES) + result = dither_image(small_test_image, SPECTRA_7_3_6COLOR, mode=DitherMode.BURKES) assert result.mode == "P" - result = dither_image(small_test_image, MONO_4_26, DitherMode.FLOYD_STEINBERG) + result = dither_image(small_test_image, MONO_4_26, mode=DitherMode.FLOYD_STEINBERG) assert result.mode == "P" - result = dither_image(small_test_image, HANSHOW_BWR, DitherMode.SIERRA) + result = dither_image(small_test_image, HANSHOW_BWR, mode=DitherMode.SIERRA) assert result.mode == "P" @@ -129,7 +129,7 @@ def test_pure_colors_map_to_own_index(self, scheme): """Each palette color should map to its own index with DitherMode.NONE.""" for idx, (name, rgb) in enumerate(scheme.palette.colors.items()): img = Image.new("RGB", (4, 4), rgb) - result = dither_image(img, scheme, DitherMode.NONE) + result = dither_image(img, scheme, mode=DitherMode.NONE) pixels = list(result.get_flattened_data()) assert all(p == idx for p in pixels), ( f"{scheme.name}: {name} {rgb} should map to index {idx}, got {set(pixels)}" diff --git a/packages/rust/Cargo.toml b/packages/rust/Cargo.toml new file mode 100644 index 0000000..d99288b --- /dev/null +++ b/packages/rust/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["core", "wasm"] +resolver = "2" \ No newline at end of file diff --git a/packages/rust/core/Cargo.toml b/packages/rust/core/Cargo.toml new file mode 100644 index 0000000..b649e03 --- /dev/null +++ b/packages/rust/core/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "epaper-dithering-core" +version = "3.0.0" +edition = "2024" +description = "High-quality dithering algorithms for e-paper / e-ink displays — OKLab color matching, error diffusion, ordered dithering, measured palettes" +license = "MIT" +repository = "https://github.com/OpenDisplay-org/epaper-dithering" +homepage = "https://opendisplay.org" +documentation = "https://docs.rs/epaper-dithering-core" +keywords = ["epaper", "eink", "dithering", "color", "embedded"] +categories = ["graphics", "multimedia::images", "embedded"] +readme = "README.md" +exclude = ["examples/minzi.jpeg"] + +[dependencies] +rayon = "1.10" + +[[bench]] +name = "dithering" +harness = false + +[dev-dependencies] +approx = "0.5" # float comparisons in tests: assert_relative_eq! +criterion = { version = "0.5", features = ["html_reports"] } +image = "0.25" # image loading/saving for examples diff --git a/packages/rust/core/README.md b/packages/rust/core/README.md new file mode 100644 index 0000000..954efc1 --- /dev/null +++ b/packages/rust/core/README.md @@ -0,0 +1,63 @@ +# epaper-dithering-core + +High-quality dithering for e-paper / e-ink displays. + +- **Weighted Cartesian OKLab color matching** — perceptually accurate, hue-preserving, no achromatic-attractor bug +- **7 error diffusion kernels** — Floyd-Steinberg, Atkinson, Burkes, Stucki, Sierra, Sierra Lite, Jarvis-Judice-Ninke +- **Ordered (Bayer 4×4) dithering** — perceptually-correct sRGB-space thresholding, parallelized via rayon +- **Measured palettes** — calibrated colors for real displays (Spectra 7.3", BWRY 3.97", and more) +- **Pre-dither knobs** — exposure, saturation, shadows, highlights, dynamic-range compression, gamut compression +- **Serpentine scanning** — reduces directional artifacts in error diffusion + +## Examples + +![Frankfurt at night — Spectra 6-color, auto tone + gamut](../../../docs/examples/frankfurt_before_after.png) + +## Usage + +```rust +use epaper_dithering_core::{ + dither, DitherConfig, + enums::DitherMode, + palettes::ColorScheme, + types::ImageBuffer, +}; + +// pixels: flat RGB bytes, row-major (width × height × 3) +let img = ImageBuffer::new(&pixels, width); +let indices = dither(&img, ColorScheme::Bwr, DitherConfig { + mode: DitherMode::FloydSteinberg, + ..Default::default() +}); +``` + +With a measured palette and pre-dither adjustments: + +```rust +use epaper_dithering_core::{ + dither, DitherConfig, + enums::{DitherMode, ToneCompression, GamutCompression}, + measured_palettes::SPECTRA_7_3_6COLOR, + types::ImageBuffer, +}; + +let img = ImageBuffer::new(&pixels, width); +let indices = dither(&img, &SPECTRA_7_3_6COLOR, DitherConfig { + mode: DitherMode::Stucki, + saturation: 1.3, // boost saturation + shadows: 0.4, // lift shadows + tone: ToneCompression::Auto, + gamut: GamutCompression::Auto, + ..Default::default() +}); +``` + +`DitherConfig` defaults: `Burkes`, `serpentine: true`, `exposure: 1.0`, `saturation: 1.0`, +`shadows: 0.0`, `highlights: 0.0`, `tone: Auto`, `gamut: Auto`. + +Pipeline order: `exposure → saturation → shadows/highlights → tone → gamut → dither`. + +## Related packages + +- Python: [`epaper-dithering`](https://pypi.org/project/epaper-dithering/) +- JavaScript: [`@opendisplay/epaper-dithering`](https://www.npmjs.com/package/@opendisplay/epaper-dithering) diff --git a/packages/rust/core/benches/dithering.rs b/packages/rust/core/benches/dithering.rs new file mode 100644 index 0000000..55517a6 --- /dev/null +++ b/packages/rust/core/benches/dithering.rs @@ -0,0 +1,308 @@ +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use epaper_dithering_core::{ + algorithms::{ + BURKES, FLOYD_STEINBERG, JARVIS_JUDICE_NINKE, direct_map, error_diffusion_dither, + ordered_dither, + }, + color_space::srgb_channel_to_linear, + color_space_lab::{PaletteLab, WAB, match_pixel_oklab, rgb_to_oklab}, + dither, DitherConfig, + enums::{DitherMode, GamutCompression, ToneCompression}, + measured_palettes::SPECTRA_7_3_6COLOR, + palettes::ColorScheme, + tone_map::{auto_compress_dynamic_range, compress_dynamic_range, gamut_compress}, + types::ImageBuffer, +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn fixtures_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/images") +} + +/// Load a fixture image as flat RGB bytes. Panics if the file is missing. +fn load_fixture(filename: &str) -> (Vec, usize, usize) { + let img = image::open(fixtures_dir().join(filename)) + .unwrap_or_else(|e| panic!("failed to load {filename}: {e}")) + .to_rgb8(); + let (w, h) = img.dimensions(); + (img.into_raw(), w as usize, h as usize) +} + +/// A non-trivial RGB gradient image (no external files needed). +fn synthetic_image(w: usize, h: usize) -> Vec { + let n = w * h; + (0..n) + .flat_map(|i| { + let x = i % w; + let y = i / w; + [ + ((x * 255) / w.max(1)) as u8, + ((y * 255) / h.max(1)) as u8, + ((i * 255) / n.max(1)) as u8, + ] + }) + .collect() +} + +/// Synthetic image converted to linear [f64; 3] pixels (for preprocessing benchmarks). +fn synthetic_linear(w: usize, h: usize) -> Vec<[f64; 3]> { + synthetic_image(w, h) + .chunks_exact(3) + .map(|c| { + [ + srgb_channel_to_linear(c[0]), + srgb_channel_to_linear(c[1]), + srgb_channel_to_linear(c[2]), + ] + }) + .collect() +} + +const SIZES: &[(usize, usize)] = &[(100, 100), (400, 300)]; + +// ── Error diffusion ─────────────────────────────────────────────────────────── + +fn bench_error_diffusion(c: &mut Criterion) { + let mut group = c.benchmark_group("error_diffusion"); + let palette = ColorScheme::Bwgbry.palette(); + + for &(w, h) in SIZES { + let pixels = synthetic_image(w, h); + group.throughput(Throughput::Elements((w * h) as u64)); + + for (name, kernel) in [ + ("floyd_steinberg", &FLOYD_STEINBERG), + ("burkes", &BURKES), + ("jjn", &JARVIS_JUDICE_NINKE), + ] { + group.bench_with_input( + BenchmarkId::new(name, format!("{w}x{h}")), + &(), + |b, _| b.iter(|| error_diffusion_dither(&pixels, w, h, palette, kernel, false)), + ); + } + } + + group.finish(); +} + +// ── Ordered dithering ───────────────────────────────────────────────────────── + +fn bench_ordered_dither(c: &mut Criterion) { + let mut group = c.benchmark_group("ordered_dither"); + let palette = ColorScheme::Bwgbry.palette(); + + for &(w, h) in SIZES { + let pixels = synthetic_image(w, h); + group.throughput(Throughput::Elements((w * h) as u64)); + group.bench_with_input(BenchmarkId::from_parameter(format!("{w}x{h}")), &(), |b, _| { + b.iter(|| ordered_dither(&pixels, w, palette)) + }); + } + + group.finish(); +} + +// ── Direct map ──────────────────────────────────────────────────────────────── + +fn bench_direct_map(c: &mut Criterion) { + let mut group = c.benchmark_group("direct_map"); + let palette = ColorScheme::Bwgbry.palette(); + + for &(w, h) in SIZES { + let pixels = synthetic_image(w, h); + group.throughput(Throughput::Elements((w * h) as u64)); + group.bench_with_input(BenchmarkId::from_parameter(format!("{w}x{h}")), &(), |b, _| { + b.iter(|| direct_map(&pixels, palette)) + }); + } + + group.finish(); +} + +// ── Color matching (inner loop isolation) ──────────────────────────────────── + +fn bench_color_matching(c: &mut Criterion) { + const N: usize = 10_000; + let palette_linear: Vec<[f64; 3]> = ColorScheme::Bwgbry + .palette() + .colors + .iter() + .map(|&[r, g, b]| { + [ + srgb_channel_to_linear(r), + srgb_channel_to_linear(g), + srgb_channel_to_linear(b), + ] + }) + .collect(); + let palette_lab = PaletteLab::from_linear_rgb(&palette_linear); + + // Pre-generate pixels so we don't measure generation time + let pixels: Vec<_> = (0..N) + .map(|i| { + let v = i as f64 / N as f64; + rgb_to_oklab(v, 1.0 - v, v * 0.5) + }) + .collect(); + + let mut group = c.benchmark_group("color_matching"); + group.throughput(Throughput::Elements(N as u64)); + group.bench_function("match_pixel_oklab_6color", |b| { + b.iter(|| { + pixels.iter().map(|&px| match_pixel_oklab(px, &palette_lab, WAB)).sum::() + }) + }); + group.finish(); +} + +// ── Preprocessing ───────────────────────────────────────────────────────────── + +fn bench_preprocessing(c: &mut Criterion) { + let (w, h) = (400, 300); + let palette = &SPECTRA_7_3_6COLOR; + + let mut group = c.benchmark_group("preprocessing"); + group.throughput(Throughput::Elements((w * h) as u64)); + + group.bench_function("auto_compress_dynamic_range", |b| { + b.iter_batched( + || synthetic_linear(w, h), + |mut pixels| auto_compress_dynamic_range(&mut pixels, palette), + criterion::BatchSize::SmallInput, + ) + }); + + group.bench_function("compress_dynamic_range_full", |b| { + b.iter_batched( + || synthetic_linear(w, h), + |mut pixels| compress_dynamic_range(&mut pixels, palette, 1.0), + criterion::BatchSize::SmallInput, + ) + }); + + group.bench_function("gamut_compress_full", |b| { + b.iter_batched( + || synthetic_linear(w, h), + |mut pixels| gamut_compress(&mut pixels, palette, 1.0), + criterion::BatchSize::SmallInput, + ) + }); + + group.finish(); +} + +// ── Full pipeline ────────────────────────────────────────────────────────────── + +fn bench_full_pipeline(c: &mut Criterion) { + let (w, h) = (400, 300); + let pixels = synthetic_image(w, h); + let img = ImageBuffer::new(&pixels, w); + + let mut group = c.benchmark_group("full_pipeline"); + group.throughput(Throughput::Elements((w * h) as u64)); + + group.bench_function("burkes_auto_tone_auto_gamut_6color", |b| { + b.iter(|| { + dither(&img, &SPECTRA_7_3_6COLOR, DitherConfig { + mode: DitherMode::Burkes, + tone: ToneCompression::Auto, + gamut: GamutCompression::Auto, + ..Default::default() + }) + }) + }); + + group.bench_function("burkes_no_preprocessing_mono", |b| { + b.iter(|| { + dither(&img, ColorScheme::Mono, DitherConfig { + mode: DitherMode::Burkes, + tone: ToneCompression::Fixed(0.0), + gamut: GamutCompression::None, + ..Default::default() + }) + }) + }); + + group.finish(); +} + +// ── Real image benchmarks ───────────────────────────────────────────────────── + +/// Full pipeline on real 800×480 photographs — reflects actual usage. +fn bench_real_images(c: &mut Criterion) { + // Images that cover different content characteristics + const FIXTURES: &[(&str, &str)] = &[ + ("frankfurt_nacht.png", "night"), // dark, low contrast + ("unicorn.png", "vivid"), // saturated colors + ("katzi.png", "detail"), // fine detail, varied tones + ("marienplatz.png", "daylight"), // normal outdoor scene + ]; + + let mut group = c.benchmark_group("real_images"); + group.throughput(Throughput::Elements((800 * 480) as u64)); + + for (filename, label) in FIXTURES { + let (pixels, w, h) = load_fixture(filename); + let img = ImageBuffer::new(&pixels, w); + + group.bench_with_input( + BenchmarkId::new("burkes_spectra6_auto", label), + &(), + |b, _| { + b.iter(|| { + dither( + &img, + &SPECTRA_7_3_6COLOR, + DitherMode::Burkes, + true, + ToneCompression::Auto, + GamutCompression::Auto, + ) + }) + }, + ); + } + + group.finish(); +} + +/// Full pipeline on a single full-resolution camera image (6240×4160). +/// Shows realistic throughput for large inputs. +fn bench_full_res(c: &mut Criterion) { + let (pixels, w, h) = load_fixture("benchmark_only/test7.jpeg"); + let img = ImageBuffer::new(&pixels, w); + + let mut group = c.benchmark_group("full_res"); + group.throughput(Throughput::Elements((w * h) as u64)); + group.sample_size(20); // fewer samples — each iteration is slow + + group.bench_function("burkes_spectra6_auto", |b| { + b.iter(|| { + dither(&img, &SPECTRA_7_3_6COLOR, DitherConfig { + mode: DitherMode::Burkes, + tone: ToneCompression::Auto, + gamut: GamutCompression::Auto, + ..Default::default() + }) + }) + }); + + group.finish(); +} + +// ── Registration ────────────────────────────────────────────────────────────── + +criterion_group!( + benches, + bench_error_diffusion, + bench_ordered_dither, + bench_direct_map, + bench_color_matching, + bench_preprocessing, + bench_full_pipeline, + bench_real_images, + bench_full_res, +); +criterion_main!(benches); \ No newline at end of file diff --git a/packages/rust/core/examples/dither.rs b/packages/rust/core/examples/dither.rs new file mode 100644 index 0000000..8923b46 --- /dev/null +++ b/packages/rust/core/examples/dither.rs @@ -0,0 +1,129 @@ +/// Quick visual test: dither a real image and save the result. +/// +/// Usage: +/// cargo run --example dither [output] [scheme] [mode] [tone] [gamut] +/// +/// Examples: +/// cargo run --example dither photo.jpg +/// cargo run --example dither photo.jpg out.png spectra stucki auto none +/// cargo run --example dither photo.jpg out.png spectra stucki 0.8 0.5 +/// +/// Schemes: mono, bwr, bwy, bwry, bwgbry, grayscale4, grayscale8, grayscale16 +/// spectra, spectra_v2, mono_4_26, bwry_4_2, bwry_3_97, solum_bwr, hanshow_bwr, hanshow_bwy +/// Modes: none, ordered, floyd_steinberg, burkes, atkinson, stucki, sierra, sierra_lite, jjn +/// Tone: auto, none, 0.0–1.0 (default: auto) +/// Gamut: none, auto, 0.0–1.0 (default: none) + +use epaper_dithering_core::enums::{DitherMode, GamutCompression, ToneCompression}; +use epaper_dithering_core::measured_palettes::{ + BWRY_3_97, BWRY_4_2, HANSHOW_BWR, HANSHOW_BWY, MONO_4_26, SOLUM_BWR, SPECTRA_7_3_6COLOR, + SPECTRA_7_3_6COLOR_V2, +}; +use epaper_dithering_core::palettes::{ColorScheme, Palette}; +use epaper_dithering_core::types::ImageBuffer; +use epaper_dithering_core::{dither, DitherConfig}; +use image::{ImageReader, RgbImage}; +use std::env; + +fn main() { + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: dither [output] [scheme] [mode]"); + std::process::exit(1); + } + + let input_path = &args[1]; + let output_path = args.get(2).map(String::as_str).unwrap_or("dithered.png"); + let scheme_name = args.get(3).map(String::as_str).unwrap_or("bwr"); + let mode_name = args.get(4).map(String::as_str).unwrap_or("burkes"); + let tone_name = args.get(5).map(String::as_str).unwrap_or("auto"); + let gamut_name = args.get(6).map(String::as_str).unwrap_or("none"); + + let palette: &Palette = match scheme_name { + "mono" => ColorScheme::Mono.palette(), + "bwr" => ColorScheme::Bwr.palette(), + "bwy" => ColorScheme::Bwy.palette(), + "bwry" => ColorScheme::Bwry.palette(), + "bwgbry" => ColorScheme::Bwgbry.palette(), + "grayscale4" => ColorScheme::Grayscale4.palette(), + "grayscale8" => ColorScheme::Grayscale8.palette(), + "grayscale16" => ColorScheme::Grayscale16.palette(), + "spectra" => &SPECTRA_7_3_6COLOR, + "spectra_v2" => &SPECTRA_7_3_6COLOR_V2, + "mono_4_26" => &MONO_4_26, + "bwry_4_2" => &BWRY_4_2, + "bwry_3_97" => &BWRY_3_97, + "solum_bwr" => &SOLUM_BWR, + "hanshow_bwr" => &HANSHOW_BWR, + "hanshow_bwy" => &HANSHOW_BWY, + other => { eprintln!("Unknown scheme: {other}"); std::process::exit(1); } + }; + + let mode = match mode_name { + "none" => DitherMode::None, + "ordered" => DitherMode::Ordered, + "floyd_steinberg" => DitherMode::FloydSteinberg, + "burkes" => DitherMode::Burkes, + "atkinson" => DitherMode::Atkinson, + "stucki" => DitherMode::Stucki, + "sierra" => DitherMode::Sierra, + "sierra_lite" => DitherMode::SierraLite, + "jjn" => DitherMode::JarvisJudiceNinke, + other => { eprintln!("Unknown mode: {other}"); std::process::exit(1); } + }; + + // Load image + let img = ImageReader::open(input_path) + .expect("failed to open image") + .decode() + .expect("failed to decode image") + .into_rgb8(); + + let (width, height) = img.dimensions(); + let buf = ImageBuffer::new(img.as_raw(), width as usize); + + let tone = match tone_name { + "auto" => ToneCompression::Auto, + "none" | "0" => ToneCompression::Fixed(0.0), + s => match s.parse::() { + Ok(v) => ToneCompression::Fixed(v), + Err(_) => { eprintln!("Unknown tone: {s}"); std::process::exit(1); } + }, + }; + + let gamut = match gamut_name { + "none" | "0" => GamutCompression::None, + "auto" | "1" => GamutCompression::Auto, + s => match s.parse::() { + Ok(v) => GamutCompression::Fixed(v), + Err(_) => { eprintln!("Unknown gamut: {s}"); std::process::exit(1); } + }, + }; + + println!("Input: {input_path} ({width}x{height})"); + println!("Scheme: {scheme_name} Mode: {mode_name} Tone: {tone_name} Gamut: {gamut_name}"); + + let t0 = std::time::Instant::now(); + let indices = dither(&buf, palette, DitherConfig { + mode, tone, gamut, ..Default::default() + }); + let elapsed = t0.elapsed(); + + println!("Dither: {:.1}ms ({} mpx/s)", + elapsed.as_secs_f64() * 1000.0, + (width as f64 * height as f64 / 1_000_000.0 / elapsed.as_secs_f64()) as u64, + ); + + // Map indices back to RGB for a viewable output + let mut out_pixels: Vec = Vec::with_capacity(width as usize * height as usize * 3); + for idx in &indices { + out_pixels.extend_from_slice(&palette.colors[*idx as usize]); + } + + RgbImage::from_raw(width, height, out_pixels) + .expect("buffer size mismatch") + .save(output_path) + .expect("failed to save output"); + + println!("Output: {output_path}"); +} diff --git a/packages/rust/core/src/algorithms.rs b/packages/rust/core/src/algorithms.rs new file mode 100644 index 0000000..ddd097c --- /dev/null +++ b/packages/rust/core/src/algorithms.rs @@ -0,0 +1,438 @@ +//! Error diffusion and ordered dithering on raw RGB pixel buffers. + +use crate::color_space::{srgb_channel_to_linear, srgb_fraction_to_linear}; +use crate::color_space_lab::{PaletteLab, match_pixel_oklab, rgb_to_oklab, WAB}; +use crate::palettes::Palette; +use rayon::prelude::*; + +// ── Kernel definition ───────────────────────────────────────────────────────── + +/// Pixel offset + pre-divided weight for one kernel entry. +pub struct KernelOffset { + pub dx: i32, + pub dy: i32, + pub weight: f64, +} + +pub struct Kernel { + pub offsets: &'static [KernelOffset], +} + +// ── Kernel constants ────────────────────────────────────────────────────────── + +pub static FLOYD_STEINBERG: Kernel = Kernel { + offsets: &[ + KernelOffset { dx: 1, dy: 0, weight: 7.0 / 16.0 }, + KernelOffset { dx: -1, dy: 1, weight: 3.0 / 16.0 }, + KernelOffset { dx: 0, dy: 1, weight: 5.0 / 16.0 }, + KernelOffset { dx: 1, dy: 1, weight: 1.0 / 16.0 }, + ], +}; + +pub static ATKINSON: Kernel = Kernel { + offsets: &[ + KernelOffset { dx: 1, dy: 0, weight: 1.0 / 8.0 }, + KernelOffset { dx: 2, dy: 0, weight: 1.0 / 8.0 }, + KernelOffset { dx: -1, dy: 1, weight: 1.0 / 8.0 }, + KernelOffset { dx: 0, dy: 1, weight: 1.0 / 8.0 }, + KernelOffset { dx: 1, dy: 1, weight: 1.0 / 8.0 }, + KernelOffset { dx: 0, dy: 2, weight: 1.0 / 8.0 }, + ], +}; + +pub static BURKES: Kernel = Kernel { + offsets: &[ + KernelOffset { dx: 1, dy: 0, weight: 8.0 / 32.0 }, + KernelOffset { dx: 2, dy: 0, weight: 4.0 / 32.0 }, + KernelOffset { dx: -2, dy: 1, weight: 2.0 / 32.0 }, + KernelOffset { dx: -1, dy: 1, weight: 4.0 / 32.0 }, + KernelOffset { dx: 0, dy: 1, weight: 8.0 / 32.0 }, + KernelOffset { dx: 1, dy: 1, weight: 4.0 / 32.0 }, + KernelOffset { dx: 2, dy: 1, weight: 2.0 / 32.0 }, + ], +}; + +pub static STUCKI: Kernel = Kernel { + offsets: &[ + KernelOffset { dx: 1, dy: 0, weight: 8.0 / 42.0 }, + KernelOffset { dx: 2, dy: 0, weight: 4.0 / 42.0 }, + KernelOffset { dx: -2, dy: 1, weight: 2.0 / 42.0 }, + KernelOffset { dx: -1, dy: 1, weight: 4.0 / 42.0 }, + KernelOffset { dx: 0, dy: 1, weight: 8.0 / 42.0 }, + KernelOffset { dx: 1, dy: 1, weight: 4.0 / 42.0 }, + KernelOffset { dx: 2, dy: 1, weight: 2.0 / 42.0 }, + KernelOffset { dx: -2, dy: 2, weight: 1.0 / 42.0 }, + KernelOffset { dx: -1, dy: 2, weight: 2.0 / 42.0 }, + KernelOffset { dx: 0, dy: 2, weight: 4.0 / 42.0 }, + KernelOffset { dx: 1, dy: 2, weight: 2.0 / 42.0 }, + KernelOffset { dx: 2, dy: 2, weight: 1.0 / 42.0 }, + ], +}; + +pub static SIERRA: Kernel = Kernel { + offsets: &[ + KernelOffset { dx: 1, dy: 0, weight: 5.0 / 32.0 }, + KernelOffset { dx: 2, dy: 0, weight: 3.0 / 32.0 }, + KernelOffset { dx: -2, dy: 1, weight: 2.0 / 32.0 }, + KernelOffset { dx: -1, dy: 1, weight: 4.0 / 32.0 }, + KernelOffset { dx: 0, dy: 1, weight: 5.0 / 32.0 }, + KernelOffset { dx: 1, dy: 1, weight: 4.0 / 32.0 }, + KernelOffset { dx: 2, dy: 1, weight: 2.0 / 32.0 }, + KernelOffset { dx: -1, dy: 2, weight: 2.0 / 32.0 }, + KernelOffset { dx: 0, dy: 2, weight: 3.0 / 32.0 }, + KernelOffset { dx: 1, dy: 2, weight: 2.0 / 32.0 }, + ], +}; + +pub static SIERRA_LITE: Kernel = Kernel { + offsets: &[ + KernelOffset { dx: 1, dy: 0, weight: 2.0 / 4.0 }, + KernelOffset { dx: -1, dy: 1, weight: 1.0 / 4.0 }, + KernelOffset { dx: 0, dy: 1, weight: 1.0 / 4.0 }, + ], +}; + +pub static JARVIS_JUDICE_NINKE: Kernel = Kernel { + offsets: &[ + KernelOffset { dx: 1, dy: 0, weight: 7.0 / 48.0 }, + KernelOffset { dx: 2, dy: 0, weight: 5.0 / 48.0 }, + KernelOffset { dx: -2, dy: 1, weight: 3.0 / 48.0 }, + KernelOffset { dx: -1, dy: 1, weight: 5.0 / 48.0 }, + KernelOffset { dx: 0, dy: 1, weight: 7.0 / 48.0 }, + KernelOffset { dx: 1, dy: 1, weight: 5.0 / 48.0 }, + KernelOffset { dx: 2, dy: 1, weight: 3.0 / 48.0 }, + KernelOffset { dx: -2, dy: 2, weight: 1.0 / 48.0 }, + KernelOffset { dx: -1, dy: 2, weight: 3.0 / 48.0 }, + KernelOffset { dx: 0, dy: 2, weight: 5.0 / 48.0 }, + KernelOffset { dx: 1, dy: 2, weight: 3.0 / 48.0 }, + KernelOffset { dx: 2, dy: 2, weight: 1.0 / 48.0 }, + ], +}; + +// ── Palette setup helper ───────────────────────────────────────────────────── + +fn build_palette_lab(palette: &Palette) -> (Vec<[f64; 3]>, PaletteLab) { + let palette_linear: Vec<[f64; 3]> = palette + .colors + .iter() + .map(|&[r, g, b]| { + [ + srgb_channel_to_linear(r), + srgb_channel_to_linear(g), + srgb_channel_to_linear(b), + ] + }) + .collect(); + let palette_lab = PaletteLab::from_linear_rgb(&palette_linear); + (palette_linear, palette_lab) +} + +// ── Main function ───────────────────────────────────────────────────────────── + +/// Error diffusion dither. Returns palette indices (len = width × height). +pub fn error_diffusion_dither( + pixels: &[u8], + width: usize, + height: usize, + palette: &Palette, + kernel: &Kernel, + serpentine: bool, +) -> Vec { + let (_palette_linear, palette_lab) = build_palette_lab(palette); + + // Palette sRGB as f64 for error computation + let palette_srgb_f: Vec<[f64; 3]> = palette + .colors + .iter() + .map(|&[r, g, b]| [r as f64, g as f64, b as f64]) + .collect(); + + // Working buffer in sRGB float space [0, 255]; accumulates diffused error. + let mut buf: Vec = pixels.iter().map(|&v| v as f64).collect(); + + // LUT: u8 sRGB -> linear f64 (avoids powf per pixel in the inner loop) + let lut: Vec = (0u8..=255).map(srgb_channel_to_linear).collect(); + + let mut output = vec![0u8; width * height]; + + for y in 0..height { + // Serpentine: odd rows scan right-to-left + let reverse = serpentine && y % 2 == 1; + + for xi in 0..width { + let x = if reverse { width - 1 - xi } else { xi }; + let idx = (y * width + x) * 3; + + let rs = buf[idx].clamp(0.0, 255.0); + let gs = buf[idx + 1].clamp(0.0, 255.0); + let bs = buf[idx + 2].clamp(0.0, 255.0); + + let r_lin = lut[rs.round() as usize]; + let g_lin = lut[gs.round() as usize]; + let b_lin = lut[bs.round() as usize]; + + let pixel_lab = rgb_to_oklab(r_lin, g_lin, b_lin); + let best_idx = match_pixel_oklab(pixel_lab, &palette_lab, WAB); + + output[y * width + x] = best_idx as u8; + + // Quantization error in sRGB space + let err_r = rs - palette_srgb_f[best_idx][0]; + let err_g = gs - palette_srgb_f[best_idx][1]; + let err_b = bs - palette_srgb_f[best_idx][2]; + + for offset in kernel.offsets { + let effective_dx = if reverse { -offset.dx } else { offset.dx }; + + let nx = x as i64 + effective_dx as i64; + let ny = y as i64 + offset.dy as i64; + + if nx >= 0 && nx < width as i64 && ny >= 0 && ny < height as i64 { + let ni = (ny as usize * width + nx as usize) * 3; + buf[ni] += err_r * offset.weight; + buf[ni + 1] += err_g * offset.weight; + buf[ni + 2] += err_b * offset.weight; + } + } + } + } + + output +} + +// ── Thin wrappers ───────────────────────────────────────────────────────────── + +pub fn floyd_steinberg(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec { + error_diffusion_dither(pixels, w, h, palette, &FLOYD_STEINBERG, serpentine) +} +pub fn atkinson(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec { + error_diffusion_dither(pixels, w, h, palette, &ATKINSON, serpentine) +} +pub fn burkes(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec { + error_diffusion_dither(pixels, w, h, palette, &BURKES, serpentine) +} +pub fn stucki(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec { + error_diffusion_dither(pixels, w, h, palette, &STUCKI, serpentine) +} +pub fn sierra(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec { + error_diffusion_dither(pixels, w, h, palette, &SIERRA, serpentine) +} +pub fn sierra_lite(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec { + error_diffusion_dither(pixels, w, h, palette, &SIERRA_LITE, serpentine) +} +pub fn jarvis_judice_ninke(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec { + error_diffusion_dither(pixels, w, h, palette, &JARVIS_JUDICE_NINKE, serpentine) +} + +// ── Direct palette map (no dithering) ──────────────────────────────────────── + +/// Nearest-color mapping with no dithering. Each pixel maps independently. +pub fn direct_map(pixels: &[u8], palette: &Palette) -> Vec { + let (_, palette_lab) = build_palette_lab(palette); + pixels + .par_chunks(3) + .map(|rgb| { + let r = srgb_channel_to_linear(rgb[0]); + let g = srgb_channel_to_linear(rgb[1]); + let b = srgb_channel_to_linear(rgb[2]); + let lab = rgb_to_oklab(r, g, b); + match_pixel_oklab(lab, &palette_lab, WAB) as u8 + }) + .collect() +} + +// ── Ordered (Bayer) dithering ───────────────────────────────────────────────── + +// 4×4 Bayer matrix, normalized to [-0.5, 0.5]. Indexed as [y % 4][x % 4]. +// Values = (bayer_entry / 16.0) - 0.5 for the standard ordered Bayer matrix. +// Written as exact fractions to keep precision (all are multiples of 1/16). +const BAYER_4X4: [[f64; 4]; 4] = [ + [-0.5000, 0.0000, -0.3750, 0.1250], + [ 0.2500, -0.2500, 0.3750, -0.1250], + [-0.3125, 0.1875, -0.4375, 0.0625], + [ 0.4375, -0.0625, 0.3125, -0.1875], +]; + +/// Ordered (Bayer 4×4) dither. Pixels are independent — parallelized with rayon. +/// +/// The Bayer threshold is added in sRGB-fraction space, not linear. Linear-space +/// thresholding produces ~3× the perceptual spread in shadows compared to highlights +/// (the sRGB gamma is convex, so a fixed linear ±0.5 step is huge near 0 and tiny near 1). +/// sRGB-space thresholding gives uniform perceptual dot density across the tonal range, +/// matching how error diffusion already accumulates error. See GitHub issue #27. +pub fn ordered_dither(pixels: &[u8], width: usize, palette: &Palette) -> Vec { + let (_palette_linear, palette_lab) = build_palette_lab(palette); + + pixels + .par_chunks(3) + .enumerate() + .map(|(i, rgb)| { + let x = i % width; + let y = i / width; + + let threshold = BAYER_4X4[y % 4][x % 4]; + + // Add threshold in sRGB-fraction space, then convert to linear for OKLab match. + let r_srgb = (rgb[0] as f64 / 255.0 + threshold).clamp(0.0, 1.0); + let g_srgb = (rgb[1] as f64 / 255.0 + threshold).clamp(0.0, 1.0); + let b_srgb = (rgb[2] as f64 / 255.0 + threshold).clamp(0.0, 1.0); + + let lab = rgb_to_oklab( + srgb_fraction_to_linear(r_srgb), + srgb_fraction_to_linear(g_srgb), + srgb_fraction_to_linear(b_srgb), + ); + match_pixel_oklab(lab, &palette_lab, WAB) as u8 + }) + .collect() +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::palettes::ColorScheme; + + fn solid_image(r: u8, g: u8, b: u8, w: usize, h: usize) -> Vec { + vec![r, g, b].into_iter().cycle().take(w * h * 3).collect() + } + + #[test] + fn solid_white_maps_to_white() { + let pixels = solid_image(255, 255, 255, 4, 4); + let out = floyd_steinberg(&pixels, 4, 4, ColorScheme::Mono.palette(), true); + // White palette index in Mono is 1 + assert!(out.iter().all(|&v| v == 1)); + } + + #[test] + fn solid_black_maps_to_black() { + let pixels = solid_image(0, 0, 0, 4, 4); + let out = floyd_steinberg(&pixels, 4, 4, ColorScheme::Mono.palette(), true); + assert!(out.iter().all(|&v| v == 0)); + } + + #[test] + fn output_length_matches_pixels() { + let pixels = solid_image(128, 64, 200, 10, 7); + let out = floyd_steinberg(&pixels, 10, 7, ColorScheme::Bwr.palette(), true); + assert_eq!(out.len(), 10 * 7); + } + + #[test] + fn all_algorithms_produce_correct_length() { + let pixels = solid_image(128, 64, 200, 10, 7); + let pal = ColorScheme::Bwr.palette(); + assert_eq!(burkes(&pixels, 10, 7, pal, false).len(), 70); + assert_eq!(atkinson(&pixels, 10, 7, pal, false).len(), 70); + assert_eq!(stucki(&pixels, 10, 7, pal, false).len(), 70); + assert_eq!(sierra(&pixels, 10, 7, pal, false).len(), 70); + assert_eq!(sierra_lite(&pixels, 10, 7, pal, false).len(), 70); + assert_eq!(jarvis_judice_ninke(&pixels, 10, 7, pal, false).len(), 70); + assert_eq!(ordered_dither(&pixels, 10, pal).len(), 70); + assert_eq!(direct_map(&pixels, pal).len(), 70); + } + + #[test] + fn direct_map_pure_red_maps_to_red_in_bwr() { + // BWR palette: index 0=black, 1=white, 2=red — pure red should map to red ink + let pixels = vec![255u8, 0, 0]; + let out = direct_map(&pixels, ColorScheme::Bwr.palette()); + assert_eq!(out[0], 2, "pure sRGB red should map to red ink (index 2) in BWR"); + } + + #[test] + fn direct_map_pure_blue_maps_to_blue_in_bwgbry() { + // BWGBRY palette order: 0=black, 1=white, 2=yellow, 3=red, 4=blue, 5=green + let pixels = vec![0u8, 0, 255]; + let out = direct_map(&pixels, ColorScheme::Bwgbry.palette()); + assert_eq!(out[0], 4, "pure sRGB blue should map to blue ink (index 4) in BWGBRY"); + } + + #[test] + fn serpentine_differs_from_raster_on_gradient() { + // A gradient (non-uniform content) should produce different output + // with serpentine=true vs serpentine=false + let pixels: Vec = (0u8..=255) + .flat_map(|v| [v, v / 2, 255 - v]) + .cycle() + .take(16 * 16 * 3) + .collect(); + let raster = floyd_steinberg(&pixels, 16, 16, ColorScheme::Mono.palette(), false); + let serpentine = floyd_steinberg(&pixels, 16, 16, ColorScheme::Mono.palette(), true); + assert_ne!(raster, serpentine, "serpentine and raster should differ on a gradient"); + } + + /// Property test for issue #27: ordered dither activity should be perceptually uniform + /// across the tonal range, not skewed toward shadows. + /// + /// On a horizontal sRGB ramp (one column per luminance level), each Bayer-period band + /// of columns contains the same `x % 4` thresholds and therefore the same *kinds* of + /// dithering decisions. Under linear-space thresholding, the perceptual spread of the + /// threshold is huge in shadows and tiny in highlights, so transitions cluster heavily + /// in the dark bands. Under sRGB-space thresholding, transitions distribute roughly + /// evenly across mid-tone bands. + /// + /// We measure transitions (adjacent columns with differing palette indices) per band + /// in the mid-tone region and assert the max/min ratio stays modest. Empirically the + /// linear-space implementation produces ratio ≈ 13.5; sRGB-space ≈ 4.3. + #[test] + fn ordered_dither_activity_is_perceptually_uniform() { + const W: usize = 256; + const H: usize = 16; // 4 full Bayer cells vertically + const BANDS: usize = 8; + const BAND_W: usize = W / BANDS; + + // Horizontal sRGB ramp 0..255. + let mut pixels = Vec::with_capacity(W * H * 3); + for _ in 0..H { + for x in 0..W { + let v = x as u8; + pixels.extend_from_slice(&[v, v, v]); + } + } + let out = ordered_dither(&pixels, W, ColorScheme::Mono.palette()); + + // Count per-band horizontal transitions (adjacent columns with differing index). + let mut transitions = [0usize; BANDS]; + for y in 0..H { + for x in 0..W - 1 { + if out[y * W + x] != out[y * W + x + 1] { + transitions[(x / BAND_W).min(BANDS - 1)] += 1; + } + } + } + + // Mid-tone bands (skip the first and last — they're near pure black/white where + // the threshold is clamped and dither activity legitimately falls off). + let mid = &transitions[1..BANDS - 1]; + let max = *mid.iter().max().unwrap(); + let min = *mid.iter().min().unwrap(); + assert!(min > 0, "every mid-tone band should have at least one transition: {transitions:?}"); + let ratio = max as f64 / min as f64; + assert!( + ratio < 5.0, + "ordered-dither activity is perceptually skewed (max/min = {ratio:.2}); \ + expected sRGB-space ordered dither to spread roughly uniformly across \ + mid-tones. Per-band transitions: {transitions:?}" + ); + } + + #[test] + fn serpentine_first_row_matches_raster() { + // Row 0 is always scanned left-to-right regardless of serpentine flag + let pixels: Vec = (0u8..=255) + .flat_map(|v| [v, 128u8.wrapping_add(v), 64u8]) + .cycle() + .take(8 * 4 * 3) + .collect(); + let raster = floyd_steinberg(&pixels, 8, 4, ColorScheme::Mono.palette(), false); + let serpentine = floyd_steinberg(&pixels, 8, 4, ColorScheme::Mono.palette(), true); + assert_eq!( + &raster[0..8], + &serpentine[0..8], + "first row must be identical (both scan left-to-right)" + ); + } +} diff --git a/packages/rust/core/src/color_space.rs b/packages/rust/core/src/color_space.rs new file mode 100644 index 0000000..518e29b --- /dev/null +++ b/packages/rust/core/src/color_space.rs @@ -0,0 +1,46 @@ +/// sRGB [0–255] → linear [0.0–1.0]. IEC 61966-2-1 piecewise transfer function. +pub fn srgb_channel_to_linear(value: u8) -> f64 { + srgb_fraction_to_linear(value as f64 / 255.0) +} + +/// Continuous sRGB fraction [0.0–1.0] → linear [0.0–1.0]. Same gamma curve as +/// `srgb_channel_to_linear` but operates on continuous input — useful when sub-byte +/// precision is needed (e.g. ordered dither perturbs pixels in sRGB-fraction space +/// before the linear conversion). +pub fn srgb_fraction_to_linear(value: f64) -> f64 { + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } +} + +/// Linear [0.0–1.0] → sRGB [0–255]. Inverse of `srgb_channel_to_linear`. +pub fn linear_channel_to_srgb(linear: f64) -> u8 { + let srgb = if linear <= 0.0031308 { + linear * 12.92 + } else { + 1.055 * linear.powf(1.0 / 2.4) - 0.055 + }; + (srgb * 255.0).round() as u8 +} + + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + #[test] + fn black_and_white_are_identity() { + assert_relative_eq!(srgb_channel_to_linear(0), 0.0); + assert_relative_eq!(srgb_channel_to_linear(255), 1.0, epsilon = 1e-6); + } + + #[test] + fn roundtrip() { + for v in [0u8, 1, 10, 127, 128, 254, 255] { + assert_eq!(linear_channel_to_srgb(srgb_channel_to_linear(v)), v); + } + } +} diff --git a/packages/rust/core/src/color_space_lab.rs b/packages/rust/core/src/color_space_lab.rs new file mode 100644 index 0000000..8669912 --- /dev/null +++ b/packages/rust/core/src/color_space_lab.rs @@ -0,0 +1,215 @@ +//! OKLab color space and color matching for dithering. + +// sRGB -> XYZ matrix (D65 illuminant, BruceLinbloom) +const M_RGB_XYZ: [[f64; 3]; 3] = [ + [0.4124564, 0.3575761, 0.1804375], + [0.2126729, 0.7151522, 0.0721750], + [0.0193339, 0.1191920, 0.9503041], +]; + +// XYZ -> LMS (M1, Ottosson) +const M1: [[f64; 3]; 3] = [ + [0.8189330101, 0.3618667424, -0.1288597137], + [0.0329845436, 0.9293118715, 0.0361456387], + [0.0482003018, 0.2643662691, 0.6338517070], +]; + +// cbrt(LMS) -> OKLab (M2, Ottosson) +const M2: [[f64; 3]; 3] = [ + [0.2104542553, 0.7936177850, -0.0040720468], + [1.9779984951, -2.4285922050, 0.4505937099], + [0.0259040371, 0.7827717662, -0.8086757660], +]; + +/// Chromatic-axes weight for `match_pixel_oklab`. +/// +/// Empirically validated by `examples/wab_sweep.rs` against the regression fixture set +/// (Burkes + Spectra 6-color, mean OKLab ΔE on 4×4-block-averaged outputs). 1.5 is a +/// conservative choice that improves saturated subjects without over-saturating neutrals; +/// see GitHub issue #28 for the methodology and justification. +pub const WAB: f64 = 1.5; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct OkLab { + pub l: f64, + pub a: f64, + pub b: f64, +} + +/// Linear RGB → OKLab. Pipeline: RGB → XYZ → LMS → cbrt → OKLab. +pub fn rgb_to_oklab(r: f64, g: f64, b: f64) -> OkLab { + let x = M_RGB_XYZ[0][0] * r + M_RGB_XYZ[0][1] * g + M_RGB_XYZ[0][2] * b; + let y = M_RGB_XYZ[1][0] * r + M_RGB_XYZ[1][1] * g + M_RGB_XYZ[1][2] * b; + let z = M_RGB_XYZ[2][0] * r + M_RGB_XYZ[2][1] * g + M_RGB_XYZ[2][2] * b; + + let l = M1[0][0] * x + M1[0][1] * y + M1[0][2] * z; + let m = M1[1][0] * x + M1[1][1] * y + M1[1][2] * z; + let s = M1[2][0] * x + M1[2][1] * y + M1[2][2] * z; + + let l_ = l.cbrt(); + let m_ = m.cbrt(); + let s_ = s.cbrt(); + + OkLab { + l: M2[0][0] * l_ + M2[0][1] * m_ + M2[0][2] * s_, + a: M2[1][0] * l_ + M2[1][1] * m_ + M2[1][2] * s_, + b: M2[2][0] * l_ + M2[2][1] * m_ + M2[2][2] * s_, + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PaletteLab { + pub colors: Vec, +} + +impl PaletteLab { + pub fn from_linear_rgb(palette: &[[f64; 3]]) -> Self { + let colors = palette.iter().map(|c| rgb_to_oklab(c[0], c[1], c[2])).collect(); + Self { colors } + } +} + +// M2_inv: OKLab -> LMS^(1/3) (Ottosson) +const M2_INV: [[f64; 3]; 3] = [ + [1.0, 0.3963377774, 0.2158037573], + [1.0, -0.1055613458, -0.0638541728], + [1.0, -0.0894841775, -1.2914855480], +]; + +// M1_inv: LMS -> XYZ (Ottosson) +const M1_INV: [[f64; 3]; 3] = [ + [ 1.2270138511035211, -0.5577999806518222, 0.2812561489664678], + [-0.0405801784232806, 1.1122568696168302, -0.0716766786656012], + [-0.0763812845057069, -0.4214819784180127, 1.5861632204407947], +]; + +// XYZ -> linear sRGB (IEC 61966-2-1 D65) +const M_XYZ_RGB: [[f64; 3]; 3] = [ + [ 3.2404542, -1.5371385, -0.4985314], + [-0.9692660, 1.8760108, 0.0415560], + [ 0.0556434, -0.2040259, 1.0572252], +]; + +/// OKLab → linear RGB. Output is clamped to [0, 1]. Inverse of `rgb_to_oklab`. +pub fn oklab_to_rgb(lab: OkLab) -> [f64; 3] { + let l_ = M2_INV[0][0] * lab.l + M2_INV[0][1] * lab.a + M2_INV[0][2] * lab.b; + let m_ = M2_INV[1][0] * lab.l + M2_INV[1][1] * lab.a + M2_INV[1][2] * lab.b; + let s_ = M2_INV[2][0] * lab.l + M2_INV[2][1] * lab.a + M2_INV[2][2] * lab.b; + + let l = l_ * l_ * l_; + let m = m_ * m_ * m_; + let s = s_ * s_ * s_; + + let x = M1_INV[0][0] * l + M1_INV[0][1] * m + M1_INV[0][2] * s; + let y = M1_INV[1][0] * l + M1_INV[1][1] * m + M1_INV[1][2] * s; + let z = M1_INV[2][0] * l + M1_INV[2][1] * m + M1_INV[2][2] * s; + + let r = M_XYZ_RGB[0][0] * x + M_XYZ_RGB[0][1] * y + M_XYZ_RGB[0][2] * z; + let g = M_XYZ_RGB[1][0] * x + M_XYZ_RGB[1][1] * y + M_XYZ_RGB[1][2] * z; + let b = M_XYZ_RGB[2][0] * x + M_XYZ_RGB[2][1] * y + M_XYZ_RGB[2][2] * z; + + [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)] +} + +/// Returns the index of the closest palette color, using weighted Cartesian OKLab distance. +/// +/// `dist² = (1·dL)² + (wab·da)² + (wab·db)²` +/// +/// `wab > 1.0` boosts the chromatic axes relative to lightness, encouraging use of color +/// inks on saturated subjects without the achromatic singularity that the LCH formulation +/// suffers from. With `wab = 1.0` this reduces to plain Euclidean OKLab. +pub fn match_pixel_oklab(pixel: OkLab, palette: &PaletteLab, wab: f64) -> usize { + let mut best_idx = 0; + let mut best_dist = f64::INFINITY; + let wab_sq = wab * wab; + for (i, pal) in palette.colors.iter().enumerate() { + let dl = pixel.l - pal.l; + let da = pixel.a - pal.a; + let db = pixel.b - pal.b; + let dist = dl * dl + wab_sq * (da * da + db * db); + if dist < best_dist { + best_dist = dist; + best_idx = i; + } + } + best_idx +} + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_relative_eq; + + #[test] + fn black_is_zero() { + let lab = rgb_to_oklab(0.0, 0.0, 0.0); + assert_relative_eq!(lab.l, 0.0, epsilon = 1e-6); + assert_relative_eq!(lab.a, 0.0, epsilon = 1e-6); + assert_relative_eq!(lab.b, 0.0, epsilon = 1e-6); + } + + #[test] + fn white_l_is_one() { + let lab = rgb_to_oklab(1.0, 1.0, 1.0); + assert_relative_eq!(lab.l, 1.0, epsilon = 1e-4); + assert_relative_eq!(lab.a, 0.0, epsilon = 1e-4); + assert_relative_eq!(lab.b, 0.0, epsilon = 1e-4); + } + + #[test] + fn match_exact_palette_color() { + let red_linear = [0.2126, 0.0, 0.0_f64]; + let palette = PaletteLab::from_linear_rgb(&[red_linear, [0.0, 0.7152, 0.0]]); + let pixel = rgb_to_oklab(red_linear[0], red_linear[1], red_linear[2]); + assert_eq!(match_pixel_oklab(pixel, &palette, WAB), 0); + } + + /// Cartesian OKLab matching does not collapse against achromatic palette colors; + /// vivid purple that the legacy LCH formulation sent to black gets routed to a chromatic ink. + #[test] + fn oklab_cartesian_avoids_achromatic_attractor() { + let palette = PaletteLab::from_linear_rgb(&[ + [0.005, 0.005, 0.005], // black (idx 0) + [0.85, 0.85, 0.85], // white (idx 1) + [0.55, 0.45, 0.0], // dim yellow (idx 2) + [0.18, 0.01, 0.0], // dim red (idx 3) + [0.01, 0.02, 0.18], // dim blue (idx 4) + [0.02, 0.18, 0.04], // dim green (idx 5) + ]); + let purple = rgb_to_oklab(0.4, 0.0, 0.6); + for wab in [1.0_f64, 1.25, 1.5, 1.75, 2.0] { + let idx = match_pixel_oklab(purple, &palette, wab); + assert_ne!(idx, 0, "wab={wab}: purple must not map to black"); + assert_ne!(idx, 1, "wab={wab}: purple must not map to white"); + } + } + + /// Sanity: with wab = 1.0 the function is plain Euclidean OKLab, so it picks the + /// palette entry that minimizes `dL² + da² + db²`. + #[test] + fn oklab_unweighted_matches_euclidean() { + let palette_rgb = [[0.1_f64, 0.0, 0.0], [0.0, 0.7152, 0.0]]; + let palette = PaletteLab::from_linear_rgb(&palette_rgb); + let pixel = rgb_to_oklab(0.0, 0.7152, 0.0); + assert_eq!(match_pixel_oklab(pixel, &palette, 1.0), 1); + assert_eq!(match_pixel_oklab(pixel, &palette, 1.5), 1); + } + + /// Hue still wins under Cartesian OKLab matching: a medium-red pixel goes to a darker + /// red rather than a same-lightness gray. The `wab > 1.0` boost on `(da, db)` + /// ensures color separation dominates lightness. + #[test] + fn oklab_cartesian_favors_hue_over_lightness() { + let target = rgb_to_oklab(0.30, 0.04, 0.04); // medium red + let darker_red = [0.10_f64, 0.012, 0.012]; // hue match + let neutral_gray = [0.30_f64, 0.30, 0.30]; // lightness match + let palette = PaletteLab::from_linear_rgb(&[darker_red, neutral_gray]); + for wab in [1.25, 1.5, 1.75, 2.0_f64] { + assert_eq!( + match_pixel_oklab(target, &palette, wab), 0, + "wab={wab}: should prefer correct hue (darker red) over correct lightness (gray)" + ); + } + } + +} diff --git a/packages/rust/core/src/enums.rs b/packages/rust/core/src/enums.rs new file mode 100644 index 0000000..b09cc20 --- /dev/null +++ b/packages/rust/core/src/enums.rs @@ -0,0 +1,104 @@ +use crate::algorithms::{ + Kernel, ATKINSON, BURKES, FLOYD_STEINBERG, JARVIS_JUDICE_NINKE, SIERRA, SIERRA_LITE, STUCKI, +}; +use crate::error::DitherError; +use crate::palettes::Palette; +use crate::tone_map; + +/// Dithering algorithm. Integer values match OpenDisplay firmware conventions. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DitherMode { + None = 0, + #[default] + Burkes = 1, + Ordered = 2, + FloydSteinberg = 3, + Atkinson = 4, + Stucki = 5, + Sierra = 6, + SierraLite = 7, + JarvisJudiceNinke = 8, +} + +/// Dynamic range compression applied before dithering. +/// Only meaningful for measured palettes (ignored for `ColorScheme`). +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum ToneCompression { + /// Reinhard 2004 skewness-based auto strength. Best for photos. + #[default] + Auto, + /// Fixed blend strength in [0.0, 1.0]. 0.0 disables. + Fixed(f64), +} + +impl ToneCompression { + pub fn apply(self, pixels: &mut [[f64; 3]], palette: &Palette) { + match self { + ToneCompression::Auto => tone_map::auto_compress_dynamic_range(pixels, palette), + ToneCompression::Fixed(s) => if s > 0.0 { + tone_map::compress_dynamic_range(pixels, palette, s) + } + } + } +} + +/// Gamut compression applied before dithering. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum GamutCompression { + /// No gamut compression (default). + #[default] + None, + /// Full gamut compression at strength 1.0. + Auto, + /// Fixed blend strength in [0.0, 1.0]. + Fixed(f64), +} + +impl GamutCompression { + pub fn apply(self, pixels: &mut [[f64; 3]], palette: &Palette) { + match self { + GamutCompression::None => {} + GamutCompression::Auto => tone_map::gamut_compress(pixels, palette, 1.0), + GamutCompression::Fixed(s) => if s > 0.0 { + tone_map::gamut_compress(pixels, palette, s) + } + } + } + + +} + +impl TryFrom for DitherMode { + type Error = DitherError; + + fn try_from(v: u8) -> Result { + match v { + 0 => Ok(DitherMode::None), + 1 => Ok(DitherMode::Burkes), + 2 => Ok(DitherMode::Ordered), + 3 => Ok(DitherMode::FloydSteinberg), + 4 => Ok(DitherMode::Atkinson), + 5 => Ok(DitherMode::Stucki), + 6 => Ok(DitherMode::Sierra), + 7 => Ok(DitherMode::SierraLite), + 8 => Ok(DitherMode::JarvisJudiceNinke), + _ => Err(DitherError::UnknownDitherMode(v)), + } + } +} + +impl DitherMode { + pub fn kernel(self) -> Option<&'static Kernel> { + match self { + DitherMode::None | DitherMode::Ordered => None, + DitherMode::FloydSteinberg => Some(&FLOYD_STEINBERG), + DitherMode::Burkes => Some(&BURKES), + DitherMode::Atkinson => Some(&ATKINSON), + DitherMode::Stucki => Some(&STUCKI), + DitherMode::Sierra => Some(&SIERRA), + DitherMode::SierraLite => Some(&SIERRA_LITE), + DitherMode::JarvisJudiceNinke => Some(&JARVIS_JUDICE_NINKE), + } + } +} diff --git a/packages/rust/core/src/error.rs b/packages/rust/core/src/error.rs new file mode 100644 index 0000000..3d8dc69 --- /dev/null +++ b/packages/rust/core/src/error.rs @@ -0,0 +1,31 @@ +use std::fmt; + +/// Errors that can occur during dithering operations. +#[derive(Debug, PartialEq)] +pub enum DitherError { + UnknownColorScheme(u8), + UnknownDitherMode(u8), +} + +impl fmt::Display for DitherError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DitherError::UnknownColorScheme(v) => write!(f, "unknown color scheme: {v}"), + DitherError::UnknownDitherMode(v) => write!(f, "unknown dither mode: {v}"), + } + } +} + +impl std::error::Error for DitherError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_unknown_scheme() { + let e = DitherError::UnknownColorScheme(42); + let msg = e.to_string(); + assert!(msg.contains("42"), "message should include the bad value: {msg}"); + } +} diff --git a/packages/rust/core/src/lib.rs b/packages/rust/core/src/lib.rs new file mode 100644 index 0000000..421f976 --- /dev/null +++ b/packages/rust/core/src/lib.rs @@ -0,0 +1,123 @@ +pub mod algorithms; +pub mod color_space; +pub mod color_space_lab; +pub mod enums; +pub mod error; +pub mod measured_palettes; +pub mod palettes; +pub mod tone_map; +pub mod types; + +use crate::color_space::{linear_channel_to_srgb, srgb_channel_to_linear}; +use crate::enums::{DitherMode, GamutCompression, ToneCompression}; +use crate::palettes::Palette; +use crate::types::ImageBuffer; + +/// Configuration for `dither()`. All fields have sensible defaults. +/// +/// Construct with struct-update syntax: +/// ```ignore +/// dither(&img, palette, DitherConfig { mode: DitherMode::Burkes, ..Default::default() }); +/// ``` +/// +/// Pre-processing pipeline (applied in order, each step is a no-op at its identity value): +/// `exposure → saturation → shadows/highlights → tone → gamut → dither`. +#[derive(Debug, Clone, PartialEq)] +pub struct DitherConfig { + /// Dithering algorithm. + pub mode: DitherMode, + /// Use serpentine scanning for error diffusion (alternates row direction). Ignored for + /// `None` and `Ordered`. + pub serpentine: bool, + /// Linear-RGB exposure multiplier. 1.0 = no change, 2.0 = +1 stop, 0.5 = -1 stop. + pub exposure: f64, + /// OKLab saturation multiplier. 1.0 = no change, 0.0 = grayscale, >1.0 = boost. + /// Hue-preserving. + pub saturation: f64, + /// Shadow lift strength (lower-half S-curve). 0.0 = identity, 1.0 = strong lift. + pub shadows: f64, + /// Highlight compression strength (upper-half S-curve). 0.0 = identity, 1.0 = strong. + pub highlights: f64, + /// Dynamic-range compression. Auto = histogram-based; Fixed(s) = manual blend strength. + pub tone: ToneCompression, + /// Gamut compression. Auto = full strength on out-of-gamut; Fixed(s) = manual. + pub gamut: GamutCompression, +} + +impl Default for DitherConfig { + fn default() -> Self { + Self { + mode: DitherMode::Burkes, + serpentine: true, + exposure: 1.0, + saturation: 1.0, + shadows: 0.0, + highlights: 0.0, + tone: ToneCompression::Auto, + gamut: GamutCompression::Auto, + } + } +} + +fn dispatch(img: &ImageBuffer, p: &Palette, mode: DitherMode, serpentine: bool) -> Vec { + match mode { + DitherMode::None => algorithms::direct_map(img.data, p), + DitherMode::Ordered => algorithms::ordered_dither(img.data, img.width, p), + DitherMode::FloydSteinberg => algorithms::error_diffusion_dither(img.data, img.width, img.height, p, &algorithms::FLOYD_STEINBERG, serpentine), + DitherMode::Burkes => algorithms::error_diffusion_dither(img.data, img.width, img.height, p, &algorithms::BURKES, serpentine), + DitherMode::Atkinson => algorithms::error_diffusion_dither(img.data, img.width, img.height, p, &algorithms::ATKINSON, serpentine), + DitherMode::Stucki => algorithms::error_diffusion_dither(img.data, img.width, img.height, p, &algorithms::STUCKI, serpentine), + DitherMode::Sierra => algorithms::error_diffusion_dither(img.data, img.width, img.height, p, &algorithms::SIERRA, serpentine), + DitherMode::SierraLite => algorithms::error_diffusion_dither(img.data, img.width, img.height, p, &algorithms::SIERRA_LITE, serpentine), + DitherMode::JarvisJudiceNinke => algorithms::error_diffusion_dither(img.data, img.width, img.height, p, &algorithms::JARVIS_JUDICE_NINKE, serpentine), + } +} + +fn needs_preprocess(c: &DitherConfig) -> bool { + (c.exposure - 1.0).abs() > 1e-9 + || (c.saturation - 1.0).abs() > 1e-9 + || c.shadows > 0.0 + || c.highlights > 0.0 + || !matches!(c.tone, ToneCompression::Fixed(s) if s <= 0.0) + || !matches!(c.gamut, GamutCompression::None) +} + +/// Dither an image for e-paper display. +/// +/// Returns palette indices (one `u8` per pixel, length = `width × height`). +pub fn dither(img: &ImageBuffer, palette: impl AsRef, config: DitherConfig) -> Vec { + let p = palette.as_ref(); + + if !needs_preprocess(&config) { + return dispatch(img, p, config.mode, config.serpentine); + } + + // Convert sRGB bytes → linear, apply pre-processing pipeline, convert back. + let mut linear: Vec<[f64; 3]> = img + .data + .chunks_exact(3) + .map(|c| [ + srgb_channel_to_linear(c[0]), + srgb_channel_to_linear(c[1]), + srgb_channel_to_linear(c[2]), + ]) + .collect(); + + tone_map::apply_exposure(&mut linear, config.exposure); + tone_map::adjust_saturation(&mut linear, config.saturation); + tone_map::apply_shadows_highlights(&mut linear, config.shadows, config.highlights); + config.tone.apply(&mut linear, p); + config.gamut.apply(&mut linear, p); + + let processed: Vec = linear + .iter() + .flat_map(|&[r, g, b]| [ + linear_channel_to_srgb(r), + linear_channel_to_srgb(g), + linear_channel_to_srgb(b), + ]) + .collect(); + + let processed_img = ImageBuffer::new(&processed, img.width); + dispatch(&processed_img, p, config.mode, config.serpentine) +} diff --git a/packages/rust/core/src/measured_palettes.rs b/packages/rust/core/src/measured_palettes.rs new file mode 100644 index 0000000..e8f5985 --- /dev/null +++ b/packages/rust/core/src/measured_palettes.rs @@ -0,0 +1,163 @@ +//! Measured color palettes for real e-paper displays. +//! +//! These are photographically calibrated — colors reflect what the display +//! actually produces, not the ideal sRGB values. Use these for best dithering +//! quality on known hardware. +//! +//! Color order within each palette matches the Python package (firmware contract). + +use std::borrow::Cow; + +use crate::palettes::Palette; + +// ── Catalog (used by language bindings to expose named palettes) ────────────── + +/// A measured palette entry with its display name and color names. +/// Language bindings use this to expose palette constants without duplicating values. +pub struct MeasuredPaletteEntry { + pub id: &'static str, + pub palette: &'static Palette, + pub color_names: &'static [&'static str], +} + +/// All measured palettes. Add new displays here — bindings pick them up automatically. +pub static CATALOG: &[MeasuredPaletteEntry] = &[ + MeasuredPaletteEntry { + id: "SPECTRA_7_3_6COLOR", + palette: &SPECTRA_7_3_6COLOR, + color_names: &["black", "white", "yellow", "red", "blue", "green"], + }, + MeasuredPaletteEntry { + id: "SPECTRA_7_3_6COLOR_V2", + palette: &SPECTRA_7_3_6COLOR_V2, + color_names: &["black", "white", "yellow", "red", "blue", "green"], + }, + MeasuredPaletteEntry { + id: "MONO_4_26", + palette: &MONO_4_26, + color_names: &["black", "white"], + }, + MeasuredPaletteEntry { + id: "BWRY_4_2", + palette: &BWRY_4_2, + color_names: &["black", "white", "yellow", "red"], + }, + MeasuredPaletteEntry { + id: "BWRY_3_97", + palette: &BWRY_3_97, + color_names: &["black", "white", "yellow", "red"], + }, + MeasuredPaletteEntry { + id: "SOLUM_BWR", + palette: &SOLUM_BWR, + color_names: &["black", "white", "red"], + }, + MeasuredPaletteEntry { + id: "HANSHOW_BWR", + palette: &HANSHOW_BWR, + color_names: &["black", "white", "red"], + }, + MeasuredPaletteEntry { + id: "HANSHOW_BWY", + palette: &HANSHOW_BWY, + color_names: &["black", "white", "yellow"], + }, +]; + +// ── Spectra 7.3" 6-color ───────────────────────────────────────────────────── + +/// Spectra 7.3" 6-color (BWGBRY layout). +/// Measured 2026-02-03, iPhone 15 Pro Max RAW, 6500K reference. +pub static SPECTRA_7_3_6COLOR: Palette = Palette { + colors: Cow::Borrowed(&[ + [26, 13, 35], // black + [185, 202, 205], // white + [202, 184, 0], // yellow + [121, 9, 0], // red + [ 0, 69, 139], // blue + [ 40, 82, 57], // green + ]), + accent_idx: 3, // red +}; + +/// Spectra 7.3" 6-color v2. +/// Measured 2026-03-15, DNG with linear tone curve. +pub static SPECTRA_7_3_6COLOR_V2: Palette = Palette { + colors: Cow::Borrowed(&[ + [ 31, 24, 41], // black + [168, 180, 182], // white + [180, 173, 0], // yellow + [113, 24, 19], // red + [ 36, 70, 139], // blue + [ 50, 84, 60], // green + ]), + accent_idx: 3, // red +}; + +// ── Monochrome displays ─────────────────────────────────────────────────────── + +/// 4.26" Monochrome. TODO: measure actual display. +pub static MONO_4_26: Palette = Palette { + colors: Cow::Borrowed(&[ + [ 5, 5, 5], // black + [220, 220, 220], // white + ]), + accent_idx: 0, +}; + +// ── BWRY displays ───────────────────────────────────────────────────────────── + +/// 4.2" BWRY. TODO: measure actual display. +pub static BWRY_4_2: Palette = Palette { + colors: Cow::Borrowed(&[ + [ 5, 5, 5], // black + [200, 200, 200], // white + [200, 180, 0], // yellow + [120, 15, 5], // red + ]), + accent_idx: 3, +}; + +/// 3.97" BWRY — EP397YR_800x480. +/// Measured 2026-03-06, iPhone RAW, paper reference RGB(205,205,205). +pub static BWRY_3_97: Palette = Palette { + colors: Cow::Borrowed(&[ + [ 10, 7, 14], // black + [173, 178, 174], // white + [172, 128, 0], // yellow + [ 85, 24, 14], // red + ]), + accent_idx: 3, +}; + +// ── Harvested displays ──────────────────────────────────────────────────────── + +/// Solum BWR (harvested display). TODO: measure. +pub static SOLUM_BWR: Palette = Palette { + colors: Cow::Borrowed(&[ + [ 5, 5, 5], + [200, 200, 200], + [120, 15, 5], + ]), + accent_idx: 2, +}; + +/// Hanshow BWR (harvested display). TODO: measure. +pub static HANSHOW_BWR: Palette = Palette { + colors: Cow::Borrowed(&[ + [ 5, 5, 5], + [200, 200, 200], + [120, 15, 5], + ]), + accent_idx: 2, +}; + +/// Hanshow BWY (harvested display). TODO: measure. +pub static HANSHOW_BWY: Palette = Palette { + colors: Cow::Borrowed(&[ + [ 5, 5, 5], + [200, 200, 200], + [200, 180, 0], + ]), + accent_idx: 2, +}; diff --git a/packages/rust/core/src/palettes.rs b/packages/rust/core/src/palettes.rs new file mode 100644 index 0000000..1ded7bd --- /dev/null +++ b/packages/rust/core/src/palettes.rs @@ -0,0 +1,174 @@ +//! Palette definitions and color schemes. +//! +//! `ColorScheme` integer values are firmware API contracts — never change them. + +use std::borrow::Cow; + +use crate::error::DitherError; + +#[derive(Debug, Clone, PartialEq)] +pub struct Palette { + pub colors: Cow<'static, [[u8; 3]]>, // sRGB [R, G, B] for each ink color + pub accent_idx: usize, // index of the "accent" color in `colors` +} + +impl Palette { + /// Construct a runtime palette from owned color data. + /// + /// # Panics + /// Panics if `colors.len() < 2` or `accent_idx >= colors.len()`. + pub fn new(colors: Vec<[u8; 3]>, accent_idx: usize) -> Self { + assert!(colors.len() >= 2, "palette must have at least 2 colors, got {}", colors.len()); + assert!(accent_idx < colors.len(), "accent_idx {accent_idx} out of range (len={})", colors.len()); + Self { colors: Cow::Owned(colors), accent_idx } + } +} + +impl AsRef for Palette { + fn as_ref(&self) -> &Palette { + self + } +} + +impl AsRef for ColorScheme { + fn as_ref(&self) -> &Palette { + (*self).palette() + } +} + +/// E-paper color scheme. Integer discriminants match OpenDisplay firmware. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorScheme { + Mono = 0, + Bwr = 1, + Bwy = 2, + Bwry = 3, + Bwgbry = 4, + Grayscale4 = 5, + Grayscale8 = 6, + Grayscale16 = 7, +} + +// ── Palette data ───────────────────────────────────────────────────────────── + +static PALETTE_MONO: Palette = Palette { + colors: Cow::Borrowed(&[[0, 0, 0], [255, 255, 255]]), + accent_idx: 0, +}; +static PALETTE_BWR: Palette = Palette { + colors: Cow::Borrowed(&[[0, 0, 0], [255, 255, 255], [255, 0, 0]]), + accent_idx: 2, +}; +static PALETTE_BWY: Palette = Palette { + colors: Cow::Borrowed(&[[0, 0, 0], [255, 255, 255], [255, 255, 0]]), + accent_idx: 2, +}; +static PALETTE_BWRY: Palette = Palette { + colors: Cow::Borrowed(&[[0, 0, 0], [255, 255, 255], [255, 255, 0], [255, 0, 0]]), + accent_idx: 3, +}; +static PALETTE_BWGBRY: Palette = Palette { + colors: Cow::Borrowed(&[ + [0, 0, 0], [255, 255, 255], [255, 255, 0], + [255, 0, 0], [0, 0, 255], [0, 255, 0], + ]), + accent_idx: 3, +}; +static PALETTE_GRAYSCALE4: Palette = Palette { + colors: Cow::Borrowed(&[[0, 0, 0], [85, 85, 85], [170, 170, 170], [255, 255, 255]]), + accent_idx: 0, +}; +static PALETTE_GRAYSCALE8: Palette = Palette { + colors: Cow::Borrowed(&[ + [0, 0, 0], [36, 36, 36], [73, 73, 73], [109, 109, 109], + [146, 146, 146], [182, 182, 182], [219, 219, 219], [255, 255, 255], + ]), + accent_idx: 0, +}; +static PALETTE_GRAYSCALE16: Palette = Palette { + colors: Cow::Borrowed(&[ + [0, 0, 0], [17, 17, 17], [34, 34, 34], [51, 51, 51], + [68, 68, 68], [85, 85, 85], [102, 102, 102], [119, 119, 119], + [136, 136, 136],[153, 153, 153],[170, 170, 170],[187, 187, 187], + [204, 204, 204],[221, 221, 221],[238, 238, 238],[255, 255, 255], + ]), + accent_idx: 0, +}; + +// ── Methods ─────────────────────────────────────────────────────────────────── + +impl ColorScheme { + pub fn palette(self) -> &'static Palette { + match self { + ColorScheme::Mono => &PALETTE_MONO, + ColorScheme::Bwr => &PALETTE_BWR, + ColorScheme::Bwy => &PALETTE_BWY, + ColorScheme::Bwry => &PALETTE_BWRY, + ColorScheme::Bwgbry => &PALETTE_BWGBRY, + ColorScheme::Grayscale4 => &PALETTE_GRAYSCALE4, + ColorScheme::Grayscale8 => &PALETTE_GRAYSCALE8, + ColorScheme::Grayscale16 => &PALETTE_GRAYSCALE16, + } + } +} + +// ── Standard conversion traits ──────────────────────────────────────────────── + +impl From for u8 { + fn from(s: ColorScheme) -> u8 { + s as u8 + } +} + +impl TryFrom for ColorScheme { + type Error = DitherError; + + fn try_from(v: u8) -> Result { + match v { + 0 => Ok(ColorScheme::Mono), + 1 => Ok(ColorScheme::Bwr), + 2 => Ok(ColorScheme::Bwy), + 3 => Ok(ColorScheme::Bwry), + 4 => Ok(ColorScheme::Bwgbry), + 5 => Ok(ColorScheme::Grayscale4), + 6 => Ok(ColorScheme::Grayscale8), + 7 => Ok(ColorScheme::Grayscale16), + _ => Err(DitherError::UnknownColorScheme(v)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn firmware_values_are_correct() { + assert_eq!(u8::from(ColorScheme::Mono), 0); + assert_eq!(u8::from(ColorScheme::Bwr), 1); + assert_eq!(u8::from(ColorScheme::Grayscale16), 7); + } + + #[test] + fn from_into_u8() { + assert_eq!(u8::from(ColorScheme::Mono), 0u8); + assert_eq!(u8::from(ColorScheme::Grayscale16), 7u8); + let v: u8 = ColorScheme::Bwr.into(); + assert_eq!(v, 1u8); + } + + #[test] + fn try_from_u8() { + assert_eq!(ColorScheme::try_from(0), Ok(ColorScheme::Mono)); + assert_eq!(ColorScheme::try_from(4), Ok(ColorScheme::Bwgbry)); + assert_eq!(ColorScheme::try_from(99), Err(DitherError::UnknownColorScheme(99))); + } + + #[test] + fn palette_color_counts() { + assert_eq!(ColorScheme::Mono.palette().colors.len(), 2); + assert_eq!(ColorScheme::Bwgbry.palette().colors.len(), 6); + assert_eq!(ColorScheme::Grayscale16.palette().colors.len(), 16); + } +} diff --git a/packages/rust/core/src/tone_map.rs b/packages/rust/core/src/tone_map.rs new file mode 100644 index 0000000..03c833c --- /dev/null +++ b/packages/rust/core/src/tone_map.rs @@ -0,0 +1,500 @@ +//! Dynamic range and gamut compression for measured e-paper palettes. +//! +//! Applied before dithering to map image content into the display's reproducible range. +//! All functions operate on linear RGB pixels (`&mut [[f64; 3]]`). + +use rayon::prelude::*; + +use crate::color_space::srgb_channel_to_linear; +use crate::color_space_lab::{oklab_to_rgb, rgb_to_oklab, OkLab}; +use crate::palettes::Palette; + +// ITU-R BT.709 / sRGB luminance coefficients +const LUM_R: f64 = 0.2126729; +const LUM_G: f64 = 0.7151522; +const LUM_B: f64 = 0.0721750; + +fn luminance([r, g, b]: [f64; 3]) -> f64 { + LUM_R * r + LUM_G * g + LUM_B * b +} + +fn palette_to_linear(palette: &Palette) -> Vec<[f64; 3]> { + palette + .colors + .iter() + .map(|&[r, g, b]| { + [ + srgb_channel_to_linear(r), + srgb_channel_to_linear(g), + srgb_channel_to_linear(b), + ] + }) + .collect() +} + +/// Two percentiles from the same data in one sort. Returns (p_low_val, p_high_val). +fn percentile_pair(values: &[f64], p_low: f64, p_high: f64) -> (f64, f64) { + let mut sorted = values.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let n = sorted.len().saturating_sub(1); + let idx_lo = ((p_low / 100.0) * n as f64).round() as usize; + let idx_hi = ((p_high / 100.0) * n as f64).round() as usize; + (sorted[idx_lo.min(sorted.len() - 1)], sorted[idx_hi.min(sorted.len() - 1)]) +} + +// ── apply_exposure ──────────────────────────────────────────────────────────── + +/// Apply exposure adjustment (linear multiply on all channels). +/// +/// `factor > 1.0` brightens, `< 1.0` darkens. 1.0 is a no-op fast path. +/// Operates on linear RGB pixels. +pub fn apply_exposure(pixels: &mut [[f64; 3]], factor: f64) { + if (factor - 1.0).abs() < 1e-9 { + return; + } + pixels.par_iter_mut().for_each(|pixel| { + pixel[0] = (pixel[0] * factor).clamp(0.0, 1.0); + pixel[1] = (pixel[1] * factor).clamp(0.0, 1.0); + pixel[2] = (pixel[2] * factor).clamp(0.0, 1.0); + }); +} + +// ── adjust_saturation ───────────────────────────────────────────────────────── + +/// Adjust saturation in OKLab space. `factor > 1.0` boosts, `< 1.0` reduces, 1.0 is identity. +/// +/// Scales `a` and `b` components equally, which scales chroma while keeping the hue +/// (`atan2(b, a)`) unchanged. Operates on linear RGB pixels. +pub fn adjust_saturation(pixels: &mut [[f64; 3]], factor: f64) { + if (factor - 1.0).abs() < 1e-9 { + return; + } + pixels.par_iter_mut().for_each(|pixel| { + let lab = rgb_to_oklab(pixel[0], pixel[1], pixel[2]); + let scaled = OkLab { + l: lab.l, + a: (lab.a * factor).clamp(-1.0, 1.0), + b: (lab.b * factor).clamp(-1.0, 1.0), + }; + *pixel = oklab_to_rgb(scaled); + }); +} + +// ── apply_shadows_highlights ────────────────────────────────────────────────── + +/// Apply shadow lift and highlight compression as a luminance-only S-curve. +/// +/// `shadows` controls lift on the lower half (0.0 = identity, 1.0 = strong; power = 1 − shadows). +/// `highlights` controls compression on the upper half (0.0 = identity, 1.0 = strong; power = 1 + highlights). +/// +/// Each half is independent — `shadows = 0` leaves shadows alone even if `highlights > 0`. +/// Hue is preserved by scaling all three channels by `Y' / Y`. +pub fn apply_shadows_highlights(pixels: &mut [[f64; 3]], shadows: f64, highlights: f64) { + if shadows <= 0.0 && highlights <= 0.0 { + return; + } + pixels.par_iter_mut().for_each(|pixel| { + let y = luminance(*pixel); + if y <= 1e-6 { + return; + } + let y_prime = if y <= 0.5 { + let t = y / 0.5; + 0.5 * t.powf(1.0 - shadows) + } else { + let t = (y - 0.5) / 0.5; + 0.5 + 0.5 * t.powf(1.0 + highlights) + }; + let scale = (y_prime / y).clamp(0.0, 1e6); + pixel[0] = (pixel[0] * scale).clamp(0.0, 1.0); + pixel[1] = (pixel[1] * scale).clamp(0.0, 1.0); + pixel[2] = (pixel[2] * scale).clamp(0.0, 1.0); + }); +} + +// ── compress_dynamic_range ──────────────────────────────────────────────────── + +/// Remap pixel luminance from [0, 1] to the display's [black_Y, white_Y] range. +/// +/// `palette.colors[0]` = black ink, `palette.colors[1]` = white/paper. +/// `strength` blends between no compression (0.0) and full remap (1.0). +pub fn compress_dynamic_range(pixels: &mut [[f64; 3]], palette: &Palette, strength: f64) { + if strength <= 0.0 { + return; + } + + let pal = palette_to_linear(palette); + let black_y = luminance(pal[0]); + let white_y = luminance(pal[1]); + let display_range = white_y - black_y; + + if display_range <= 0.0 { + return; + } + + + pixels.par_iter_mut().for_each(|pixel| { + let y = luminance(*pixel); + let compressed_y = black_y + y * display_range; + let target_y = y + strength * (compressed_y - y); + if y > 1e-6 { + let scale = (target_y / y).clamp(0.0, 1e6); + pixel[0] = (pixel[0] * scale).clamp(0.0, 1.0); + pixel[1] = (pixel[1] * scale).clamp(0.0, 1.0); + pixel[2] = (pixel[2] * scale).clamp(0.0, 1.0); + } else { + // Pixel luminance is near zero — preserve channel ratios, scale toward display black. + let blended_black = black_y * strength; + let max_ch = pixel[0].max(pixel[1]).max(pixel[2]); + if max_ch > 1e-12 { + let scale = blended_black / max_ch; + pixel[0] = (pixel[0] * scale).clamp(0.0, 1.0); + pixel[1] = (pixel[1] * scale).clamp(0.0, 1.0); + pixel[2] = (pixel[2] * scale).clamp(0.0, 1.0); + } else { + pixel[0] = blended_black; + pixel[1] = blended_black; + pixel[2] = blended_black; + } + } + }); +} + +// ── auto_compress_dynamic_range ─────────────────────────────────────────────── + +/// Conditionally compress dynamic range using Reinhard 2004 log-skewness for strength. +/// +/// Only compresses when the image genuinely exceeds the display range (±10% tolerance). +/// Strength = clip(skew^1.4, 0, 1) where skew = position of log-average in log range. +pub fn auto_compress_dynamic_range(pixels: &mut [[f64; 3]], palette: &Palette) { + let pal = palette_to_linear(palette); + let black_y = luminance(pal[0]); + let white_y = luminance(pal[1]); + let display_range = white_y - black_y; + + if display_range <= 0.0 { + return; + } + + let lum_values: Vec = pixels.iter().map(|&p| luminance(p)).collect(); + + let (p_low, p_high) = percentile_pair(&lum_values, 2.0, 98.0); + let image_range = p_high - p_low; + + if image_range < 1e-6 { + compress_dynamic_range(pixels, palette, 1.0); + return; + } + + const TOLERANCE: f64 = 0.10; + let fits_shadows = p_low >= black_y - TOLERANCE * display_range; + let fits_highlights = p_high <= white_y + TOLERANCE * display_range; + + if fits_shadows && fits_highlights { + return; + } + + // Reinhard 2004: strength from log-histogram skewness + let nonzero: Vec = lum_values.iter().copied().filter(|&y| y > 1e-6).collect(); + let strength = if !nonzero.is_empty() { + let l_lav = (nonzero.iter().map(|&y| (y + 1e-5).ln()).sum::() / nonzero.len() as f64).exp(); + let log_min = p_low.max(1e-5).ln(); + let log_max = p_high.max(1e-5).ln(); + let log_range = log_max - log_min; + if log_range > 1e-6 { + let skew = (log_max - (l_lav + 1e-5).ln()) / log_range; + skew.powf(1.4).clamp(0.0, 1.0) + } else { + 1.0 + } + } else { + 1.0 + }; + + // Remap [p_low, p_high] → [black_y, white_y] at computed strength + pixels.par_iter_mut().for_each(|pixel| { + let y = luminance(*pixel); + let normalized = (y - p_low) / image_range; + let target_y_full = black_y + normalized * display_range; + let target_y = y + strength * (target_y_full - y); + if y > 1e-6 { + let scale = (target_y / y).clamp(0.0, 1e6); + pixel[0] = (pixel[0] * scale).clamp(0.0, 1.0); + pixel[1] = (pixel[1] * scale).clamp(0.0, 1.0); + pixel[2] = (pixel[2] * scale).clamp(0.0, 1.0); + } else { + *pixel = [black_y, black_y, black_y]; + } + }); +} + +// ── gamut_compress ──────────────────────────────────────────────────────────── + +const GAMUT_THRESHOLD: f64 = 0.15; +const GAMUT_THRESHOLD_MAX: f64 = 0.45; + +/// Nearest point on a line segment [a, b] to point p, in 3D. +/// Returns the interpolation parameter t ∈ [0, 1]. +fn nearest_on_segment(p: [f64; 3], a: [f64; 3], b: [f64; 3]) -> f64 { + let edge = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + let edge_len_sq = edge[0] * edge[0] + edge[1] * edge[1] + edge[2] * edge[2]; + if edge_len_sq < 1e-10 { + return 0.0; + } + let diff = [p[0] - a[0], p[1] - a[1], p[2] - a[2]]; + let dot = diff[0] * edge[0] + diff[1] * edge[1] + diff[2] * edge[2]; + (dot / edge_len_sq).clamp(0.0, 1.0) +} + +fn dist_sq(a: [f64; 3], b: [f64; 3]) -> f64 { + let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + d[0] * d[0] + d[1] * d[1] + d[2] * d[2] +} + +fn lerp3(a: [f64; 3], b: [f64; 3], t: f64) -> [f64; 3] { + [ + a[0] + t * (b[0] - a[0]), + a[1] + t * (b[1] - a[1]), + a[2] + t * (b[2] - a[2]), + ] +} + +fn smoothstep(edge0: f64, edge1: f64, x: f64) -> f64 { + let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// Blend out-of-gamut pixels toward the nearest point on the palette hull in OKLab space. +/// +/// Smoothstep from no effect at `GAMUT_THRESHOLD` to full at `GAMUT_THRESHOLD_MAX`. +/// +/// Note: searches all palette edges (O(n²) pairs) and relies on `nearest_on_segment` +/// returning an endpoint when the projection falls outside the segment, which covers all +/// vertices. For 4+ color palettes the true nearest point on the convex hull can be +/// interior to a triangular face — this is a known approximation. +pub fn gamut_compress(pixels: &mut [[f64; 3]], palette: &Palette, strength: f64) { + if strength <= 0.0 { + return; + } + + let pal_linear = palette_to_linear(palette); + let pal_lab: Vec<_> = pal_linear + .iter() + .map(|&[r, g, b]| rgb_to_oklab(r, g, b)) + .collect(); + let n = pal_linear.len(); + + pixels.par_iter_mut().for_each(|pixel| { + let px_lab = rgb_to_oklab(pixel[0], pixel[1], pixel[2]); + let px_lab_arr = [px_lab.l, px_lab.a, px_lab.b]; + + // Find nearest point on any palette edge or vertex + let mut best_dist_sq = f64::INFINITY; + let mut best_target = *pixel; + + // Edges + for i in 0..n { + for j in (i + 1)..n { + let a_lab = [pal_lab[i].l, pal_lab[i].a, pal_lab[i].b]; + let b_lab = [pal_lab[j].l, pal_lab[j].a, pal_lab[j].b]; + let t = nearest_on_segment(px_lab_arr, a_lab, b_lab); + let nearest_lab = lerp3(a_lab, b_lab, t); + let d = dist_sq(px_lab_arr, nearest_lab); + if d < best_dist_sq { + best_dist_sq = d; + best_target = lerp3(pal_linear[i], pal_linear[j], t); + } + } + } + + // Note: vertices are already covered by the edge loop — nearest_on_segment + // returns the endpoint (t=0 or t=1) when the projection falls outside the segment. + + let nearest_dist = best_dist_sq.sqrt(); + let blend = smoothstep(GAMUT_THRESHOLD, GAMUT_THRESHOLD_MAX, nearest_dist) * strength; + + pixel[0] = (pixel[0] + blend * (best_target[0] - pixel[0])).clamp(0.0, 1.0); + pixel[1] = (pixel[1] + blend * (best_target[1] - pixel[1])).clamp(0.0, 1.0); + pixel[2] = (pixel[2] + blend * (best_target[2] - pixel[2])).clamp(0.0, 1.0); + }); +} + + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + fn spectra_palette() -> &'static Palette { + use crate::measured_palettes::SPECTRA_7_3_6COLOR; + &SPECTRA_7_3_6COLOR + } + + #[test] + fn compress_strength_zero_is_identity() { + let mut pixels = vec![[0.8, 0.4, 0.2_f64]]; + let original = pixels[0]; + compress_dynamic_range(&mut pixels, spectra_palette(), 0.0); + assert_eq!(pixels[0], original); + } + + #[test] + fn compress_reduces_highlights() { + // A bright pixel should be dimmed toward the display white point + let mut pixels = vec![[1.0, 1.0, 1.0_f64]]; + compress_dynamic_range(&mut pixels, spectra_palette(), 1.0); + assert!(luminance(pixels[0]) < 1.0, "highlight should be compressed"); + } + + #[test] + fn gamut_compress_identity_on_palette_colors() { + // A pixel that exactly matches a palette color should be unchanged + let pal = spectra_palette(); + let [r, g, b] = pal.colors[0]; + let mut pixels = vec![[ + srgb_channel_to_linear(r), + srgb_channel_to_linear(g), + srgb_channel_to_linear(b), + ]]; + let original = pixels[0]; + gamut_compress(&mut pixels, pal, 1.0); + let d = dist_sq(pixels[0], original); + assert!(d < 1e-10, "palette color should not be moved: d={d}"); + } + + #[test] + fn compress_maps_white_to_display_white() { + // Full-strength compression should remap luminance-1.0 to the display's white point + let mut pixels = vec![[1.0, 1.0, 1.0_f64]]; + let pal = spectra_palette(); + compress_dynamic_range(&mut pixels, pal, 1.0); + let pal_lin = palette_to_linear(pal); + let white_y = luminance(pal_lin[1]); + let out_y = luminance(pixels[0]); + assert!( + (out_y - white_y).abs() < 1e-6, + "white pixel should compress to display white point (expected {white_y}, got {out_y})" + ); + } + + #[test] + fn compress_maps_black_to_display_black() { + // Full-strength compression of pure black should yield the display black point + let mut pixels = vec![[0.0, 0.0, 0.0_f64]]; + let pal = spectra_palette(); + compress_dynamic_range(&mut pixels, pal, 1.0); + let pal_lin = palette_to_linear(pal); + let black_y = luminance(pal_lin[0]); + let out_y = luminance(pixels[0]); + assert!( + (out_y - black_y).abs() < 1e-4, + "black pixel should compress to display black point (expected {black_y}, got {out_y})" + ); + } + + #[test] + fn auto_compress_skips_in_range_images() { + // Images whose luminance already fits within the display range should not be modified + let pal = spectra_palette(); + let pal_lin = palette_to_linear(pal); + let black_y = luminance(pal_lin[0]); + let white_y = luminance(pal_lin[1]); + // A pixel sitting at the midpoint of the display range is well within bounds + let mid = (black_y + white_y) / 2.0; + // Use enough pixels to avoid the flat-image fast path (image_range < 1e-6) + // by including two distinct luminance values within the display range. + let lo = black_y + 0.05 * (white_y - black_y); + let hi = white_y - 0.05 * (white_y - black_y); + let mut pixels = vec![[lo, lo, lo], [mid, mid, mid], [hi, hi, hi]]; + let original = pixels.clone(); + auto_compress_dynamic_range(&mut pixels, pal); + for (i, (got, expected)) in pixels.iter().zip(original.iter()).enumerate() { + let d: f64 = got.iter().zip(expected.iter()).map(|(a, b)| (a - b).abs()).sum(); + assert!(d < 1e-6, "pixel {i} should not be modified by auto-compress: moved by {d}"); + } + } + + #[test] + fn gamut_compress_moves_out_of_gamut_pixel() { + // Pure linear red [1, 0, 0] is much more vivid than the Spectra palette's desaturated + // red ink [121, 9, 0] — the OKLab distance to the nearest edge exceeds GAMUT_THRESHOLD, + // so the smoothstep is non-zero and the pixel must be moved. + let pal = spectra_palette(); + let mut pixels = vec![[1.0_f64, 0.0, 0.0]]; // pure linear red + let original = pixels[0]; + gamut_compress(&mut pixels, pal, 1.0); + let moved = pixels[0].iter().zip(original.iter()).any(|(a, b)| (a - b).abs() > 1e-6); + assert!(moved, "vivid red should be moved toward the palette by gamut_compress"); + } + + #[test] + fn exposure_factor_one_is_identity() { + let mut pixels = vec![[0.5_f64, 0.2, 0.1]]; + let original = pixels.clone(); + apply_exposure(&mut pixels, 1.0); + assert_eq!(pixels, original); + } + + #[test] + fn exposure_factor_greater_than_one_brightens() { + let mut pixels = vec![[0.2_f64, 0.2, 0.2]]; + apply_exposure(&mut pixels, 2.0); + assert!(pixels[0][0] > 0.39 && pixels[0][0] < 0.41); + } + + #[test] + fn saturation_factor_one_is_identity() { + let mut pixels = vec![[0.5_f64, 0.2, 0.1]]; + let original = pixels.clone(); + adjust_saturation(&mut pixels, 1.0); + for (got, expected) in pixels.iter().zip(original.iter()) { + for (a, b) in got.iter().zip(expected.iter()) { + assert!((a - b).abs() < 1e-9); + } + } + } + + #[test] + fn saturation_factor_zero_produces_gray() { + let mut pixels = vec![[0.8_f64, 0.2, 0.1]]; + adjust_saturation(&mut pixels, 0.0); + let [r, g, b] = pixels[0]; + assert!( + (r - g).abs() < 1e-3 && (r - b).abs() < 1e-3, + "factor=0 must yield neutral gray: [{r}, {g}, {b}]" + ); + } + + #[test] + fn shadows_highlights_zero_is_identity() { + let mut pixels = vec![[0.2_f64, 0.2, 0.2], [0.7, 0.7, 0.7]]; + let original = pixels.clone(); + apply_shadows_highlights(&mut pixels, 0.0, 0.0); + assert_eq!(pixels, original); + } + + #[test] + fn shadows_lifts_only_lower_half() { + let mut pixels = vec![[0.1_f64, 0.1, 0.1], [0.8, 0.8, 0.8]]; + apply_shadows_highlights(&mut pixels, 0.5, 0.0); + // Shadow pixel lifted (brighter) + assert!(pixels[0][0] > 0.1, "shadow should be lifted: {}", pixels[0][0]); + // Highlight pixel unchanged (highlights=0) + assert!((pixels[1][0] - 0.8).abs() < 1e-6, "highlight should be unchanged: {}", pixels[1][0]); + } + + #[test] + fn dark_pixel_chroma_preserved_after_compress() { + // A near-black but distinctly blue pixel should not become gray after compression + let pal = spectra_palette(); + // Very dark blue: luminance ≈ 0.07*0.00001 ≈ near zero, strong blue channel + let mut pixels = vec![[0.0_f64, 0.0, 1e-5]]; + compress_dynamic_range(&mut pixels, pal, 1.0); + // Blue channel should still dominate (be the largest) after compression + let [r, g, b] = pixels[0]; + assert!( + b >= r && b >= g, + "blue channel should remain dominant after dark pixel compression: [{r}, {g}, {b}]" + ); + } +} diff --git a/packages/rust/core/src/types.rs b/packages/rust/core/src/types.rs new file mode 100644 index 0000000..efbb7c2 --- /dev/null +++ b/packages/rust/core/src/types.rs @@ -0,0 +1,15 @@ +/// A flat RGB image buffer. Height is derived from data length and width. +pub struct ImageBuffer<'a> { + pub data: &'a [u8], + pub width: usize, + pub height: usize, +} + +impl<'a> ImageBuffer<'a> { + /// Create from flat RGB bytes (len = width × height × 3). + pub fn new(data: &'a [u8], width: usize) -> Self { + let height = data.len() / 3 / width; + debug_assert_eq!(data.len(), width * height * 3, "pixel buffer size mismatch"); + Self { data, width, height } + } +} diff --git a/packages/rust/core/tests/fixtures/images/README.md b/packages/rust/core/tests/fixtures/images/README.md new file mode 100644 index 0000000..2a8f357 --- /dev/null +++ b/packages/rust/core/tests/fixtures/images/README.md @@ -0,0 +1,43 @@ +# Test fixture images + +Photographs used for regression testing and benchmarking. + +## Copyright + +All images © Gabriel. Included in this repository for testing purposes only. + +## Images + +| File | Content | Primary test coverage | +|---|---|---| +| `frankfurt_nacht.png` | Frankfurt skyline at night | Auto tone compression — dark scene | +| `unicorn.png` | Unicorn sculpture, Tollwood festival Munich | Gamut compression — vivid colors | +| `cat_orange.png` | Orange cat in grass, golden hour | Fine detail, warm tones | +| `cat.png` | Calico cat on tiles | High contrast B&W + color patches | +| `olympiapark.png` | Olympic Park, Munich | Outdoor scene, sky gradients | +| `river.png` | River/waterway with sky | Daylight outdoor, sky gradients | +| `ubahn_station.png` | Marienplatz U-Bahn station | Graphic/saturated, architecture | +| `seeed_opendisplay.png` | Synthetic color chart | Pure primaries, checkerboard, text | + +## Benchmark-only + +Images in `benchmark_only/` are full-resolution camera files used only for performance +benchmarks. They are not included in regression tests. + +| File | Content | +|---|---| +| `cat.png` | Calico cat — full resolution | +| `cat_orange.png` / `orange_cat.png` | Orange cat — full resolution | +| `marienplatz.png` | Marienplatz U-Bahn — full resolution | +| `river.png` | River/waterway — full resolution | + +## Adding more images + +Drop any `.png` or `.jpg` file into this directory and run: + +```bash +UPDATE_FIXTURES=1 cargo test --test regression +``` + +This generates reference outputs for all three regression suites and locks them in. +No code changes needed. \ No newline at end of file diff --git a/packages/rust/core/tests/fixtures/images/benchmark_only/cat.png b/packages/rust/core/tests/fixtures/images/benchmark_only/cat.png new file mode 100644 index 0000000..c35fa87 Binary files /dev/null and b/packages/rust/core/tests/fixtures/images/benchmark_only/cat.png differ diff --git a/packages/rust/core/tests/fixtures/images/benchmark_only/marienplatz.png b/packages/rust/core/tests/fixtures/images/benchmark_only/marienplatz.png new file mode 100644 index 0000000..bd5b68f Binary files /dev/null and b/packages/rust/core/tests/fixtures/images/benchmark_only/marienplatz.png differ diff --git a/packages/rust/core/tests/fixtures/images/benchmark_only/orange_cat.png b/packages/rust/core/tests/fixtures/images/benchmark_only/orange_cat.png new file mode 100644 index 0000000..0f76680 Binary files /dev/null and b/packages/rust/core/tests/fixtures/images/benchmark_only/orange_cat.png differ diff --git a/packages/rust/core/tests/fixtures/images/benchmark_only/river.png b/packages/rust/core/tests/fixtures/images/benchmark_only/river.png new file mode 100644 index 0000000..95802ce Binary files /dev/null and b/packages/rust/core/tests/fixtures/images/benchmark_only/river.png differ diff --git a/packages/rust/core/tests/fixtures/images/cat.png b/packages/rust/core/tests/fixtures/images/cat.png new file mode 100644 index 0000000..cca2f5a Binary files /dev/null and b/packages/rust/core/tests/fixtures/images/cat.png differ diff --git a/packages/rust/core/tests/fixtures/images/cat_orange.png b/packages/rust/core/tests/fixtures/images/cat_orange.png new file mode 100644 index 0000000..56ffab2 Binary files /dev/null and b/packages/rust/core/tests/fixtures/images/cat_orange.png differ diff --git a/packages/rust/core/tests/fixtures/images/frankfurt_nacht.png b/packages/rust/core/tests/fixtures/images/frankfurt_nacht.png new file mode 100644 index 0000000..a116de6 Binary files /dev/null and b/packages/rust/core/tests/fixtures/images/frankfurt_nacht.png differ diff --git a/packages/rust/core/tests/fixtures/images/olympiapark.png b/packages/rust/core/tests/fixtures/images/olympiapark.png new file mode 100644 index 0000000..8180431 Binary files /dev/null and b/packages/rust/core/tests/fixtures/images/olympiapark.png differ diff --git a/packages/rust/core/tests/fixtures/images/river.png b/packages/rust/core/tests/fixtures/images/river.png new file mode 100644 index 0000000..46c5aa6 Binary files /dev/null and b/packages/rust/core/tests/fixtures/images/river.png differ diff --git a/packages/rust/core/tests/fixtures/images/seeed_opendisplay.png b/packages/rust/core/tests/fixtures/images/seeed_opendisplay.png new file mode 100644 index 0000000..28000b4 Binary files /dev/null and b/packages/rust/core/tests/fixtures/images/seeed_opendisplay.png differ diff --git a/packages/rust/core/tests/fixtures/images/ubahn_station.png b/packages/rust/core/tests/fixtures/images/ubahn_station.png new file mode 100644 index 0000000..e7e54f4 Binary files /dev/null and b/packages/rust/core/tests/fixtures/images/ubahn_station.png differ diff --git a/packages/rust/core/tests/fixtures/images/unicorn.png b/packages/rust/core/tests/fixtures/images/unicorn.png new file mode 100644 index 0000000..258d2f9 Binary files /dev/null and b/packages/rust/core/tests/fixtures/images/unicorn.png differ diff --git a/packages/rust/core/tests/fixtures/references/cat__burkes_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/cat__burkes_spectra6_auto.bin new file mode 100644 index 0000000..6977943 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/cat__burkes_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/cat__floyd_steinberg_mono_raw.bin b/packages/rust/core/tests/fixtures/references/cat__floyd_steinberg_mono_raw.bin new file mode 100644 index 0000000..509d5a9 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/cat__floyd_steinberg_mono_raw.bin differ diff --git a/packages/rust/core/tests/fixtures/references/cat__ordered_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/cat__ordered_spectra6_auto.bin new file mode 100644 index 0000000..1712646 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/cat__ordered_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/cat_orange__burkes_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/cat_orange__burkes_spectra6_auto.bin new file mode 100644 index 0000000..9d86b0d --- /dev/null +++ b/packages/rust/core/tests/fixtures/references/cat_orange__burkes_spectra6_auto.bin @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/rust/core/tests/fixtures/references/cat_orange__floyd_steinberg_mono_raw.bin b/packages/rust/core/tests/fixtures/references/cat_orange__floyd_steinberg_mono_raw.bin new file mode 100644 index 0000000..506e517 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/cat_orange__floyd_steinberg_mono_raw.bin differ diff --git a/packages/rust/core/tests/fixtures/references/cat_orange__ordered_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/cat_orange__ordered_spectra6_auto.bin new file mode 100644 index 0000000..689a080 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/cat_orange__ordered_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/frankfurt_nacht__burkes_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/frankfurt_nacht__burkes_spectra6_auto.bin new file mode 100644 index 0000000..0100f6f Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/frankfurt_nacht__burkes_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/frankfurt_nacht__floyd_steinberg_mono_raw.bin b/packages/rust/core/tests/fixtures/references/frankfurt_nacht__floyd_steinberg_mono_raw.bin new file mode 100644 index 0000000..c1f2b2c Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/frankfurt_nacht__floyd_steinberg_mono_raw.bin differ diff --git a/packages/rust/core/tests/fixtures/references/frankfurt_nacht__ordered_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/frankfurt_nacht__ordered_spectra6_auto.bin new file mode 100644 index 0000000..d14b28f Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/frankfurt_nacht__ordered_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/olympiapark__burkes_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/olympiapark__burkes_spectra6_auto.bin new file mode 100644 index 0000000..37ba17c Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/olympiapark__burkes_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/olympiapark__floyd_steinberg_mono_raw.bin b/packages/rust/core/tests/fixtures/references/olympiapark__floyd_steinberg_mono_raw.bin new file mode 100644 index 0000000..6dca27a Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/olympiapark__floyd_steinberg_mono_raw.bin differ diff --git a/packages/rust/core/tests/fixtures/references/olympiapark__ordered_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/olympiapark__ordered_spectra6_auto.bin new file mode 100644 index 0000000..377b417 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/olympiapark__ordered_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/river__burkes_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/river__burkes_spectra6_auto.bin new file mode 100644 index 0000000..4661d6c --- /dev/null +++ b/packages/rust/core/tests/fixtures/references/river__burkes_spectra6_auto.bin @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/rust/core/tests/fixtures/references/river__floyd_steinberg_mono_raw.bin b/packages/rust/core/tests/fixtures/references/river__floyd_steinberg_mono_raw.bin new file mode 100644 index 0000000..99f3e52 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/river__floyd_steinberg_mono_raw.bin differ diff --git a/packages/rust/core/tests/fixtures/references/river__ordered_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/river__ordered_spectra6_auto.bin new file mode 100644 index 0000000..c5b2070 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/river__ordered_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/seeed_opendisplay__burkes_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/seeed_opendisplay__burkes_spectra6_auto.bin new file mode 100644 index 0000000..cc54a20 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/seeed_opendisplay__burkes_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/seeed_opendisplay__floyd_steinberg_mono_raw.bin b/packages/rust/core/tests/fixtures/references/seeed_opendisplay__floyd_steinberg_mono_raw.bin new file mode 100644 index 0000000..2b38eb0 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/seeed_opendisplay__floyd_steinberg_mono_raw.bin differ diff --git a/packages/rust/core/tests/fixtures/references/seeed_opendisplay__ordered_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/seeed_opendisplay__ordered_spectra6_auto.bin new file mode 100644 index 0000000..df0e64d Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/seeed_opendisplay__ordered_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/ubahn_station__burkes_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/ubahn_station__burkes_spectra6_auto.bin new file mode 100644 index 0000000..c9771e4 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/ubahn_station__burkes_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/ubahn_station__floyd_steinberg_mono_raw.bin b/packages/rust/core/tests/fixtures/references/ubahn_station__floyd_steinberg_mono_raw.bin new file mode 100644 index 0000000..33e0718 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/ubahn_station__floyd_steinberg_mono_raw.bin differ diff --git a/packages/rust/core/tests/fixtures/references/ubahn_station__ordered_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/ubahn_station__ordered_spectra6_auto.bin new file mode 100644 index 0000000..8aade53 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/ubahn_station__ordered_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/unicorn__burkes_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/unicorn__burkes_spectra6_auto.bin new file mode 100644 index 0000000..f6728dd Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/unicorn__burkes_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/unicorn__floyd_steinberg_mono_raw.bin b/packages/rust/core/tests/fixtures/references/unicorn__floyd_steinberg_mono_raw.bin new file mode 100644 index 0000000..b9b6e47 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/unicorn__floyd_steinberg_mono_raw.bin differ diff --git a/packages/rust/core/tests/fixtures/references/unicorn__ordered_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/unicorn__ordered_spectra6_auto.bin new file mode 100644 index 0000000..43aadc7 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/unicorn__ordered_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/regression.rs b/packages/rust/core/tests/regression.rs new file mode 100644 index 0000000..a25910a --- /dev/null +++ b/packages/rust/core/tests/regression.rs @@ -0,0 +1,159 @@ +//! Visual regression tests using real photographs. +//! +//! References are stored as raw palette-index `.bin` files in `tests/fixtures/references/`. +//! To regenerate all references (e.g. after an intentional algorithm change): +//! +//! UPDATE_FIXTURES=1 cargo test --test regression + +use std::path::{Path, PathBuf}; + +use epaper_dithering_core::{ + dither, DitherConfig, + enums::{DitherMode, GamutCompression, ToneCompression}, + measured_palettes::SPECTRA_7_3_6COLOR, + palettes::ColorScheme, + types::ImageBuffer, +}; + +// ── Paths ───────────────────────────────────────────────────────────────────── + +fn fixtures_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} + +fn image_path(filename: &str) -> PathBuf { + fixtures_dir().join("images").join(filename) +} + +fn reference_path(image_stem: &str, tag: &str) -> PathBuf { + fixtures_dir() + .join("references") + .join(format!("{image_stem}__{tag}.bin")) +} + +// ── Image loading ───────────────────────────────────────────────────────────── + +fn load_rgb(filename: &str) -> (Vec, usize, usize) { + let img = image::open(image_path(filename)) + .unwrap_or_else(|e| panic!("failed to load {filename}: {e}")) + .to_rgb8(); + let (w, h) = img.dimensions(); + (img.into_raw(), w as usize, h as usize) +} + +// ── Regression driver ───────────────────────────────────────────────────────── + +/// Run `dither()` on `filename` and compare against (or write) the stored reference. +/// +/// `tag` identifies the combination, e.g. `"burkes_spectra6_auto"`. +fn assert_regression( + filename: &str, + tag: &str, + mode: DitherMode, + palette: impl AsRef, + tone: ToneCompression, + gamut: GamutCompression, +) { + let (pixels, w, _h) = load_rgb(filename); + let img = ImageBuffer::new(&pixels, w); + let output = dither(&img, palette, DitherConfig { + mode, tone, gamut, ..Default::default() + }); + + let stem = Path::new(filename).file_stem().unwrap().to_str().unwrap(); + let ref_path = reference_path(stem, tag); + + if std::env::var("UPDATE_FIXTURES").is_ok() { + std::fs::create_dir_all(ref_path.parent().unwrap()).unwrap(); + std::fs::write(&ref_path, &output) + .unwrap_or_else(|e| panic!("failed to write reference {ref_path:?}: {e}")); + return; + } + + let reference = std::fs::read(&ref_path).unwrap_or_else(|_| { + panic!( + "Reference not found: {ref_path:?}\nRun with UPDATE_FIXTURES=1 to generate it." + ) + }); + + assert_eq!( + output, reference, + "Regression failure: {filename} × {tag}\n\ + Output differs from reference. If this change is intentional, \ + regenerate with UPDATE_FIXTURES=1." + ); +} + +// ── Test image discovery ────────────────────────────────────────────────────── + +/// Returns all image filenames in `tests/fixtures/images/` (non-recursive). +/// Images in `benchmark_only/` are excluded — those are too large for regression tests. +/// To add a new image, just drop it into `tests/fixtures/images/` and run +/// `UPDATE_FIXTURES=1 cargo test --test regression`. +fn discover_images() -> Vec { + let dir = fixtures_dir().join("images"); + let mut names: Vec = std::fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("cannot read fixtures/images: {e}")) + .filter_map(|entry| { + let entry = entry.ok()?; + if !entry.file_type().ok()?.is_file() { + return None; // skip subdirectories (e.g. benchmark_only/) + } + let name = entry.file_name().into_string().ok()?; + let ext = std::path::Path::new(&name).extension()?.to_str()?; + matches!(ext, "png" | "jpg" | "jpeg").then_some(name) + }) + .collect(); + names.sort(); // deterministic order + names +} + +// ── Regression suites ───────────────────────────────────────────────────────── + +/// Primary path: Burkes + 6-color measured palette + auto tone & gamut. +/// This is the most common real-world usage. +#[test] +fn burkes_spectra6_auto() { + for img in discover_images() { + assert_regression( + &img, + "burkes_spectra6_auto", + DitherMode::Burkes, + &SPECTRA_7_3_6COLOR, + ToneCompression::Auto, + GamutCompression::Auto, + ); + } +} + +/// Secondary path: Floyd-Steinberg + monochrome + no preprocessing. +/// Fast, no measured palette — exercises pure error-diffusion on a 2-color palette. +#[test] +fn floyd_steinberg_mono_raw() { + for img in discover_images() { + assert_regression( + &img, + "floyd_steinberg_mono_raw", + DitherMode::FloydSteinberg, + ColorScheme::Mono, + ToneCompression::Fixed(0.0), + GamutCompression::None, + ); + } +} + +/// Ordered dithering + 6-color measured palette + auto preprocessing. +/// Different algorithm family — verifies the Bayer path independently. +#[test] +fn ordered_spectra6_auto() { + for img in discover_images() { + assert_regression( + &img, + "ordered_spectra6_auto", + DitherMode::Ordered, + &SPECTRA_7_3_6COLOR, + ToneCompression::Auto, + GamutCompression::Auto, + ); + } +} \ No newline at end of file diff --git a/packages/rust/wasm/Cargo.toml b/packages/rust/wasm/Cargo.toml new file mode 100644 index 0000000..d93232e --- /dev/null +++ b/packages/rust/wasm/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "epaper-dithering-wasm" +version = "3.0.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +epaper-dithering-core = { path = "../core" } +wasm-bindgen = "0.2" diff --git a/packages/rust/wasm/src/lib.rs b/packages/rust/wasm/src/lib.rs new file mode 100644 index 0000000..593ecd7 --- /dev/null +++ b/packages/rust/wasm/src/lib.rs @@ -0,0 +1,117 @@ +use epaper_dithering_core::{ + dither, DitherConfig, + enums::{DitherMode, GamutCompression, ToneCompression}, + measured_palettes::CATALOG, + palettes::{ColorScheme, Palette}, + types::ImageBuffer, +}; +use wasm_bindgen::prelude::*; + +fn parse_mode(v: u8) -> Result { + DitherMode::try_from(v).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// `None` ⇒ Auto; otherwise Fixed(value). Negative or zero acts as "off" via Fixed semantics. +fn parse_tone(v: Option) -> ToneCompression { + match v { + None => ToneCompression::Auto, + Some(s) => ToneCompression::Fixed(s), + } +} + +fn parse_gamut(v: Option) -> GamutCompression { + match v { + None => GamutCompression::Auto, + Some(s) if s > 0.0 => GamutCompression::Fixed(s), + _ => GamutCompression::None, + } +} + +/// Dither a flat RGB image. Accepts either an idealized `scheme_id` or a measured +/// `palette_bytes`/`accent_idx` pair; `palette_bytes` empty ⇒ use `scheme_id`. +/// +/// Returns a `Uint8Array` of palette indices (one per pixel, length = width × height). +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn dither_image( + pixels: &[u8], + width: usize, + scheme_id: u8, + palette_bytes: &[u8], + accent_idx: usize, + mode_id: u8, + serpentine: bool, + exposure: f64, + saturation: f64, + shadows: f64, + highlights: f64, + tone: Option, + gamut: Option, +) -> Result, JsValue> { + let img = ImageBuffer::new(pixels, width); + let config = DitherConfig { + mode: parse_mode(mode_id)?, + serpentine, + exposure, + saturation, + shadows, + highlights, + tone: parse_tone(tone), + gamut: parse_gamut(gamut), + }; + + if palette_bytes.is_empty() { + let scheme = ColorScheme::try_from(scheme_id) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + Ok(dither(&img, scheme.palette(), config)) + } else { + if !palette_bytes.len().is_multiple_of(3) { + return Err(JsValue::from_str("palette_bytes length must be a multiple of 3")); + } + let colors: Vec<[u8; 3]> = palette_bytes.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(); + let palette = Palette::new(colors, accent_idx); + Ok(dither(&img, palette, config)) + } +} + +/// Composite an RGBA buffer onto white, returning flat RGB bytes (sRGB). +#[wasm_bindgen] +pub fn composite_rgba(rgba: &[u8]) -> Vec { + let n = rgba.len() / 4; + let mut rgb = vec![0u8; n * 3]; + for i in 0..n { + let s = i * 4; + let a = rgba[s + 3] as f64 / 255.0; + let inv = 1.0 - a; + rgb[i * 3] = (rgba[s] as f64 * a + 255.0 * inv).round() as u8; + rgb[i * 3 + 1] = (rgba[s + 1] as f64 * a + 255.0 * inv).round() as u8; + rgb[i * 3 + 2] = (rgba[s + 2] as f64 * a + 255.0 * inv).round() as u8; + } + rgb +} + +/// Returns all measured palettes as a JSON string. +/// +/// Format: `[{"id": "SPECTRA_7_3_6COLOR", "colors": [[r,g,b], ...], "color_names": [...], "accent_idx": 3}, ...]` +#[wasm_bindgen] +pub fn measured_palettes() -> String { + let entries: Vec = CATALOG + .iter() + .map(|e| { + let colors: Vec = e.palette.colors.iter() + .map(|c| format!("[{},{},{}]", c[0], c[1], c[2])) + .collect(); + let names: Vec = e.color_names.iter() + .map(|s| format!("\"{}\"", s)) + .collect(); + format!( + "{{\"id\":\"{}\",\"colors\":[{}],\"color_names\":[{}],\"accent_idx\":{}}}", + e.id, + colors.join(","), + names.join(","), + e.palette.accent_idx, + ) + }) + .collect(); + format!("[{}]", entries.join(",")) +} diff --git a/release-please-config.json b/release-please-config.json index 3c7744f..783dfa2 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -2,26 +2,18 @@ "packages": { "packages/python": { "release-type": "python", - "component": "python", + "component": "epaper-dithering", "package-name": "epaper-dithering", - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": true, + "bump-minor-pre-major": false, + "bump-patch-for-minor-pre-major": false, "changelog-path": "CHANGELOG.md", "extra-files": [ "pyproject.toml", - "src/epaper_dithering/__init__.py" - ] - }, - "packages/javascript": { - "release-type": "node", - "component": "javascript", - "package-name": "@opendisplay/epaper-dithering", - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": true, - "changelog-path": "CHANGELOG.md", - "extra-files": [ - "package.json", - "src/index.ts" + "Cargo.toml", + "src/epaper_dithering/__init__.py", + "../../packages/rust/core/Cargo.toml", + "../../packages/rust/wasm/Cargo.toml", + "../../packages/javascript/package.json" ] } },