experiment(render): revert sat/gamma/blur to baseline; blue-channel ×0.95
CI / test (push) Has been cancelled

Experiment #3 (sat 115, gamma 1.2, blur 0.6) was a net loss — greens
desaturated, no help on sky→face blue bleed.

Reverting those three to baseline (130, 1.0, 0.0). New experiment #4:
multiply the source's blue channel by 0.95 before dither. Real sky
stays well above the BLUE-vs-WHITE boundary, but borderline-bluish
skin (sky cast on outdoor faces) drops below it and maps to YELLOW /
WHITE / RED instead of feeding the dither's BLUE attractor.

Adds public BLUE_CHANNEL_MUL constant; render-compare's baseline
struct gets blue_mul=1.0 so half-A is still frozen at the original.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 13:54:11 -04:00
parent a37edcb6c7
commit 488fc3d0f4
2 changed files with 33 additions and 24 deletions
+7 -2
View File
@@ -58,6 +58,7 @@ final class RenderCompareCommand extends Command
'gamma' => 1.0, 'gamma' => 1.0,
'sharpen' => 0.8, 'sharpen' => 0.8,
'blur' => 0.0, 'blur' => 0.0,
'blue_mul' => 1.0,
'dither' => \Imagick::DITHERMETHOD_FLOYDSTEINBERG, 'dither' => \Imagick::DITHERMETHOD_FLOYDSTEINBERG,
]; ];
@@ -94,6 +95,7 @@ final class RenderCompareCommand extends Command
'gamma' => RenderImageMessageHandler::GAMMA, 'gamma' => RenderImageMessageHandler::GAMMA,
'sharpen' => RenderImageMessageHandler::SHARPEN_SIGMA, 'sharpen' => RenderImageMessageHandler::SHARPEN_SIGMA,
'blur' => RenderImageMessageHandler::BLUR_SIGMA, 'blur' => RenderImageMessageHandler::BLUR_SIGMA,
'blue_mul' => RenderImageMessageHandler::BLUE_CHANNEL_MUL,
'dither' => RenderImageMessageHandler::DITHER_METHOD, 'dither' => RenderImageMessageHandler::DITHER_METHOD,
]; ];
@@ -140,8 +142,8 @@ final class RenderCompareCommand extends Command
? 'FloydSteinberg' ? 'FloydSteinberg'
: ($p['dither'] === \Imagick::DITHERMETHOD_RIEMERSMA ? 'Riemersma' : 'No'); : ($p['dither'] === \Imagick::DITHERMETHOD_RIEMERSMA ? 'Riemersma' : 'No');
return sprintf( return sprintf(
'sat=%d gamma=%.2f sharpen=%.2f blur=%.2f dither=%s', 'sat=%d gamma=%.2f sharpen=%.2f blur=%.2f blue_mul=%.2f dither=%s',
$p['saturation'], $p['gamma'], $p['sharpen'], $p['blur'], $dither, $p['saturation'], $p['gamma'], $p['sharpen'], $p['blur'], $p['blue_mul'], $dither,
); );
} }
@@ -164,6 +166,9 @@ final class RenderCompareCommand extends Command
if ($params['blur'] > 0) { if ($params['blur'] > 0) {
$im->blurImage(0, $params['blur']); $im->blurImage(0, $params['blur']);
} }
if ($params['blue_mul'] !== 1.0) {
$im->evaluateImage(\Imagick::EVALUATE_MULTIPLY, $params['blue_mul'], \Imagick::CHANNEL_BLUE);
}
if ($im->getImageWidth() !== self::W || $im->getImageHeight() !== self::FULL_H) { if ($im->getImageWidth() !== self::W || $im->getImageHeight() !== self::FULL_H) {
$canvas = new \Imagick(); $canvas = new \Imagick();
@@ -37,29 +37,28 @@ final class RenderImageMessageHandler
/** modulateImage(brightness=100, SATURATION, hue=100). 100 = no change. /** modulateImage(brightness=100, SATURATION, hue=100). 100 = no change.
* Baseline: 130 (+30%, prevents desaturated photos collapsing to dither * Baseline: 130 (+30%, prevents desaturated photos collapsing to dither
* noise). Experiment #1: 115 (less risk of garish faces). */ * noise; greens stay vibrant). */
public const SATURATION_PCT = 115; public const SATURATION_PCT = 130;
/** gammaImage(GAMMA). 1.0 = no change; >1 lifts midtones, <1 crushes them. /** gammaImage(GAMMA). 1.0 = no change; >1 lifts midtones, <1 crushes. */
* Baseline: 1.0 (no gamma pass). Experiment #1: 1.2 (gentle midtone lift). */ public const GAMMA = 1.0;
public const GAMMA = 1.2;
/** sharpenImage(radius=0, SHARPEN_SIGMA). Baseline: 0.8 (light). */ /** sharpenImage(radius=0, SHARPEN_SIGMA). Baseline: 0.8 (light). */
public const SHARPEN_SIGMA = 0.8; public const SHARPEN_SIGMA = 0.8;
/** blurImage(radius=0, BLUR_SIGMA) applied *before* dither. 0 = no blur. /** blurImage(radius=0, BLUR_SIGMA) applied *before* dither. 0 = no blur. */
* Baseline: 0.0. Experiment #3: 0.6 — softens sharp blue-sky → skin public const BLUR_SIGMA = 0.0;
* transitions so Floyd-Steinberg has less per-pixel error to carry
* forward, reducing the blue "bleed" into faces below the sky. */
public const BLUR_SIGMA = 0.6;
/** Imagick dither method. /** evaluateImage(MULTIPLY, BLUE_CHANNEL_MUL, CHANNEL_BLUE) — scales the
* FLOYDSTEINBERG (baseline) — error diffuses down-right in row order, * source's blue channel before dither. 1.0 = no change.
* risks blue bleed into faces below sky. * Experiment #4: 0.95 (knock 5% off blue). Real sky stays well above
* RIEMERSMA — Hilbert-curve scan, error stays local but produces * the BLUE-vs-WHITE boundary, but borderline-bluish skin pixels (sky
* visible "ink-spill" streaks in low-contrast regions. * cast on faces in outdoor photos) drop below it and map to YELLOW /
* Rejected experiment #2 — much worse than the bleed. * WHITE / RED instead of contaminating the face with BLUE dither. */
* Sticking with Floyd-Steinberg, attacking the bleed via blur. */ public const BLUE_CHANNEL_MUL = 0.95;
/** Imagick dither method. Sticking with Floyd-Steinberg — Riemersma
* produced visible Hilbert-curve "ink-spill" streaks in skin/sky. */
public const DITHER_METHOD = \Imagick::DITHERMETHOD_FLOYDSTEINBERG; public const DITHER_METHOD = \Imagick::DITHERMETHOD_FLOYDSTEINBERG;
public function __construct( public function __construct(
@@ -166,15 +165,20 @@ final class RenderImageMessageHandler
// Light sharpen so edges survive the dithering scatter. // Light sharpen so edges survive the dithering scatter.
$imagick->sharpenImage(0, self::SHARPEN_SIGMA); $imagick->sharpenImage(0, self::SHARPEN_SIGMA);
// Optional pre-dither blur to soften sharp colour transitions // Optional pre-dither blur to soften sharp colour transitions.
// Floyd-Steinberg propagates each pixel's error to its neighbours,
// and a hard sky→skin boundary leaks blue into faces below. Even
// a sub-1px Gaussian smooths the boundary enough that the carried
// error is small. Sharpening above keeps edge crispness.
if (self::BLUR_SIGMA > 0) { if (self::BLUR_SIGMA > 0) {
$imagick->blurImage(0, self::BLUR_SIGMA); $imagick->blurImage(0, self::BLUR_SIGMA);
} }
// Optional blue-channel knock-down. Spectra-6 has only 6 inks; skin
// tones in outdoor photos pick up a sky cast that the dither would
// otherwise map to BLUE. Scaling the blue channel down a few % pulls
// borderline-bluish-skin pixels off the BLUE attractor while leaving
// real sky safely above it.
if (self::BLUE_CHANNEL_MUL !== 1.0) {
$imagick->evaluateImage(\Imagick::EVALUATE_MULTIPLY, self::BLUE_CHANNEL_MUL, \Imagick::CHANNEL_BLUE);
}
// Now composite onto a white canvas of exact target dims so that any // Now composite onto a white canvas of exact target dims so that any
// aspect mismatch shows up as letterbox bars, not as a sliced crop. // aspect mismatch shows up as letterbox bars, not as a sliced crop.
if ($imagick->getImageWidth() !== $width || $imagick->getImageHeight() !== $height) { if ($imagick->getImageWidth() !== $width || $imagick->getImageHeight() !== $height) {