Skip to content

Feature - ordered dither: apply Bayer threshold in perceptual (sRGB) space #27

@claudobahn

Description

@claudobahn

The ordered dither currently adds the Bayer threshold to pixel values in linear RGB space. Because of the sRGB gamma curve, this produces a ~2-3x stronger perceptual effect in shadows/midtones than in highlights — dark regions get visibly heavier dithering patterns while bright areas are barely affected.

Applying the threshold in sRGB space instead (or scaling it by local perceptual sensitivity) would produce more uniform dithering across the tonal range. This matches how error diffusion already works — it accumulates error in sRGB space specifically because mid-tone sRGB 128 should produce ~50% dithering dots, not the ~21% that linear space gives.

Note: this affects all three language implementations (Rust, JS, Python) equally — the Rust port faithfully preserved the original behavior. Changing it would alter output for all images using ordered dithering and would require regenerating regression references.

An attempt at visualizing the challenge:

Image

And the code that was used to generate that:

import numpy as np
import matplotlib.pyplot as plt

def srgb_to_linear(x):
    return np.where(x <= 0.04045, x / 12.92, ((x + 0.055) / 1.055) ** 2.4)

def linear_to_srgb(x):
    return np.where(x <= 0.0031308, x * 12.92, 1.055 * (x ** (1 / 2.4)) - 0.055)

# Plot 1 Data
srgb_vals = np.linspace(0, 1, 256)
threshold = 0.5

# Threshold in Linear Space
linear_vals = srgb_to_linear(srgb_vals)
lin_plus = np.clip(linear_vals + threshold, 0, 1)
lin_minus = np.clip(linear_vals - threshold, 0, 1)
lin_spread_srgb = (linear_to_srgb(lin_plus) - linear_to_srgb(lin_minus)) * 255

# Threshold in sRGB Space
srgb_plus = np.clip(srgb_vals + threshold, 0, 1)
srgb_minus = np.clip(srgb_vals - threshold, 0, 1)
srgb_spread_srgb = (srgb_plus - srgb_minus) * 255

# Plot 2 Data (Gradient)
gradient_base = np.linspace(0, 1, 256).reshape(1, -1)
gradient_base = np.tile(gradient_base, (40, 1))

# Row 2: Linear + 0.25
lin_grad = srgb_to_linear(gradient_base)
lin_grad_mod = np.clip(lin_grad + 0.25, 0, 1)
row2 = linear_to_srgb(lin_grad_mod)

# Row 3: sRGB + 0.25
row3 = np.clip(gradient_base + 0.25, 0, 1)

# Combined Image for Plot 2
# We'll stack them with spacers
spacer = np.ones((5, 256))
vis_gradient = np.vstack([gradient_base, spacer, row2, spacer, row3])

# Create Figure
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 10))

# Plot 1
ax1.plot(srgb_vals * 255, lin_spread_srgb, color='blue', label='threshold in linear space')
ax1.plot(srgb_vals * 255, srgb_spread_srgb, color='red', label='threshold in sRGB space')
ax1.set_title("Perceptual spread of $\pm 0.5$ threshold: linear vs sRGB space")
ax1.set_xlabel("input sRGB value (0–255)")
ax1.set_ylabel("perceptual spread in sRGB levels")
ax1.legend()
ax1.grid(True, alpha=0.3)

# Plot 2
ax2.imshow(vis_gradient, cmap='gray', aspect='auto', extent=[0, 255, 0, 130])
ax2.set_yticks([110, 65, 20])
ax2.set_yticklabels(['Original', 'Linear +0.25', 'sRGB +0.25'])
ax2.set_title("Threshold effect on a gradient")
ax2.set_xlabel("sRGB Level")
ax2.set_xticks(np.arange(0, 257, 32))

plt.tight_layout()
plt.savefig('dithering_comparison.png')
plt.close()

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions