[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 ]; public function __construct( private readonly ImageRepository $imageRepo, private readonly RenderedAssetRepository $assetRepo, private readonly EntityManagerInterface $em, #[Autowire('%kernel.project_dir%')] private readonly string $projectDir, ) {} public function __invoke(RenderImageMessage $msg): void { $image = $this->imageRepo->find($msg->imageId); if (!$image || $image->isDeleted()) { return; } $model = DeviceModel::from($msg->deviceModel); $orientation = Orientation::from($msg->orientation); $asset = $this->assetRepo->findOneBy([ 'image' => $image, 'deviceModel' => $model, 'orientation' => $orientation, ]); if (!$asset) { $asset = (new RenderedAsset()) ->setImage($image) ->setDeviceModel($model) ->setOrientation($orientation); $this->em->persist($asset); } $asset->setStatus(RenderStatus::Processing); $this->em->flush(); try { // Prefer composited.jpg (cropped+stickered) over the raw original $compositedPath = $this->projectDir . '/var/storage/images/' . $image->getId() . '/composited.jpg'; $originalPath = file_exists($compositedPath) ? $compositedPath : $this->projectDir . '/' . $image->getStoragePath(); $width = $model->width($orientation); $height = $model->height($orientation); $bin = $this->renderToBin($originalPath, $width, $height, $orientation); $relPath = 'var/storage/images/' . $image->getId() . '/' . $model->value . '_' . $orientation->value . '.bin'; $absPath = $this->projectDir . '/' . $relPath; file_put_contents($absPath, $bin); $asset->setFilePath($relPath) ->setStatus(RenderStatus::Ready) ->setRenderedAt(new \DateTimeImmutable()); } catch (\Throwable) { $asset->setStatus(RenderStatus::Failed); } $this->em->flush(); } private function renderToBin(string $path, int $width, int $height, Orientation $orientation): string { $imagick = new \Imagick($path); $imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE); $imagick->setBackgroundColor('white'); $imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN); $imagick->autoOrient(); $imagick->cropThumbnailImage($width, $height); // Portrait: rotate the cropped photo 90° CCW so the packed .bin's row // layout matches the EPD's native 800-pixel scan order. The frame is // physically rotated 90° CW for portrait (ribbon on right from EPD's // POV → on left from user's view), so the photo's top edge maps to the // EPD's left column. Firmware streams bytes raw — no orientation // awareness on-device. if ($orientation === Orientation::Portrait) { $imagick->rotateImage(new \ImagickPixel('white'), -90); } $imagick->setImageColorspace(\Imagick::COLORSPACE_SRGB); // Auto-levels: stretch the tonal range, clipping 1% at each end. // Fixes underexposed/dark photos so the full palette range is used. $pixels = $width * $height; $imagick->contrastStretchImage((int) ($pixels * 0.01), (int) ($pixels * 0.01)); // 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, 0.8); // Build a strip of 6 palette pixels for remapImage $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(); $palStrip = $palImagick->appendImages(false); $imagick->remapImage($palStrip, \Imagick::DITHERMETHOD_FLOYDSTEINBERG); $palStrip->destroy(); $palImagick->destroy(); // Export as raw RGB bytes $imagick->setImageDepth(8); $imagick->setFormat('RGB'); $blob = $imagick->getImageBlob(); $imagick->destroy(); // Pack into 4bpp: high nibble = left pixel, low nibble = right pixel $output = ''; $total = $width * $height; 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; } }