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:
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()
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:
And the code that was used to generate that: