Files
pictureFrame-webApp/src/MessageHandler/RenderImageMessageHandler.php
T
football2801 4586079fae
CI / test (push) Has been cancelled
fix: flip portrait rotation direction so the EPD shows the photo upright
The first rotation pass picked CW server-side / CCW preview-side based on
"ribbon on left" → user rotates frame 90° CCW. On hardware the photo came
out upside down, which means the user's physical rotation is the opposite
of what was assumed: 90° CW from landscape native, putting the ribbon to
the left from the user's POV but to the right from the EPD's reference
frame. The two rotation signs always need to stay opposite — flipping
both keeps the webapp preview upright while fixing the device.

Also drops the temporary upload debug log; the cropOrientation persistence
issue resolved on its own once Doctrine's metadata cache was cleared.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:10:25 -04:00

174 lines
6.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Message\RenderImageMessage;
use App\Repository\ImageRepository;
use App\Repository\RenderedAssetRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class RenderImageMessageHandler
{
// Waveshare Spectra 6 palette — must match gen_screens.py
private 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
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;
}
}