diff --git a/src/Command/RenderCompareCommand.php b/src/Command/RenderCompareCommand.php new file mode 100644 index 0000000..28c633d --- /dev/null +++ b/src/Command/RenderCompareCommand.php @@ -0,0 +1,219 @@ + [26, 26, 26 ], // BLACK + 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 + ]; + + private const W = 1200; + private const HALF_H = 800; + private const FULL_H = 1600; + private const SATURATION = 115; + private const GAMMA = 1.2; + private const SHARPEN_SIG = 0.8; + + 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'); + + // Prefer composited.jpg over the original — it's already cropped to + // the user's frame aspect. + $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; + } + $io->writeln("Source: $source"); + + // Pre-dither pipeline runs once; we clone the result for each dither. + $base = $this->preDitherPipeline($source); + + // Top half of the fitted/processed photo. Same crop feeds both + // dithers so the only variable is the dither method itself. + $topHalf = clone $base; + $topHalf->cropImage(self::W, self::HALF_H, 0, 0); + $topHalf->setImagePage(self::W, self::HALF_H, 0, 0); + $base->destroy(); + + $palStrip = $this->buildPaletteStrip(); + + $fsBytes = $this->ditherAndPack(clone $topHalf, $palStrip, \Imagick::DITHERMETHOD_FLOYDSTEINBERG); + $riemersmaBytes = $this->ditherAndPack(clone $topHalf, $palStrip, \Imagick::DITHERMETHOD_RIEMERSMA); + + $topHalf->destroy(); + $palStrip->destroy(); + + $combined = $fsBytes . $riemersmaBytes; + $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(); + + // Bust the preview PNG cache so the web UI matches the panel. + $pngPath = preg_replace('/\.bin$/', '.png', $absPath); + if (file_exists($pngPath)) { + @unlink($pngPath); + } + + $io->success(sprintf( + 'Wrote %s — TOP: Floyd-Steinberg, BOTTOM: Riemersma. Asset rendered_at bumped; next device poll will fetch.', + $absPath, + )); + return Command::SUCCESS; + } + + private function preDitherPipeline(string $source): \Imagick + { + $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(); + $im->gammaImage(self::GAMMA); + $im->modulateImage(100, self::SATURATION, 100); + $im->sharpenImage(0, self::SHARPEN_SIG); + + 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; + } + + return $im; + } + + private function buildPaletteStrip(): \Imagick + { + $palImagick = new \Imagick(); + foreach (self::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 ditherAndPack(\Imagick $im, \Imagick $palStrip, int $ditherMethod): string + { + $im->remapImage($palStrip, $ditherMethod); + $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(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])); + $output .= chr(($n0 << 4) | $n1); + } + return $output; + } + + private function nearestPalette(int $r, int $g, int $b): int + { + $best = 0x1; + $bestDist = PHP_INT_MAX; + foreach (self::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; + } +}