experiment(render): revert FS + add pre-dither blur 0.6; A/B vs frozen baseline
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:
2026-05-14 13:42:54 -04:00
parent 2f3527aaf9
commit a37edcb6c7
2 changed files with 122 additions and 90 deletions
+89 -73
View File
@@ -7,6 +7,7 @@ namespace App\Command;
use App\Entity\RenderedAsset; use App\Entity\RenderedAsset;
use App\Enum\DeviceModel; use App\Enum\DeviceModel;
use App\Enum\Orientation; use App\Enum\Orientation;
use App\MessageHandler\RenderImageMessageHandler;
use App\Repository\RenderedAssetRepository; use App\Repository\RenderedAssetRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
@@ -18,41 +19,47 @@ use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
/** /**
* One-shot A/B dither comparison: render an image's top half through two * Side-by-side render comparison on the panel.
* 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 * 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 * bin/console app:render-compare <imageId>
* poll fetches the new bytes; also wipes the cached preview PNG so the *
* web UI mirrors what the panel shows. * Overwrites the device's V2 portrait .bin, bumps rendered_at and busts
* the cached preview PNG.
*/ */
#[AsCommand( #[AsCommand(
name: 'app:render-compare', 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 final class RenderCompareCommand extends Command
{ {
// Mirrors RenderImageMessageHandler::PALETTE. Kept inline so the
// experiment can iterate independently of the main pipeline.
private const PALETTE = [ private const PALETTE = [
0x0 => [26, 26, 26 ], // BLACK 0x0 => [26, 26, 26 ],
0x1 => [245, 245, 240], // WHITE 0x1 => [245, 245, 240],
0x2 => [240, 208, 0 ], // YELLOW 0x2 => [240, 208, 0 ],
0x3 => [192, 48, 32 ], // RED 0x3 => [192, 48, 32 ],
0x5 => [24, 64, 192], // BLUE 0x5 => [24, 64, 192],
0x6 => [16, 160, 64 ], // GREEN 0x6 => [16, 160, 64 ],
]; ];
private const W = 1200; private const W = 1200;
private const HALF_H = 800; private const HALF_H = 800;
private const FULL_H = 1600; private const FULL_H = 1600;
private const SATURATION = 115;
private const GAMMA = 1.2; /** Baseline tunables — must NOT track the live pipeline. Frozen reference. */
private const SHARPEN_SIG = 0.8; private const BASELINE = [
'saturation' => 130,
'gamma' => 1.0,
'sharpen' => 0.8,
'blur' => 0.0,
'dither' => \Imagick::DITHERMETHOD_FLOYDSTEINBERG,
];
public function __construct( public function __construct(
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
@@ -73,8 +80,6 @@ final class RenderCompareCommand extends Command
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$imageId = (int) $input->getArgument('imageId'); $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'; $compositedPath = $this->projectDir . '/var/storage/images/' . $imageId . '/composited.jpg';
$originalGlob = glob($this->projectDir . '/var/storage/images/' . $imageId . '/original.*'); $originalGlob = glob($this->projectDir . '/var/storage/images/' . $imageId . '/original.*');
$source = file_exists($compositedPath) ? $compositedPath : ($originalGlob[0] ?? null); $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"); $io->error("No source file for image $imageId");
return Command::FAILURE; return Command::FAILURE;
} }
$io->writeln("Source: $source");
// Pre-dither pipeline runs once; we clone the result for each dither. // Pull the live pipeline's current settings for half-B.
$base = $this->preDitherPipeline($source); $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 $io->writeln('TOP (baseline): ' . $this->summarize(self::BASELINE));
// dithers so the only variable is the dither method itself. $io->writeln('BOTTOM (experiment): ' . $this->summarize($experiment));
$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(); $topBytes = $this->renderTopHalf($source, self::BASELINE);
$bottomBytes = $this->renderTopHalf($source, $experiment);
$fsBytes = $this->ditherAndPack(clone $topHalf, $palStrip, \Imagick::DITHERMETHOD_FLOYDSTEINBERG); $combined = $topBytes . $bottomBytes;
$riemersmaBytes = $this->ditherAndPack(clone $topHalf, $palStrip, \Imagick::DITHERMETHOD_RIEMERSMA);
$topHalf->destroy();
$palStrip->destroy();
$combined = $fsBytes . $riemersmaBytes;
$expected = (int) (self::W * self::FULL_H / 2); $expected = (int) (self::W * self::FULL_H / 2);
if (strlen($combined) !== $expected) { if (strlen($combined) !== $expected) {
$io->error(sprintf('Size mismatch: got %d bytes, expected %d', 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()); $asset->setRenderedAt(new \DateTimeImmutable());
$this->em->flush(); $this->em->flush();
// Bust the preview PNG cache so the web UI matches the panel.
$pngPath = preg_replace('/\.bin$/', '.png', $absPath); $pngPath = preg_replace('/\.bin$/', '.png', $absPath);
if (file_exists($pngPath)) { if (file_exists($pngPath)) {
@unlink($pngPath); @unlink($pngPath);
} }
$io->success(sprintf( $io->success(sprintf('Wrote %s. Next device poll will fetch.', $absPath));
'Wrote %s — TOP: Floyd-Steinberg, BOTTOM: Riemersma. Asset rendered_at bumped; next device poll will fetch.',
$absPath,
));
return Command::SUCCESS; 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 = new \Imagick($source);
$im->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE); $im->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE);
@@ -148,9 +156,14 @@ final class RenderCompareCommand extends Command
$im->thumbnailImage(self::W, self::FULL_H, true); $im->thumbnailImage(self::W, self::FULL_H, true);
$im->setImageColorspace(\Imagick::COLORSPACE_SRGB); $im->setImageColorspace(\Imagick::COLORSPACE_SRGB);
$im->normalizeImage(); $im->normalizeImage();
$im->gammaImage(self::GAMMA); if ($params['gamma'] !== 1.0) {
$im->modulateImage(100, self::SATURATION, 100); $im->gammaImage($params['gamma']);
$im->sharpenImage(0, self::SHARPEN_SIG); }
$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) { if ($im->getImageWidth() !== self::W || $im->getImageHeight() !== self::FULL_H) {
$canvas = new \Imagick(); $canvas = new \Imagick();
@@ -163,7 +176,30 @@ final class RenderCompareCommand extends Command
$im = $canvas; $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 private function buildPaletteStrip(): \Imagick
@@ -183,26 +219,6 @@ final class RenderCompareCommand extends Command
return $strip; 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 private function nearestPalette(int $r, int $g, int $b): int
{ {
$best = 0x1; $best = 0x1;
@@ -29,31 +29,38 @@ final class RenderImageMessageHandler
]; ];
// ── Render tunables ────────────────────────────────────────────────────── // ── Render tunables ──────────────────────────────────────────────────────
// Tweak these to change how photos look on the panel; each value has the // Public so app:render-compare can mirror exactly what the live pipeline
// baseline it shipped with as a comment so we can rollback per-knob. // would do. Baseline values (git tag render-baseline-2026-05-14):
// git tag render-baseline-2026-05-14 captures the full known-working set. // SATURATION_PCT 130, GAMMA 1.0, SHARPEN 0.8, BLUR 0.0, DITHER FS.
// app:render-compare hardcodes those into its top-half so the panel
// always shows baseline-vs-current side-by-side.
/** modulateImage(brightness=100, SATURATION, hue=100). 100 = no change. /** modulateImage(brightness=100, SATURATION, hue=100). 100 = no change.
* Baseline: 130 (+30% saturation, prevents desaturated photos collapsing * Baseline: 130 (+30%, prevents desaturated photos collapsing to dither
* to dither noise). Experiment #1: 115 (+15%, less risk of garish faces). */ * noise). Experiment #1: 115 (less risk of garish faces). */
private const SATURATION_PCT = 115; public const SATURATION_PCT = 115;
/** gammaImage(GAMMA). 1.0 = no change; >1 lifts midtones, <1 crushes them. /** gammaImage(GAMMA). 1.0 = no change; >1 lifts midtones, <1 crushes them.
* Baseline: 1.0 (no gamma pass). Experiment #1: 1.2 (gentle midtone lift * Baseline: 1.0 (no gamma pass). Experiment #1: 1.2 (gentle midtone lift). */
* faces and shadows climb out of the BLACK cluster). */ public const GAMMA = 1.2;
private const GAMMA = 1.2;
/** sharpenImage(radius=0, SHARPEN_SIGMA). Baseline: 0.8 (light). */ /** sharpenImage(radius=0, SHARPEN_SIGMA). Baseline: 0.8 (light). */
private const SHARPEN_SIGMA = 0.8; public const SHARPEN_SIGMA = 0.8;
/** blurImage(radius=0, BLUR_SIGMA) applied *before* dither. 0 = no blur.
* Baseline: 0.0. Experiment #3: 0.6 — softens sharp blue-sky → skin
* transitions so Floyd-Steinberg has less per-pixel error to carry
* forward, reducing the blue "bleed" into faces below the sky. */
public const BLUR_SIGMA = 0.6;
/** Imagick dither method. /** Imagick dither method.
* Baseline: DITHERMETHOD_FLOYDSTEINBERG — error diffuses down-right * FLOYDSTEINBERG (baseline) — error diffuses down-right in row order,
* in row order, so a blue sky's residual error * risks blue bleed into faces below sky.
* accumulates downward and "flushes" into faces/hair * RIEMERSMA — Hilbert-curve scan, error stays local but produces
* below, contaminating skin tones with blue. * visible "ink-spill" streaks in low-contrast regions.
* Experiment #2: DITHERMETHOD_RIEMERSMA — Hilbert-curve scan, error * Rejected experiment #2 — much worse than the bleed.
* stays local and doesn't bias along any axis. */ * Sticking with Floyd-Steinberg, attacking the bleed via blur. */
private const DITHER_METHOD = \Imagick::DITHERMETHOD_RIEMERSMA; public const DITHER_METHOD = \Imagick::DITHERMETHOD_FLOYDSTEINBERG;
public function __construct( public function __construct(
private readonly ImageRepository $imageRepo, private readonly ImageRepository $imageRepo,
@@ -159,6 +166,15 @@ final class RenderImageMessageHandler
// Light sharpen so edges survive the dithering scatter. // Light sharpen so edges survive the dithering scatter.
$imagick->sharpenImage(0, self::SHARPEN_SIGMA); $imagick->sharpenImage(0, self::SHARPEN_SIGMA);
// Optional pre-dither blur to soften sharp colour transitions —
// Floyd-Steinberg propagates each pixel's error to its neighbours,
// and a hard sky→skin boundary leaks blue into faces below. Even
// a sub-1px Gaussian smooths the boundary enough that the carried
// error is small. Sharpening above keeps edge crispness.
if (self::BLUR_SIGMA > 0) {
$imagick->blurImage(0, self::BLUR_SIGMA);
}
// Now composite onto a white canvas of exact target dims so that any // Now composite onto a white canvas of exact target dims so that any
// aspect mismatch shows up as letterbox bars, not as a sliced crop. // aspect mismatch shows up as letterbox bars, not as a sliced crop.
if ($imagick->getImageWidth() !== $width || $imagick->getImageHeight() !== $height) { if ($imagick->getImageWidth() !== $width || $imagick->getImageHeight() !== $height) {