From a37edcb6c77cb25c7defedc0130d0058c736f3e3 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Thu, 14 May 2026 13:42:54 -0400 Subject: [PATCH] experiment(render): revert FS + add pre-dither blur 0.6; A/B vs frozen baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/Command/RenderCompareCommand.php | 162 ++++++++++-------- .../RenderImageMessageHandler.php | 50 ++++-- 2 files changed, 122 insertions(+), 90 deletions(-) diff --git a/src/Command/RenderCompareCommand.php b/src/Command/RenderCompareCommand.php index 28c633d..8d235e2 100644 --- a/src/Command/RenderCompareCommand.php +++ b/src/Command/RenderCompareCommand.php @@ -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 + * + * 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; diff --git a/src/MessageHandler/RenderImageMessageHandler.php b/src/MessageHandler/RenderImageMessageHandler.php index 7927c3f..c594345 100644 --- a/src/MessageHandler/RenderImageMessageHandler.php +++ b/src/MessageHandler/RenderImageMessageHandler.php @@ -29,31 +29,38 @@ final class RenderImageMessageHandler ]; // ── Render tunables ────────────────────────────────────────────────────── - // Tweak these to change how photos look on the panel; each value has the - // baseline it shipped with as a comment so we can rollback per-knob. - // git tag render-baseline-2026-05-14 captures the full known-working set. + // Public so app:render-compare can mirror exactly what the live pipeline + // would do. Baseline values (git tag render-baseline-2026-05-14): + // 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. - * Baseline: 130 (+30% saturation, prevents desaturated photos collapsing - * to dither noise). Experiment #1: 115 (+15%, less risk of garish faces). */ - private const SATURATION_PCT = 115; + * Baseline: 130 (+30%, prevents desaturated photos collapsing to dither + * noise). Experiment #1: 115 (less risk of garish faces). */ + public const SATURATION_PCT = 115; /** 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 — - * faces and shadows climb out of the BLACK cluster). */ - private const GAMMA = 1.2; + * Baseline: 1.0 (no gamma pass). Experiment #1: 1.2 (gentle midtone lift). */ + public const GAMMA = 1.2; /** 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. - * Baseline: DITHERMETHOD_FLOYDSTEINBERG — error diffuses down-right - * in row order, so a blue sky's residual error - * accumulates downward and "flushes" into faces/hair - * below, contaminating skin tones with blue. - * Experiment #2: DITHERMETHOD_RIEMERSMA — Hilbert-curve scan, error - * stays local and doesn't bias along any axis. */ - private const DITHER_METHOD = \Imagick::DITHERMETHOD_RIEMERSMA; + * FLOYDSTEINBERG (baseline) — error diffuses down-right in row order, + * risks blue bleed into faces below sky. + * RIEMERSMA — Hilbert-curve scan, error stays local but produces + * visible "ink-spill" streaks in low-contrast regions. + * Rejected experiment #2 — much worse than the bleed. + * Sticking with Floyd-Steinberg, attacking the bleed via blur. */ + public const DITHER_METHOD = \Imagick::DITHERMETHOD_FLOYDSTEINBERG; public function __construct( private readonly ImageRepository $imageRepo, @@ -159,6 +166,15 @@ final class RenderImageMessageHandler // Light sharpen so edges survive the dithering scatter. $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 // aspect mismatch shows up as letterbox bars, not as a sliced crop. if ($imagick->getImageWidth() !== $width || $imagick->getImageHeight() !== $height) {