diff --git a/src/Command/RenderCompareCommand.php b/src/Command/RenderCompareCommand.php index 8d235e2..38d2d9f 100644 --- a/src/Command/RenderCompareCommand.php +++ b/src/Command/RenderCompareCommand.php @@ -58,6 +58,7 @@ final class RenderCompareCommand extends Command 'gamma' => 1.0, 'sharpen' => 0.8, 'blur' => 0.0, + 'blue_mul' => 1.0, 'dither' => \Imagick::DITHERMETHOD_FLOYDSTEINBERG, ]; @@ -94,6 +95,7 @@ final class RenderCompareCommand extends Command 'gamma' => RenderImageMessageHandler::GAMMA, 'sharpen' => RenderImageMessageHandler::SHARPEN_SIGMA, 'blur' => RenderImageMessageHandler::BLUR_SIGMA, + 'blue_mul' => RenderImageMessageHandler::BLUE_CHANNEL_MUL, 'dither' => RenderImageMessageHandler::DITHER_METHOD, ]; @@ -140,8 +142,8 @@ final class RenderCompareCommand extends Command ? 'FloydSteinberg' : ($p['dither'] === \Imagick::DITHERMETHOD_RIEMERSMA ? 'Riemersma' : 'No'); return sprintf( - 'sat=%d gamma=%.2f sharpen=%.2f blur=%.2f dither=%s', - $p['saturation'], $p['gamma'], $p['sharpen'], $p['blur'], $dither, + 'sat=%d gamma=%.2f sharpen=%.2f blur=%.2f blue_mul=%.2f dither=%s', + $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) { $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) { $canvas = new \Imagick(); diff --git a/src/MessageHandler/RenderImageMessageHandler.php b/src/MessageHandler/RenderImageMessageHandler.php index c594345..fe63a47 100644 --- a/src/MessageHandler/RenderImageMessageHandler.php +++ b/src/MessageHandler/RenderImageMessageHandler.php @@ -37,29 +37,28 @@ final class RenderImageMessageHandler /** modulateImage(brightness=100, SATURATION, hue=100). 100 = no change. * Baseline: 130 (+30%, prevents desaturated photos collapsing to dither - * noise). Experiment #1: 115 (less risk of garish faces). */ - public const SATURATION_PCT = 115; + * noise; greens stay vibrant). */ + public const SATURATION_PCT = 130; - /** gammaImage(GAMMA). 1.0 = no change; >1 lifts midtones, <1 crushes them. - * Baseline: 1.0 (no gamma pass). Experiment #1: 1.2 (gentle midtone lift). */ - public const GAMMA = 1.2; + /** gammaImage(GAMMA). 1.0 = no change; >1 lifts midtones, <1 crushes. */ + public const GAMMA = 1.0; /** sharpenImage(radius=0, SHARPEN_SIGMA). Baseline: 0.8 (light). */ public const SHARPEN_SIGMA = 0.8; - /** blurImage(radius=0, BLUR_SIGMA) applied *before* dither. 0 = no blur. - * Baseline: 0.0. Experiment #3: 0.6 — softens sharp blue-sky → skin - * 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; + /** blurImage(radius=0, BLUR_SIGMA) applied *before* dither. 0 = no blur. */ + public const BLUR_SIGMA = 0.0; - /** Imagick dither method. - * FLOYDSTEINBERG (baseline) — error diffuses down-right in row order, - * risks blue bleed into faces below sky. - * RIEMERSMA — Hilbert-curve scan, error stays local but produces - * visible "ink-spill" streaks in low-contrast regions. - * Rejected experiment #2 — much worse than the bleed. - * Sticking with Floyd-Steinberg, attacking the bleed via blur. */ + /** evaluateImage(MULTIPLY, BLUE_CHANNEL_MUL, CHANNEL_BLUE) — scales the + * source's blue channel before dither. 1.0 = no change. + * Experiment #4: 0.95 (knock 5% off blue). Real sky stays well above + * the BLUE-vs-WHITE boundary, but borderline-bluish skin pixels (sky + * cast on faces in outdoor photos) drop below it and map to YELLOW / + * WHITE / RED instead of contaminating the face with BLUE dither. */ + 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 function __construct( @@ -166,15 +165,20 @@ final class RenderImageMessageHandler // Light sharpen so edges survive the dithering scatter. $imagick->sharpenImage(0, self::SHARPEN_SIGMA); - // 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. + // Optional pre-dither blur to soften sharp colour transitions. if (self::BLUR_SIGMA > 0) { $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 // aspect mismatch shows up as letterbox bars, not as a sliced crop. if ($imagick->getImageWidth() !== $width || $imagick->getImageHeight() !== $height) {