tool(render): app:render-compare for FS vs Riemersma A/B on the panel
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Stacks two dither treatments of the same image's top half into one V2 portrait .bin — top half Floyd-Steinberg, bottom half Riemersma — overwrites the device's current V2 portrait asset, bumps rendered_at and clears the preview PNG cache. Usage: bin/console app:render-compare <imageId> Lets Matt eyeball both methods on a single panel refresh instead of two re-renders and two waits. One-shot experimental tool; not part of the live render pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,219 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\RenderedAsset;
|
||||||
|
use App\Enum\DeviceModel;
|
||||||
|
use App\Enum\Orientation;
|
||||||
|
use App\Repository\RenderedAssetRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot A/B dither comparison: render an image's top half through two
|
||||||
|
* different dither methods and stack the results vertically into the V2
|
||||||
|
* portrait asset for a device. The frame then shows both treatments at
|
||||||
|
* once on a single refresh — top half = Floyd-Steinberg, bottom half =
|
||||||
|
* Riemersma.
|
||||||
|
*
|
||||||
|
* bin/console app:render-compare 42
|
||||||
|
*
|
||||||
|
* Re-runs idempotently. Bumps the asset's rendered_at so the next device
|
||||||
|
* poll fetches the new bytes; also wipes the cached preview PNG so the
|
||||||
|
* web UI mirrors what the panel shows.
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:render-compare',
|
||||||
|
description: 'Render an image with two dither methods stacked; replace its V2 portrait asset',
|
||||||
|
)]
|
||||||
|
final class RenderCompareCommand extends Command
|
||||||
|
{
|
||||||
|
// Mirrors RenderImageMessageHandler::PALETTE. Kept inline so the
|
||||||
|
// experiment can iterate independently of the main pipeline.
|
||||||
|
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
|
||||||
|
];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user