4586079fae
CI / test (push) Has been cancelled
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>
174 lines
6.4 KiB
PHP
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;
|
|
}
|
|
}
|