experiment(render): revert blue_mul; shift BLUE palette target to (8,32,220)
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
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) <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,8 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|||||||
)]
|
)]
|
||||||
final class RenderCompareCommand extends Command
|
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 ],
|
0x0 => [26, 26, 26 ],
|
||||||
0x1 => [245, 245, 240],
|
0x1 => [245, 245, 240],
|
||||||
0x2 => [240, 208, 0 ],
|
0x2 => [240, 208, 0 ],
|
||||||
@@ -52,13 +53,14 @@ final class RenderCompareCommand extends Command
|
|||||||
private const HALF_H = 800;
|
private const HALF_H = 800;
|
||||||
private const FULL_H = 1600;
|
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 = [
|
private const BASELINE = [
|
||||||
'saturation' => 130,
|
'saturation' => 130,
|
||||||
'gamma' => 1.0,
|
'gamma' => 1.0,
|
||||||
'sharpen' => 0.8,
|
'sharpen' => 0.8,
|
||||||
'blur' => 0.0,
|
'blur' => 0.0,
|
||||||
'blue_mul' => 1.0,
|
'blue_mul' => 1.0,
|
||||||
|
'palette' => self::BASELINE_PALETTE,
|
||||||
'dither' => \Imagick::DITHERMETHOD_FLOYDSTEINBERG,
|
'dither' => \Imagick::DITHERMETHOD_FLOYDSTEINBERG,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -96,6 +98,7 @@ final class RenderCompareCommand extends Command
|
|||||||
'sharpen' => RenderImageMessageHandler::SHARPEN_SIGMA,
|
'sharpen' => RenderImageMessageHandler::SHARPEN_SIGMA,
|
||||||
'blur' => RenderImageMessageHandler::BLUR_SIGMA,
|
'blur' => RenderImageMessageHandler::BLUR_SIGMA,
|
||||||
'blue_mul' => RenderImageMessageHandler::BLUE_CHANNEL_MUL,
|
'blue_mul' => RenderImageMessageHandler::BLUE_CHANNEL_MUL,
|
||||||
|
'palette' => RenderImageMessageHandler::PALETTE,
|
||||||
'dither' => RenderImageMessageHandler::DITHER_METHOD,
|
'dither' => RenderImageMessageHandler::DITHER_METHOD,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -186,7 +189,7 @@ final class RenderCompareCommand extends Command
|
|||||||
$im->cropImage(self::W, self::HALF_H, 0, 0);
|
$im->cropImage(self::W, self::HALF_H, 0, 0);
|
||||||
$im->setImagePage(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']);
|
$im->remapImage($palStrip, $params['dither']);
|
||||||
$palStrip->destroy();
|
$palStrip->destroy();
|
||||||
|
|
||||||
@@ -200,17 +203,17 @@ final class RenderCompareCommand extends Command
|
|||||||
for ($i = 0; $i < $total; $i += 2) {
|
for ($i = 0; $i < $total; $i += 2) {
|
||||||
$base0 = $i * 3;
|
$base0 = $i * 3;
|
||||||
$base1 = $base0 + 3;
|
$base1 = $base0 + 3;
|
||||||
$n0 = $this->nearestPalette(ord($blob[$base0]), ord($blob[$base0 + 1]), ord($blob[$base0 + 2]));
|
$n0 = $this->nearestPalette($params['palette'], 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]));
|
$n1 = $this->nearestPalette($params['palette'], ord($blob[$base1]), ord($blob[$base1 + 1]), ord($blob[$base1 + 2]));
|
||||||
$output .= chr(($n0 << 4) | $n1);
|
$output .= chr(($n0 << 4) | $n1);
|
||||||
}
|
}
|
||||||
return $output;
|
return $output;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildPaletteStrip(): \Imagick
|
private function buildPaletteStrip(array $palette): \Imagick
|
||||||
{
|
{
|
||||||
$palImagick = new \Imagick();
|
$palImagick = new \Imagick();
|
||||||
foreach (self::PALETTE as $rgb) {
|
foreach ($palette as $rgb) {
|
||||||
$hex = sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]);
|
$hex = sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]);
|
||||||
$tmp = new \Imagick();
|
$tmp = new \Imagick();
|
||||||
$tmp->newImage(1, 1, new \ImagickPixel($hex));
|
$tmp->newImage(1, 1, new \ImagickPixel($hex));
|
||||||
@@ -224,11 +227,11 @@ final class RenderCompareCommand extends Command
|
|||||||
return $strip;
|
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;
|
$best = 0x1;
|
||||||
$bestDist = PHP_INT_MAX;
|
$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;
|
$dist = ($r - $rgb[0]) ** 2 + ($g - $rgb[1]) ** 2 + ($b - $rgb[2]) ** 2;
|
||||||
if ($dist < $bestDist) {
|
if ($dist < $bestDist) {
|
||||||
$bestDist = $dist;
|
$bestDist = $dist;
|
||||||
|
|||||||
@@ -18,13 +18,23 @@ use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
|||||||
#[AsMessageHandler]
|
#[AsMessageHandler]
|
||||||
final class RenderImageMessageHandler
|
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
|
0x0 => [26, 26, 26 ], // BLACK
|
||||||
0x1 => [245, 245, 240], // WHITE
|
0x1 => [245, 245, 240], // WHITE
|
||||||
0x2 => [240, 208, 0 ], // YELLOW
|
0x2 => [240, 208, 0 ], // YELLOW
|
||||||
0x3 => [192, 48, 32 ], // RED
|
0x3 => [192, 48, 32 ], // RED
|
||||||
0x5 => [24, 64, 192], // BLUE
|
0x5 => [8, 32, 220], // BLUE (shifted in experiment #5)
|
||||||
0x6 => [16, 160, 64 ], // GREEN
|
0x6 => [16, 160, 64 ], // GREEN
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -50,12 +60,12 @@ final class RenderImageMessageHandler
|
|||||||
public const BLUR_SIGMA = 0.0;
|
public const BLUR_SIGMA = 0.0;
|
||||||
|
|
||||||
/** evaluateImage(MULTIPLY, BLUE_CHANNEL_MUL, CHANNEL_BLUE) — scales the
|
/** evaluateImage(MULTIPLY, BLUE_CHANNEL_MUL, CHANNEL_BLUE) — scales the
|
||||||
* source's blue channel before dither. 1.0 = no change.
|
* source's blue channel before dither. 1.0 = no change. Reverted from
|
||||||
* Experiment #4: 0.95 (knock 5% off blue). Real sky stays well above
|
* experiment #4 (0.95) which made shadow regions appear MORE blue, not
|
||||||
* the BLUE-vs-WHITE boundary, but borderline-bluish skin pixels (sky
|
* less — likely because reducing source blue still left positive blue
|
||||||
* cast on faces in outdoor photos) drop below it and map to YELLOW /
|
* error after a BLACK mapping, which the dither then spent on
|
||||||
* WHITE / RED instead of contaminating the face with BLUE dither. */
|
* neighbours, creating blue dither dots in dark regions. */
|
||||||
public const BLUE_CHANNEL_MUL = 0.95;
|
public const BLUE_CHANNEL_MUL = 1.0;
|
||||||
|
|
||||||
/** Imagick dither method. Sticking with Floyd-Steinberg — Riemersma
|
/** Imagick dither method. Sticking with Floyd-Steinberg — Riemersma
|
||||||
* produced visible Hilbert-curve "ink-spill" streaks in skin/sky. */
|
* produced visible Hilbert-curve "ink-spill" streaks in skin/sky. */
|
||||||
|
|||||||
Reference in New Issue
Block a user