From 324f1b26417066fbd2e99b4cb3a30bcc2ad18b6d Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Thu, 14 May 2026 14:03:25 -0400 Subject: [PATCH] experiment(render): revert blue_mul; shift BLUE palette target to (8,32,220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Experiment #4 (blue×0.95) made shadow regions appear MORE blue, not less — reducing source blue still leaves positive blue error after a BLACK mapping, and the dither spends that error on neighbours, creating blue dither dots in dark regions. Reverting blue_mul to 1.0. Experiment #5 takes a different attack on the same problem: shift the BLUE palette mapping target from the muted (24, 64, 192) to a more saturated (8, 32, 220). Doesn't change what the panel displays (the blue ink is fixed); it just makes Euclidean distance from skin tones to "BLUE" larger in the algorithm's view, so the dither prefers RED/WHITE/YELLOW for borderline pixels. Render-compare's BASELINE struct now carries its own frozen palette, so half-A keeps the original (24,64,192) BLUE target while half-B pulls the shifted palette from the live pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Command/RenderCompareCommand.php | 21 ++++++++------ .../RenderImageMessageHandler.php | 28 +++++++++++++------ 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/Command/RenderCompareCommand.php b/src/Command/RenderCompareCommand.php index 38d2d9f..96aa37e 100644 --- a/src/Command/RenderCompareCommand.php +++ b/src/Command/RenderCompareCommand.php @@ -39,7 +39,8 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; )] final class RenderCompareCommand extends Command { - private const PALETTE = [ + /** Frozen baseline palette — render-baseline-2026-05-14. NEVER touch. */ + private const BASELINE_PALETTE = [ 0x0 => [26, 26, 26 ], 0x1 => [245, 245, 240], 0x2 => [240, 208, 0 ], @@ -52,13 +53,14 @@ final class RenderCompareCommand extends Command private const HALF_H = 800; private const FULL_H = 1600; - /** Baseline tunables — must NOT track the live pipeline. Frozen reference. */ + /** Baseline tunables — frozen reference, never tracks the live pipeline. */ private const BASELINE = [ 'saturation' => 130, 'gamma' => 1.0, 'sharpen' => 0.8, 'blur' => 0.0, 'blue_mul' => 1.0, + 'palette' => self::BASELINE_PALETTE, 'dither' => \Imagick::DITHERMETHOD_FLOYDSTEINBERG, ]; @@ -96,6 +98,7 @@ final class RenderCompareCommand extends Command 'sharpen' => RenderImageMessageHandler::SHARPEN_SIGMA, 'blur' => RenderImageMessageHandler::BLUR_SIGMA, 'blue_mul' => RenderImageMessageHandler::BLUE_CHANNEL_MUL, + 'palette' => RenderImageMessageHandler::PALETTE, 'dither' => RenderImageMessageHandler::DITHER_METHOD, ]; @@ -186,7 +189,7 @@ final class RenderCompareCommand extends Command $im->cropImage(self::W, self::HALF_H, 0, 0); $im->setImagePage(self::W, self::HALF_H, 0, 0); - $palStrip = $this->buildPaletteStrip(); + $palStrip = $this->buildPaletteStrip($params['palette']); $im->remapImage($palStrip, $params['dither']); $palStrip->destroy(); @@ -200,17 +203,17 @@ final class RenderCompareCommand extends Command for ($i = 0; $i < $total; $i += 2) { $base0 = $i * 3; $base1 = $base0 + 3; - $n0 = $this->nearestPalette(ord($blob[$base0]), ord($blob[$base0 + 1]), ord($blob[$base0 + 2])); - $n1 = $this->nearestPalette(ord($blob[$base1]), ord($blob[$base1 + 1]), ord($blob[$base1 + 2])); + $n0 = $this->nearestPalette($params['palette'], ord($blob[$base0]), ord($blob[$base0 + 1]), ord($blob[$base0 + 2])); + $n1 = $this->nearestPalette($params['palette'], ord($blob[$base1]), ord($blob[$base1 + 1]), ord($blob[$base1 + 2])); $output .= chr(($n0 << 4) | $n1); } return $output; } - private function buildPaletteStrip(): \Imagick + private function buildPaletteStrip(array $palette): \Imagick { $palImagick = new \Imagick(); - foreach (self::PALETTE as $rgb) { + foreach ($palette as $rgb) { $hex = sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]); $tmp = new \Imagick(); $tmp->newImage(1, 1, new \ImagickPixel($hex)); @@ -224,11 +227,11 @@ final class RenderCompareCommand extends Command return $strip; } - private function nearestPalette(int $r, int $g, int $b): int + private function nearestPalette(array $palette, int $r, int $g, int $b): int { $best = 0x1; $bestDist = PHP_INT_MAX; - foreach (self::PALETTE as $index => $rgb) { + foreach ($palette as $index => $rgb) { $dist = ($r - $rgb[0]) ** 2 + ($g - $rgb[1]) ** 2 + ($b - $rgb[2]) ** 2; if ($dist < $bestDist) { $bestDist = $dist; diff --git a/src/MessageHandler/RenderImageMessageHandler.php b/src/MessageHandler/RenderImageMessageHandler.php index fe63a47..d112fd3 100644 --- a/src/MessageHandler/RenderImageMessageHandler.php +++ b/src/MessageHandler/RenderImageMessageHandler.php @@ -18,13 +18,23 @@ use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler] final class RenderImageMessageHandler { - // Waveshare Spectra 6 palette — must match gen_screens.py - private const PALETTE = [ + /** + * Waveshare Spectra 6 palette. Values are the dither's *mapping targets* — + * panel still displays each nibble as its actual ink colour regardless of + * what RGB we tell the algorithm. Shifting BLUE further from neutral pulls + * borderline-bluish pixels (sky cast on skin) toward RED/WHITE/YELLOW. + * + * Baseline (frozen at render-baseline-2026-05-14): + * 0x5 BLUE = [24, 64, 192] + * Experiment #5: + * 0x5 BLUE = [ 8, 32, 220] — more saturated mapping target. + */ + public const PALETTE = [ 0x0 => [26, 26, 26 ], // BLACK 0x1 => [245, 245, 240], // WHITE 0x2 => [240, 208, 0 ], // YELLOW 0x3 => [192, 48, 32 ], // RED - 0x5 => [24, 64, 192], // BLUE + 0x5 => [8, 32, 220], // BLUE (shifted in experiment #5) 0x6 => [16, 160, 64 ], // GREEN ]; @@ -50,12 +60,12 @@ final class RenderImageMessageHandler public const BLUR_SIGMA = 0.0; /** 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; + * source's blue channel before dither. 1.0 = no change. Reverted from + * experiment #4 (0.95) which made shadow regions appear MORE blue, not + * less — likely because reducing source blue still left positive blue + * error after a BLACK mapping, which the dither then spent on + * neighbours, creating blue dither dots in dark regions. */ + public const BLUE_CHANNEL_MUL = 1.0; /** Imagick dither method. Sticking with Floyd-Steinberg — Riemersma * produced visible Hilbert-curve "ink-spill" streaks in skin/sky. */