diff --git a/src/Command/RenderCompareCommand.php b/src/Command/RenderCompareCommand.php deleted file mode 100644 index 96aa37e..0000000 --- a/src/Command/RenderCompareCommand.php +++ /dev/null @@ -1,243 +0,0 @@ - - * - * Overwrites the device's V2 portrait .bin, bumps rendered_at and busts - * the cached preview PNG. - */ -#[AsCommand( - name: 'app:render-compare', - description: 'Render half-A (baseline) + half-B (current experiment) stacked on the panel', -)] -final class RenderCompareCommand extends Command -{ - /** 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 ], - 0x3 => [192, 48, 32 ], - 0x5 => [24, 64, 192], - 0x6 => [16, 160, 64 ], - ]; - - private const W = 1200; - private const HALF_H = 800; - private const FULL_H = 1600; - - /** 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, - ]; - - public function __construct( - private readonly EntityManagerInterface $em, - private readonly RenderedAssetRepository $assetRepo, - #[Autowire('%kernel.project_dir%')] - private readonly string $projectDir, - ) { - parent::__construct(); - } - - protected function configure(): void - { - $this->addArgument('imageId', InputArgument::REQUIRED, 'Image to render'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - $imageId = (int) $input->getArgument('imageId'); - - $compositedPath = $this->projectDir . '/var/storage/images/' . $imageId . '/composited.jpg'; - $originalGlob = glob($this->projectDir . '/var/storage/images/' . $imageId . '/original.*'); - $source = file_exists($compositedPath) ? $compositedPath : ($originalGlob[0] ?? null); - if (!$source) { - $io->error("No source file for image $imageId"); - return Command::FAILURE; - } - - // Pull the live pipeline's current settings for half-B. - $experiment = [ - 'saturation' => RenderImageMessageHandler::SATURATION_PCT, - 'gamma' => RenderImageMessageHandler::GAMMA, - 'sharpen' => RenderImageMessageHandler::SHARPEN_SIGMA, - 'blur' => RenderImageMessageHandler::BLUR_SIGMA, - 'blue_mul' => RenderImageMessageHandler::BLUE_CHANNEL_MUL, - 'palette' => RenderImageMessageHandler::PALETTE, - 'dither' => RenderImageMessageHandler::DITHER_METHOD, - ]; - - $io->writeln('TOP (baseline): ' . $this->summarize(self::BASELINE)); - $io->writeln('BOTTOM (experiment): ' . $this->summarize($experiment)); - - $topBytes = $this->renderTopHalf($source, self::BASELINE); - $bottomBytes = $this->renderTopHalf($source, $experiment); - - $combined = $topBytes . $bottomBytes; - $expected = (int) (self::W * self::FULL_H / 2); - if (strlen($combined) !== $expected) { - $io->error(sprintf('Size mismatch: got %d bytes, expected %d', strlen($combined), $expected)); - return Command::FAILURE; - } - - $asset = $this->assetRepo->findOneBy([ - 'image' => $imageId, - 'deviceModel' => DeviceModel::V2, - 'orientation' => Orientation::Portrait, - ]); - if (!$asset?->getFilePath()) { - $io->error("No V2 portrait asset for image $imageId — render normally first."); - return Command::FAILURE; - } - - $absPath = $this->projectDir . '/' . $asset->getFilePath(); - file_put_contents($absPath, $combined); - $asset->setRenderedAt(new \DateTimeImmutable()); - $this->em->flush(); - - $pngPath = preg_replace('/\.bin$/', '.png', $absPath); - if (file_exists($pngPath)) { - @unlink($pngPath); - } - - $io->success(sprintf('Wrote %s. Next device poll will fetch.', $absPath)); - return Command::SUCCESS; - } - - private function summarize(array $p): string - { - $dither = $p['dither'] === \Imagick::DITHERMETHOD_FLOYDSTEINBERG - ? 'FloydSteinberg' - : ($p['dither'] === \Imagick::DITHERMETHOD_RIEMERSMA ? 'Riemersma' : 'No'); - return sprintf( - '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, - ); - } - - private function renderTopHalf(string $source, array $params): string - { - $im = new \Imagick($source); - $im->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE); - $im->setBackgroundColor('white'); - $im->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN); - $im->autoOrient(); - - $im->thumbnailImage(self::W, self::FULL_H, true); - $im->setImageColorspace(\Imagick::COLORSPACE_SRGB); - $im->normalizeImage(); - if ($params['gamma'] !== 1.0) { - $im->gammaImage($params['gamma']); - } - $im->modulateImage(100, $params['saturation'], 100); - $im->sharpenImage(0, $params['sharpen']); - 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(); - $canvas->newImage(self::W, self::FULL_H, new \ImagickPixel('white')); - $canvas->setImageFormat('png'); - $offsetX = (int) ((self::W - $im->getImageWidth()) / 2); - $offsetY = (int) ((self::FULL_H - $im->getImageHeight()) / 2); - $canvas->compositeImage($im, \Imagick::COMPOSITE_OVER, $offsetX, $offsetY); - $im->destroy(); - $im = $canvas; - } - - // Crop top half so both halves show the same content with the only - // variable being the tunables. - $im->cropImage(self::W, self::HALF_H, 0, 0); - $im->setImagePage(self::W, self::HALF_H, 0, 0); - - $palStrip = $this->buildPaletteStrip($params['palette']); - $im->remapImage($palStrip, $params['dither']); - $palStrip->destroy(); - - $im->setImageDepth(8); - $im->setFormat('RGB'); - $blob = $im->getImageBlob(); - $im->destroy(); - - $output = ''; - $total = self::W * self::HALF_H; - for ($i = 0; $i < $total; $i += 2) { - $base0 = $i * 3; - $base1 = $base0 + 3; - $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(array $palette): \Imagick - { - $palImagick = new \Imagick(); - 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)); - $tmp->setImageFormat('png'); - $palImagick->addImage($tmp); - $tmp->destroy(); - } - $palImagick->resetIterator(); - $strip = $palImagick->appendImages(false); - $palImagick->destroy(); - return $strip; - } - - private function nearestPalette(array $palette, int $r, int $g, int $b): int - { - $best = 0x1; - $bestDist = PHP_INT_MAX; - foreach ($palette as $index => $rgb) { - $dist = ($r - $rgb[0]) ** 2 + ($g - $rgb[1]) ** 2 + ($b - $rgb[2]) ** 2; - if ($dist < $bestDist) { - $bestDist = $dist; - $best = $index; - } - } - return $best; - } -} diff --git a/src/MessageHandler/RenderImageMessageHandler.php b/src/MessageHandler/RenderImageMessageHandler.php index 00acb33..f95a96c 100644 --- a/src/MessageHandler/RenderImageMessageHandler.php +++ b/src/MessageHandler/RenderImageMessageHandler.php @@ -18,61 +18,16 @@ use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler] final class RenderImageMessageHandler { - /** - * 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. - * - * Baseline (frozen at render-baseline-2026-05-14): - * 0x1 WHITE = [245, 245, 240] - * 0x5 BLUE = [24, 64, 192] - * Experiment #5 (BLUE → [8, 32, 220]) — made sky greenish, reverted. - * Experiment #6 (WHITE → [255, 248, 230]) — warmer cream pulls warm - * skin pixels toward WHITE before error diffusion can drift them - * toward BLUE, without changing how sky maps. - */ - public const PALETTE = [ + // Waveshare Spectra 6 palette — must match gen_screens.py + private const PALETTE = [ 0x0 => [26, 26, 26 ], // BLACK - 0x1 => [255, 248, 230], // WHITE (warmed in experiment #6) + 0x1 => [245, 245, 240], // WHITE 0x2 => [240, 208, 0 ], // YELLOW 0x3 => [192, 48, 32 ], // RED 0x5 => [24, 64, 192], // BLUE 0x6 => [16, 160, 64 ], // GREEN ]; - // ── Render tunables ────────────────────────────────────────────────────── - // Public so app:render-compare can mirror exactly what the live pipeline - // would do. Baseline values (git tag render-baseline-2026-05-14): - // SATURATION_PCT 130, GAMMA 1.0, SHARPEN 0.8, BLUR 0.0, DITHER FS. - // app:render-compare hardcodes those into its top-half so the panel - // always shows baseline-vs-current side-by-side. - - /** modulateImage(brightness=100, SATURATION, hue=100). 100 = no change. - * Baseline: 130 (+30%, prevents desaturated photos collapsing to dither - * noise; greens stay vibrant). */ - public const SATURATION_PCT = 130; - - /** 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. */ - 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. 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. */ - public const DITHER_METHOD = \Imagick::DITHERMETHOD_FLOYDSTEINBERG; - public function __construct( private readonly ImageRepository $imageRepo, private readonly RenderedAssetRepository $assetRepo, @@ -161,35 +116,12 @@ final class RenderImageMessageHandler // histogram percentiles and produces gentle, correct stretching. $imagick->normalizeImage(); - // Midtone lift before the saturation + dither passes. Spectra-6's - // tonal range is squeezed (effectively ~3 bits per channel after - // dither); without a gamma push, faces and shadow detail get - // crushed into BLACK and the photo reads as too dark on the panel. - if (self::GAMMA !== 1.0) { - $imagick->gammaImage(self::GAMMA); - } - - // Saturation boost. Dark desaturated photos otherwise map almost - // entirely to BLACK with scattered noise dots from error diffusion; - // too much saturation and skin tones go ruddy / sky goes synthetic. - $imagick->modulateImage(100, self::SATURATION_PCT, 100); + // Boost saturation 130%. Dark desaturated photos otherwise map almost + // entirely to BLACK with scattered noise dots from error diffusion. + $imagick->modulateImage(100, 130, 100); // Light sharpen so edges survive the dithering scatter. - $imagick->sharpenImage(0, self::SHARPEN_SIGMA); - - // 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); - } + $imagick->sharpenImage(0, 0.8); // 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. @@ -229,7 +161,7 @@ final class RenderImageMessageHandler $palImagick->resetIterator(); $palStrip = $palImagick->appendImages(false); - $imagick->remapImage($palStrip, self::DITHER_METHOD); + $imagick->remapImage($palStrip, \Imagick::DITHERMETHOD_FLOYDSTEINBERG); $palStrip->destroy(); $palImagick->destroy();