Skip to content

Instantly share code, notes, and snippets.

@ParkWardRR
Created June 1, 2026 17:58
Show Gist options
  • Select an option

  • Save ParkWardRR/9a60dd50a4361eef6928e4f58523ec71 to your computer and use it in GitHub Desktop.

Select an option

Save ParkWardRR/9a60dd50a4361eef6928e4f58523ec71 to your computer and use it in GitHub Desktop.
Photo Optimization for Thermal Printers — adaptive tone curves, 4-zone brightness model, Atkinson/Stucki/Sierra dithering

📸 Photo Optimization for Thermal Printers

Overview

The Problem

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.

The Pipeline

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]
Loading

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.

What is Dithering?

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.


How It Works

Adaptive Tone Curve Parameters

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

4-Zone Brightness Model

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

Std-Dev Awareness

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.

Washout Detection

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

Catches over-brightened images and re-processes with gentler parameters.


Deep Dive

Thermal Printing Physics

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

Dithering Algorithms

Atkinson (Default)

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

Stucki (Alternative — Smoothest Gradients)

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.

Sierra (Alternative — Middle Ground)

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

Adaptive Tone Curve Pipeline (Full)

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


Performance

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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment