experiment(render): revert FS + add pre-dither blur 0.6; A/B vs frozen baseline
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Riemersma dither produced visible "ink-spill" Hilbert-curve streaks in low-contrast regions like skin. Reverting DITHER_METHOD to Floyd- Steinberg and attacking the original sky-bleeds-into-face problem with a pre-dither Gaussian blur instead: DITHER_METHOD RIEMERSMA → FLOYDSTEINBERG (back to baseline) + new BLUR_SIGMA = 0.6 (pre-dither softening; baseline 0.0) Live tunables are now public so RenderCompareCommand can mirror them. Half-A of app:render-compare is locked to the frozen-baseline set (sat=130 gamma=1.0 sharpen=0.8 blur=0.0 FS) — what shipped at git tag render-baseline-2026-05-14. Half-B always tracks the live pipeline, so each future experiment is automatically the next A/B without touching the comparison command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -18,41 +19,47 @@ 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.
|
||||
* Side-by-side render comparison on the panel.
|
||||
*
|
||||
* bin/console app:render-compare 42
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* 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 an image with two dither methods stacked; replace its V2 portrait asset',
|
||||
description: 'Render half-A (baseline) + half-B (current experiment) stacked on the panel',
|
||||
)]
|
||||
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
|
||||
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;
|
||||
private const SATURATION = 115;
|
||||
private const GAMMA = 1.2;
|
||||
private const SHARPEN_SIG = 0.8;
|
||||
private const W = 1200;
|
||||
private const HALF_H = 800;
|
||||
private const FULL_H = 1600;
|
||||
|
||||
/** Baseline tunables — must NOT track the live pipeline. Frozen reference. */
|
||||
private const BASELINE = [
|
||||
'saturation' => 130,
|
||||
'gamma' => 1.0,
|
||||
'sharpen' => 0.8,
|
||||
'blur' => 0.0,
|
||||
'dither' => \Imagick::DITHERMETHOD_FLOYDSTEINBERG,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
@@ -73,8 +80,6 @@ final class RenderCompareCommand extends Command
|
||||
$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);
|
||||
@@ -82,27 +87,23 @@ final class RenderCompareCommand extends Command
|
||||
$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);
|
||||
// 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,
|
||||
'dither' => RenderImageMessageHandler::DITHER_METHOD,
|
||||
];
|
||||
|
||||
// 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();
|
||||
$io->writeln('TOP (baseline): ' . $this->summarize(self::BASELINE));
|
||||
$io->writeln('BOTTOM (experiment): ' . $this->summarize($experiment));
|
||||
|
||||
$palStrip = $this->buildPaletteStrip();
|
||||
$topBytes = $this->renderTopHalf($source, self::BASELINE);
|
||||
$bottomBytes = $this->renderTopHalf($source, $experiment);
|
||||
|
||||
$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;
|
||||
$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));
|
||||
@@ -124,20 +125,27 @@ final class RenderCompareCommand extends Command
|
||||
$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,
|
||||
));
|
||||
$io->success(sprintf('Wrote %s. Next device poll will fetch.', $absPath));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function preDitherPipeline(string $source): \Imagick
|
||||
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 dither=%s',
|
||||
$p['saturation'], $p['gamma'], $p['sharpen'], $p['blur'], $dither,
|
||||
);
|
||||
}
|
||||
|
||||
private function renderTopHalf(string $source, array $params): string
|
||||
{
|
||||
$im = new \Imagick($source);
|
||||
$im->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE);
|
||||
@@ -148,9 +156,14 @@ final class RenderCompareCommand extends Command
|
||||
$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 ($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 ($im->getImageWidth() !== self::W || $im->getImageHeight() !== self::FULL_H) {
|
||||
$canvas = new \Imagick();
|
||||
@@ -163,7 +176,30 @@ final class RenderCompareCommand extends Command
|
||||
$im = $canvas;
|
||||
}
|
||||
|
||||
return $im;
|
||||
// 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();
|
||||
$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(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 buildPaletteStrip(): \Imagick
|
||||
@@ -183,26 +219,6 @@ final class RenderCompareCommand extends Command
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user