Thermal printers can only print black or white — no gray, no color. Every pixel is a binary decision. Reproducing a photograph means compressing 256 brightness levels into 2.
flowchart LR
A[RGB Input] --> B[Grayscale]
B --> C[Gamma Correction]
C --> D[Autocontrast]
D --> E[CLAHE Local Contrast]
E --> F[Contrast + Brightness]
F --> G[Sharpening]
G --> H[Sigmoid Tone Map]
H --> I[Dithering]
I --> J[1-bit ESC/POS Output]
Every image gets analyzed first. The pipeline picks settings per image — dark photos get more lift, bright photos get pulled down, flat images get extra contrast.
Imagine drawing with only a black pen on white paper. To show light gray, you draw tiny dots far apart. For dark gray, dots close together. That's dithering.
Atkinson dithering (invented by Bill Atkinson at Apple in the 1980s) spreads only 75% of the quantization error to neighboring pixels. The "lost" 25% makes highlights brighter and shadows deeper — ideal for thermal paper's limited dynamic range.
The optimize_for_thermal() function analyzes each image and selects parameters from a 4-zone brightness model with std-dev awareness. Returns an (img, params) tuple so the Stats receipt can report exactly what happened.
Baseline values (tuned for portrait/skin-tone preservation):
| Parameter | Value |
|---|---|
| Gamma | 1.3 |
| Contrast | 1.35× |
| Brightness | 1.05× |
| Sharpness | 130% @ radius 1.5 |
| CLAHE blend | 0.30 |
| Sigmoid k | 6.0 |
| Zone | Condition | γ | Brightness | Contrast | Sigmoid k | CLAHE |
|---|---|---|---|---|---|---|
| Very Dark | mean < 60 | 2.0 | 1.30 | 1.25 | 5.0 | 0.35 |
| Dark | mean < 100 | 1.6 | 1.15 | baseline | 5.5 | 0.32 |
| Bright | mean > 160 | 1.1 | 0.95 | 1.4 | 6.5 | baseline |
| Very Bright | mean > 200 | 1.0 | 0.90 | 1.5 | 7.0 | baseline |
The pipeline also checks pixel standard deviation to avoid over-processing:
| Condition | Effect |
|---|---|
std > 70 (high contrast) |
Caps contrast at 1.3, CLAHE at 0.25, sigmoid_k at 6.0 |
std < 30 (very flat) |
Floors contrast at 1.5, CLAHE at 0.40 |
Note
This prevents clipping in images that already have good dynamic range, while boosting flat/washed-out images that need help.
After optimization, the filter checks for washed-out results:
if post_mean > 195 or post_pct_white > 60:
# Re-process with gamma=1.1, brightness=0.95Catches over-brightened images and re-processes with gentler parameters.
Thermal printers use a fixed-width array of heating elements (203 DPI ≈ 125μm pitch). Each element is fully on (black) or fully off (white) — true 1-bit output. No PWM, no variable-duration heating.
Key challenges:
| Challenge | Description |
|---|---|
| Tone compression | 8-bit input (256 levels) → 1-bit output (2 levels) |
| Dot gain | Heat spreads laterally through the paper substrate, enlarging black areas |
| Thermal memory | Large black areas accumulate heat, printing darker than intended |
| Low resolution | 203 DPI vs 300–1200 DPI for typical photo printers |
Distributes error to 6 neighbors with 1/8 coefficient each (6/8 = 75% total diffusion):
* 1 1
1 1 1
1
The 25% "lost" error creates crisper highlights and deeper shadows. Implementation is pixel-sequential (not vectorizable due to error propagation dependencies):
for y in range(h):
for x in range(w):
old = pixels[y, x]
new = 255 if old >= 128 else 0
pixels[y, x] = new
err = (old - new) / 8
# Distribute to 6 neighbors...Distributes error to 12 neighbors with a /42 divisor matrix. Produces the smoothest tonal gradients at the cost of slightly softer detail:
* 8 4
2 4 8 4 2
1 2 4 2 1
Tip
Best for images with large smooth gradients (sky, skin). Trade-off: less sharp edges than Atkinson.
Distributes error to 10 neighbors with a /32 divisor matrix. Balances smoothness and detail between Atkinson and Stucki:
* 5 3
2 4 5 4 2
2 3 2
| Algorithm | Neighbors | Divisor | Error Diffused | Best For |
|---|---|---|---|---|
| Atkinson | 6 | /8 | 75% | High contrast, thermal paper |
| Stucki | 12 | /42 | 100% | Smooth gradients, portraits |
| Sierra | 10 | /32 | 100% | General purpose, balanced |
# 1. Grayscale conversion (luminance-weighted)
img = img.convert("L")
# 2. Gamma correction
pixels = 255.0 * (pixels / 255.0) ** (1.0 / gamma)
# 3. Autocontrast (2% cutoff)
img = ImageOps.autocontrast(img, cutoff=2)
# 4. CLAHE-like local contrast
blurred = img.filter(GaussianBlur(radius=15))
local = pixels - blur_px + 128
pixels = (1 - clahe_blend) * pixels + clahe_blend * local
# 5. Contrast + Brightness (PIL ImageEnhance)
img = ImageEnhance.Contrast(img).enhance(contrast)
img = ImageEnhance.Brightness(img).enhance(brightness)
# 6. Unsharp mask sharpening
img = img.filter(UnsharpMask(radius=sharp_r, percent=sharp_pct, threshold=2))
# 7. Sigmoid tone mapping
pixels = 1.0 / (1.0 + exp(-sigmoid_k * (pixels - 0.5)))
# 8. Final autocontrast (1% cutoff)
img = ImageOps.autocontrast(img, cutoff=1)Important
The function returns (img, params) — the params dict captures every tuning value applied. This feeds directly into the Stats receipt for full pipeline transparency.
On Raspberry Pi (ARM, 416MB RAM):
| Stage | Time |
|---|---|
| Image analysis | ~50ms (scaled thumbnail) |
| Tone curve optimization | ~100–200ms |
| Atkinson dither (576×800) | ~2–4s |
| ESC/POS encoding | ~50ms |
| Total filter time | ~3–6s per image |
| Peak memory | ~80MB (float32 pixel arrays) |