Files
pictureFrame-webApp/src/Command/RenderCompareCommand.php
T
football2801 324f1b2641
CI / test (push) Has been cancelled
experiment(render): revert blue_mul; shift BLUE palette target to (8,32,220)
Experiment #4 (blue×0.95) made shadow regions appear MORE blue, not
less — reducing source blue still leaves positive blue error after a
BLACK mapping, and the dither spends that error on neighbours,
creating blue dither dots in dark regions.

Reverting blue_mul to 1.0. Experiment #5 takes a different attack on
the same problem: shift the BLUE palette mapping target from the
muted (24, 64, 192) to a more saturated (8, 32, 220). Doesn't change
what the panel displays (the blue ink is fixed); it just makes
Euclidean distance from skin tones to "BLUE" larger in the algorithm's
view, so the dither prefers RED/WHITE/YELLOW for borderline pixels.

Render-compare's BASELINE struct now carries its own frozen palette,
so half-A keeps the original (24,64,192) BLUE target while half-B
pulls the shifted palette from the live pipeline.

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

244 lines
9.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\MessageHandler\RenderImageMessageHandler;
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;
/**
* Side-by-side render comparison on the panel.
*
* TOP HALF — baseline tunables (sat 130, gamma 1.0, sharpen 0.8,
* no blur, Floyd-Steinberg). The known-good reference,
* frozen at git tag render-baseline-2026-05-14.
* BOTTOM HALF — whatever's currently in RenderImageMessageHandler's
* public constants. Iterating the live pipeline
* automatically updates what shows as half-B.
*
* bin/console app:render-compare <imageId>
*
* Overwrites the device's V2 portrait .bin, bumps rendered_at and busts
* the cached preview PNG.
*/
#[AsCommand(
name: 'app:render-compare',
description: 'Render half-A (baseline) + half-B (current experiment) stacked on the panel',
)]
final class RenderCompareCommand extends Command
{
/** Frozen baseline palette — render-baseline-2026-05-14. NEVER touch. */
private const BASELINE_PALETTE = [
0x0 => [26, 26, 26 ],
0x1 => [245, 245, 240],
0x2 => [240, 208, 0 ],
0x3 => [192, 48, 32 ],
0x5 => [24, 64, 192],
0x6 => [16, 160, 64 ],
];
private const W = 1200;
private const HALF_H = 800;
private const FULL_H = 1600;
/** Baseline tunables — frozen reference, never tracks the live pipeline. */
private const BASELINE = [
'saturation' => 130,
'gamma' => 1.0,
'sharpen' => 0.8,
'blur' => 0.0,
'blue_mul' => 1.0,
'palette' => self::BASELINE_PALETTE,
'dither' => \Imagick::DITHERMETHOD_FLOYDSTEINBERG,
];
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');
$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;
}
// Pull the live pipeline's current settings for half-B.
$experiment = [
'saturation' => RenderImageMessageHandler::SATURATION_PCT,
'gamma' => RenderImageMessageHandler::GAMMA,
'sharpen' => RenderImageMessageHandler::SHARPEN_SIGMA,
'blur' => RenderImageMessageHandler::BLUR_SIGMA,
'blue_mul' => RenderImageMessageHandler::BLUE_CHANNEL_MUL,
'palette' => RenderImageMessageHandler::PALETTE,
'dither' => RenderImageMessageHandler::DITHER_METHOD,
];
$io->writeln('TOP (baseline): ' . $this->summarize(self::BASELINE));
$io->writeln('BOTTOM (experiment): ' . $this->summarize($experiment));
$topBytes = $this->renderTopHalf($source, self::BASELINE);
$bottomBytes = $this->renderTopHalf($source, $experiment);
$combined = $topBytes . $bottomBytes;
$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();
$pngPath = preg_replace('/\.bin$/', '.png', $absPath);
if (file_exists($pngPath)) {
@unlink($pngPath);
}
$io->success(sprintf('Wrote %s. Next device poll will fetch.', $absPath));
return Command::SUCCESS;
}
private function summarize(array $p): string
{
$dither = $p['dither'] === \Imagick::DITHERMETHOD_FLOYDSTEINBERG
? 'FloydSteinberg'
: ($p['dither'] === \Imagick::DITHERMETHOD_RIEMERSMA ? 'Riemersma' : 'No');
return sprintf(
'sat=%d gamma=%.2f sharpen=%.2f blur=%.2f blue_mul=%.2f dither=%s',
$p['saturation'], $p['gamma'], $p['sharpen'], $p['blur'], $p['blue_mul'], $dither,
);
}
private function renderTopHalf(string $source, array $params): string
{
$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();
if ($params['gamma'] !== 1.0) {
$im->gammaImage($params['gamma']);
}
$im->modulateImage(100, $params['saturation'], 100);
$im->sharpenImage(0, $params['sharpen']);
if ($params['blur'] > 0) {
$im->blurImage(0, $params['blur']);
}
if ($params['blue_mul'] !== 1.0) {
$im->evaluateImage(\Imagick::EVALUATE_MULTIPLY, $params['blue_mul'], \Imagick::CHANNEL_BLUE);
}
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;
}
// Crop top half so both halves show the same content with the only
// variable being the tunables.
$im->cropImage(self::W, self::HALF_H, 0, 0);
$im->setImagePage(self::W, self::HALF_H, 0, 0);
$palStrip = $this->buildPaletteStrip($params['palette']);
$im->remapImage($palStrip, $params['dither']);
$palStrip->destroy();
$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($params['palette'], ord($blob[$base0]), ord($blob[$base0 + 1]), ord($blob[$base0 + 2]));
$n1 = $this->nearestPalette($params['palette'], ord($blob[$base1]), ord($blob[$base1 + 1]), ord($blob[$base1 + 2]));
$output .= chr(($n0 << 4) | $n1);
}
return $output;
}
private function buildPaletteStrip(array $palette): \Imagick
{
$palImagick = new \Imagick();
foreach ($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 nearestPalette(array $palette, int $r, int $g, int $b): int
{
$best = 0x1;
$bestDist = PHP_INT_MAX;
foreach ($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;
}
}