Skip to content

Commit a8fa4ca

Browse files
committed
feat!: add auto tone compression as default
Analyzes image histogram (2nd/98th percentile) and remaps the actual used luminance range to the display's capabilities, maximizing contrast. Full-range images behave like strength=1.0; narrow-range images get more contrast than fixed linear compression. BREAKING CHANGE: tone_compression default changed from 1.0 to "auto".
1 parent 29da6f2 commit a8fa4ca

6 files changed

Lines changed: 197 additions & 27 deletions

File tree

packages/python/README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,19 +118,22 @@ Note: The `serpentine` parameter only affects error diffusion algorithms (Floyd-
118118

119119
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.
120120

121-
Tone compression remaps image luminance from [0, 1] to the display's actual [black, white] range before dithering, producing smoother results. Based on [`fast_compress_dynamic_range()`](https://github.com/aitjcize/esp32-photoframe) from esp32-photoframe by aitjcize. It is enabled by default (`tone_compression=1.0`) and only applies when using measured `ColorPalette` instances:
121+
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:
122+
123+
- **`"auto"`** (default): Analyzes the image histogram and remaps its actual luminance range to the display range. Maximizes contrast by stretching only the used range.
124+
- **`0.0-1.0`**: Fixed linear compression strength. `1.0` maps the full [0,1] range to the display range. `0.0` disables compression.
122125

123126
```python
124127
from epaper_dithering import dither_image, SPECTRA_7_3_6COLOR, DitherMode
125128

126-
# Default: full tone compression (recommended)
129+
# Default: auto tone compression (recommended)
127130
result = dither_image(img, SPECTRA_7_3_6COLOR, DitherMode.FLOYD_STEINBERG)
128131

132+
# Fixed linear compression
133+
result = dither_image(img, SPECTRA_7_3_6COLOR, DitherMode.FLOYD_STEINBERG, tone_compression=1.0)
134+
129135
# Disable tone compression
130136
result = dither_image(img, SPECTRA_7_3_6COLOR, DitherMode.FLOYD_STEINBERG, tone_compression=0.0)
131-
132-
# Partial compression (blend between original and compressed)
133-
result = dither_image(img, SPECTRA_7_3_6COLOR, DitherMode.FLOYD_STEINBERG, tone_compression=0.5)
134137
```
135138

136139
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.

packages/python/src/epaper_dithering/algorithms.py

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
precompute_palette_lab,
1515
)
1616
from .palettes import ColorPalette, ColorScheme
17-
from .tone_map import compress_dynamic_range
17+
from .tone_map import auto_compress_dynamic_range, compress_dynamic_range
1818

1919

2020
@dataclass(frozen=True)
@@ -140,7 +140,7 @@ def error_diffusion_dither(
140140
color_scheme: ColorScheme | ColorPalette,
141141
kernel: ErrorDiffusionKernel,
142142
serpentine: bool = True,
143-
tone_compression: float = 1.0,
143+
tone_compression: float | str = "auto",
144144
) -> Image.Image:
145145
"""Generic error diffusion dithering with any kernel.
146146
@@ -191,8 +191,11 @@ def error_diffusion_dither(
191191
palette_linear = srgb_to_linear(np.array(palette_srgb, dtype=np.float32))
192192

193193
# Compress dynamic range for measured palettes
194-
if isinstance(color_scheme, ColorPalette) and tone_compression > 0:
195-
pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression)
194+
if isinstance(color_scheme, ColorPalette) and tone_compression != 0:
195+
if tone_compression == "auto":
196+
pixels_linear = auto_compress_dynamic_range(pixels_linear, palette_linear)
197+
else:
198+
pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression)
196199

197200
# Pre-compute palette LAB components for scalar per-pixel matching
198201
palette_L, palette_a, palette_b, palette_C = precompute_palette_lab(palette_linear)
@@ -271,7 +274,7 @@ def error_diffusion_dither(
271274

272275
def floyd_steinberg_dither(
273276
image: Image.Image, color_scheme: ColorScheme | ColorPalette,
274-
serpentine: bool = True, tone_compression: float = 1.0,
277+
serpentine: bool = True, tone_compression: float | str = "auto",
275278
) -> Image.Image:
276279
"""Apply Floyd-Steinberg error diffusion dithering.
277280
@@ -295,7 +298,7 @@ def floyd_steinberg_dither(
295298

296299
def burkes_dither(
297300
image: Image.Image, color_scheme: ColorScheme | ColorPalette,
298-
serpentine: bool = True, tone_compression: float = 1.0,
301+
serpentine: bool = True, tone_compression: float | str = "auto",
299302
) -> Image.Image:
300303
"""Apply Burkes error diffusion dithering.
301304
@@ -317,7 +320,7 @@ def burkes_dither(
317320

318321
def sierra_dither(
319322
image: Image.Image, color_scheme: ColorScheme | ColorPalette,
320-
serpentine: bool = True, tone_compression: float = 1.0,
323+
serpentine: bool = True, tone_compression: float | str = "auto",
321324
) -> Image.Image:
322325
"""Apply Sierra error diffusion dithering.
323326
@@ -342,7 +345,7 @@ def sierra_dither(
342345

343346
def sierra_lite_dither(
344347
image: Image.Image, color_scheme: ColorScheme | ColorPalette,
345-
serpentine: bool = True, tone_compression: float = 1.0,
348+
serpentine: bool = True, tone_compression: float | str = "auto",
346349
) -> Image.Image:
347350
"""Apply Sierra Lite error diffusion dithering.
348351
@@ -366,7 +369,7 @@ def sierra_lite_dither(
366369

367370
def atkinson_dither(
368371
image: Image.Image, color_scheme: ColorScheme | ColorPalette,
369-
serpentine: bool = True, tone_compression: float = 1.0,
372+
serpentine: bool = True, tone_compression: float | str = "auto",
370373
) -> Image.Image:
371374
"""Apply Atkinson error diffusion dithering.
372375
@@ -391,7 +394,7 @@ def atkinson_dither(
391394

392395
def stucki_dither(
393396
image: Image.Image, color_scheme: ColorScheme | ColorPalette,
394-
serpentine: bool = True, tone_compression: float = 1.0,
397+
serpentine: bool = True, tone_compression: float | str = "auto",
395398
) -> Image.Image:
396399
"""Apply Stucki error diffusion dithering.
397400
@@ -416,7 +419,7 @@ def stucki_dither(
416419

417420
def jarvis_judice_ninke_dither(
418421
image: Image.Image, color_scheme: ColorScheme | ColorPalette,
419-
serpentine: bool = True, tone_compression: float = 1.0,
422+
serpentine: bool = True, tone_compression: float | str = "auto",
420423
) -> Image.Image:
421424
"""Apply Jarvis-Judice-Ninke error diffusion dithering.
422425
@@ -445,7 +448,7 @@ def jarvis_judice_ninke_dither(
445448

446449

447450
def direct_palette_map(
448-
image: Image.Image, color_scheme: ColorScheme | ColorPalette, tone_compression: float = 1.0,
451+
image: Image.Image, color_scheme: ColorScheme | ColorPalette, tone_compression: float | str = "auto",
449452
) -> Image.Image:
450453
"""Map image colors directly to palette without dithering.
451454
@@ -478,8 +481,11 @@ def direct_palette_map(
478481
palette_linear = srgb_to_linear(np.array(palette_srgb, dtype=np.float32))
479482

480483
# Compress dynamic range for measured palettes
481-
if isinstance(color_scheme, ColorPalette) and tone_compression > 0:
482-
pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression)
484+
if isinstance(color_scheme, ColorPalette) and tone_compression != 0:
485+
if tone_compression == "auto":
486+
pixels_linear = auto_compress_dynamic_range(pixels_linear, palette_linear)
487+
else:
488+
pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression)
483489

484490
# Find closest palette color for ALL pixels at once using LAB
485491
output_pixels = find_closest_palette_color_lab(pixels_linear, palette_linear)
@@ -494,7 +500,7 @@ def direct_palette_map(
494500

495501

496502
def ordered_dither(
497-
image: Image.Image, color_scheme: ColorScheme | ColorPalette, tone_compression: float = 1.0,
503+
image: Image.Image, color_scheme: ColorScheme | ColorPalette, tone_compression: float | str = "auto",
498504
) -> Image.Image:
499505
"""Apply ordered (Bayer) dithering with full vectorization.
500506
@@ -545,8 +551,11 @@ def ordered_dither(
545551
palette_linear = srgb_to_linear(np.array(palette_srgb, dtype=np.float32))
546552

547553
# Compress dynamic range for measured palettes
548-
if isinstance(color_scheme, ColorPalette) and tone_compression > 0:
549-
pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression)
554+
if isinstance(color_scheme, ColorPalette) and tone_compression != 0:
555+
if tone_compression == "auto":
556+
pixels_linear = auto_compress_dynamic_range(pixels_linear, palette_linear)
557+
else:
558+
pixels_linear = compress_dynamic_range(pixels_linear, palette_linear, tone_compression)
550559

551560
# ===== VECTORIZED ORDERED DITHERING =====
552561

packages/python/src/epaper_dithering/core.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def dither_image(
1818
color_scheme: ColorScheme | ColorPalette,
1919
mode: DitherMode = DitherMode.BURKES,
2020
serpentine: bool = True,
21-
tone_compression: float = 1.0,
21+
tone_compression: float | str = "auto",
2222
) -> Image.Image:
2323
"""Apply dithering to image for e-paper display.
2424
@@ -29,9 +29,10 @@ def dither_image(
2929
serpentine: Use serpentine scanning for error diffusion (default: True).
3030
Alternates scan direction each row to reduce directional artifacts.
3131
Only applies to error diffusion algorithms, ignored for NONE and ORDERED.
32-
tone_compression: Dynamic range compression strength (default: 1.0).
33-
Remaps image luminance to the display's actual range before dithering.
34-
0.0 = disabled, 1.0 = full compression. Only applies to measured ColorPalette.
32+
tone_compression: Dynamic range compression (default: "auto").
33+
"auto" = analyze image histogram and fit to display range.
34+
0.0 = disabled, 0.0-1.0 = fixed linear compression strength.
35+
Only applies to measured ColorPalette.
3536
3637
Returns:
3738
Dithered palette image matching color scheme

packages/python/src/epaper_dithering/tone_map.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,73 @@ def compress_dynamic_range(
8484
result[near_black, 2] = black_level
8585

8686
return np.clip(result, 0.0, 1.0)
87+
88+
89+
def auto_compress_dynamic_range(
90+
pixels_linear: np.ndarray,
91+
palette_linear: np.ndarray,
92+
) -> np.ndarray:
93+
"""Auto-levels dynamic range compression fitted to display capabilities.
94+
95+
Analyzes the image's actual luminance distribution and remaps it to the
96+
display's [black_Y, white_Y] range. Uses 2nd/98th percentiles to ignore
97+
outliers, maximizing contrast within the display's capabilities.
98+
99+
For full-range images this is equivalent to compress_dynamic_range(..., 1.0).
100+
For narrow-range images (e.g., all midtones) this preserves more contrast
101+
by stretching the used range to fill the display range.
102+
103+
Args:
104+
pixels_linear: Image in linear RGB, shape (H, W, 3), values in [0, 1].
105+
palette_linear: Palette in linear RGB, shape (N, 3). Row 0 = black, row 1 = white.
106+
107+
Returns:
108+
Modified pixels_linear array with compressed dynamic range.
109+
"""
110+
# Display black/white luminance from measured palette
111+
black_Y = (_LUM_R * palette_linear[0, 0]
112+
+ _LUM_G * palette_linear[0, 1]
113+
+ _LUM_B * palette_linear[0, 2])
114+
white_Y = (_LUM_R * palette_linear[1, 0]
115+
+ _LUM_G * palette_linear[1, 1]
116+
+ _LUM_B * palette_linear[1, 2])
117+
display_range = white_Y - black_Y
118+
119+
if display_range <= 0:
120+
return pixels_linear
121+
122+
# Per-pixel luminance
123+
Y = (_LUM_R * pixels_linear[:, :, 0]
124+
+ _LUM_G * pixels_linear[:, :, 1]
125+
+ _LUM_B * pixels_linear[:, :, 2])
126+
127+
# Image luminance percentiles (ignore 2% outliers at each end)
128+
p_low = float(np.percentile(Y, 2))
129+
p_high = float(np.percentile(Y, 98))
130+
image_range = p_high - p_low
131+
132+
if image_range < 1e-6:
133+
# Uniform image: fall back to standard linear compression
134+
return compress_dynamic_range(pixels_linear, palette_linear, 1.0)
135+
136+
# Remap: [p_low, p_high] → [black_Y, white_Y]
137+
normalized_Y = (Y - p_low) / image_range
138+
target_Y = black_Y + normalized_Y * display_range
139+
140+
# Scale RGB proportionally to preserve hue
141+
safe_Y = np.where(Y > 1e-6, Y, 1.0)
142+
scale = np.where(Y > 1e-6, target_Y / safe_Y, 0.0)
143+
144+
result = pixels_linear.copy()
145+
result[:, :, 0] *= scale
146+
result[:, :, 1] *= scale
147+
result[:, :, 2] *= scale
148+
149+
# Near-black pixels: set to display black luminance
150+
near_black = Y <= 1e-6
151+
if np.any(near_black):
152+
result[near_black, 0] = black_Y
153+
result[near_black, 1] = black_Y
154+
result[near_black, 2] = black_Y
155+
156+
return np.clip(result, 0.0, 1.0)

packages/python/tests/test_color_matching.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from epaper_dithering import ColorScheme, DitherMode, dither_image
88
from epaper_dithering.color_space import srgb_to_linear
99
from epaper_dithering.color_space_lab import rgb_to_lab
10-
from epaper_dithering.tone_map import compress_dynamic_range
10+
from epaper_dithering.tone_map import auto_compress_dynamic_range, compress_dynamic_range
1111

1212

1313
class TestLABConversion:
@@ -177,3 +177,76 @@ def test_near_black_pixels_get_display_black(self):
177177
+ 0.0721750 * palette_linear[0, 2])
178178
# Near-black pixels should be set to approximately display black luminance
179179
assert result.mean() == pytest.approx(black_Y, abs=0.01)
180+
181+
182+
class TestAutoCompressDynamicRange:
183+
"""Unit tests for auto (percentile-based) dynamic range compression."""
184+
185+
def _make_palette_linear(self, black_srgb, white_srgb):
186+
"""Helper: convert black/white sRGB tuples to linear palette array."""
187+
palette_srgb = np.array([black_srgb, white_srgb], dtype=np.float32)
188+
return srgb_to_linear(palette_srgb)
189+
190+
def _luminance(self, pixels):
191+
"""Compute per-pixel luminance."""
192+
return (0.2126729 * pixels[:, :, 0]
193+
+ 0.7151522 * pixels[:, :, 1]
194+
+ 0.0721750 * pixels[:, :, 2])
195+
196+
def test_full_range_gradient_matches_linear(self):
197+
"""Full-range gradient should produce similar result to linear compression."""
198+
palette_linear = self._make_palette_linear([30, 30, 30], [200, 200, 200])
199+
pixels = np.linspace(0.0, 1.0, 100).reshape(10, 10, 1).repeat(3, axis=2).astype(np.float32)
200+
201+
auto_result = auto_compress_dynamic_range(pixels.copy(), palette_linear)
202+
linear_result = compress_dynamic_range(pixels.copy(), palette_linear, 1.0)
203+
204+
# For a full-range gradient, auto should be close to linear 1.0
205+
np.testing.assert_allclose(auto_result, linear_result, atol=0.05)
206+
207+
def test_narrow_range_has_more_contrast(self):
208+
"""Narrow-range image should have more contrast with auto than fixed 1.0."""
209+
palette_linear = self._make_palette_linear([30, 30, 30], [200, 200, 200])
210+
# Image using only 30-70% of luminance range
211+
pixels = np.linspace(0.3, 0.7, 100).reshape(10, 10, 1).repeat(3, axis=2).astype(np.float32)
212+
213+
auto_result = auto_compress_dynamic_range(pixels.copy(), palette_linear)
214+
linear_result = compress_dynamic_range(pixels.copy(), palette_linear, 1.0)
215+
216+
# Auto should stretch to use more of the display range
217+
auto_Y = self._luminance(auto_result)
218+
linear_Y = self._luminance(linear_result)
219+
auto_range = float(auto_Y.max() - auto_Y.min())
220+
linear_range = float(linear_Y.max() - linear_Y.min())
221+
222+
assert auto_range > linear_range, \
223+
f"Auto range {auto_range:.4f} should exceed linear range {linear_range:.4f}"
224+
225+
def test_uniform_image_falls_back(self):
226+
"""Uniform image should fall back to linear compression."""
227+
palette_linear = self._make_palette_linear([30, 30, 30], [200, 200, 200])
228+
pixels = np.full((5, 5, 3), 0.5, dtype=np.float32)
229+
230+
result = auto_compress_dynamic_range(pixels, palette_linear)
231+
expected = compress_dynamic_range(pixels.copy(), palette_linear, 1.0)
232+
np.testing.assert_allclose(result, expected, atol=1e-5)
233+
234+
def test_output_luminance_within_display_range(self):
235+
"""Auto-compressed output luminance should stay within display range (with tolerance)."""
236+
palette_linear = self._make_palette_linear([30, 30, 30], [200, 200, 200])
237+
black_Y = float(0.2126729 * palette_linear[0, 0]
238+
+ 0.7151522 * palette_linear[0, 1]
239+
+ 0.0721750 * palette_linear[0, 2])
240+
white_Y = float(0.2126729 * palette_linear[1, 0]
241+
+ 0.7151522 * palette_linear[1, 1]
242+
+ 0.0721750 * palette_linear[1, 2])
243+
244+
pixels = np.linspace(0.0, 1.0, 100).reshape(10, 10, 1).repeat(3, axis=2).astype(np.float32)
245+
result = auto_compress_dynamic_range(pixels, palette_linear)
246+
result_Y = self._luminance(result)
247+
248+
# p2/p98 percentile-based: the 2% outliers at each end may exceed the range slightly
249+
p2 = float(np.percentile(result_Y, 2))
250+
p98 = float(np.percentile(result_Y, 98))
251+
assert p2 >= black_Y - 0.01, f"p2 luminance {p2:.4f} should be near black_Y {black_Y:.4f}"
252+
assert p98 <= white_Y + 0.01, f"p98 luminance {p98:.4f} should be near white_Y {white_Y:.4f}"

packages/python/tests/test_dithering.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,23 @@ def test_tone_compression_skipped_for_color_scheme(self):
218218
# These should produce identical output since ColorScheme bypasses compression
219219
result_tc0 = dither_image(img, ColorScheme.MONO, DitherMode.NONE, tone_compression=0.0)
220220
result_tc1 = dither_image(img, ColorScheme.MONO, DitherMode.NONE, tone_compression=1.0)
221+
result_auto = dither_image(img, ColorScheme.MONO, DitherMode.NONE, tone_compression="auto")
221222

222223
assert np.array_equal(np.array(result_tc0), np.array(result_tc1)), \
223224
"Tone compression should have no effect on theoretical ColorScheme"
225+
assert np.array_equal(np.array(result_tc0), np.array(result_auto)), \
226+
"Auto tone compression should have no effect on theoretical ColorScheme"
227+
228+
@pytest.mark.parametrize("mode", list(DitherMode))
229+
def test_auto_tone_compression_all_modes(self, mode):
230+
"""Auto tone compression (default) should produce valid output for all modes."""
231+
from epaper_dithering import SPECTRA_7_3_6COLOR
232+
233+
img = Image.new("RGB", (10, 10), (128, 128, 128))
234+
result = dither_image(img, SPECTRA_7_3_6COLOR, mode)
235+
236+
assert result.mode == 'P'
237+
assert result.size == (10, 10)
224238

225239
def test_tone_compression_changes_measured_output(self):
226240
"""Tone compression should change the output for measured palettes."""

0 commit comments

Comments
 (0)