revert(render): drop A/B tuning experiments; back to baseline pipeline
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Tried six experiments tuning saturation, gamma, blur, blue-channel multiply, BLUE palette target, and WHITE palette target — each had side effects worse than the original sky→face blue bleed we were trying to fix (greens lost vibrance, sky went green, shadows got more blue, etc). The baseline pipeline is the local maximum for now; the 6-colour Spectra-6 palette is the real ceiling. Drops RenderCompareCommand and restores RenderImageMessageHandler verbatim from git tag render-baseline-2026-05-14. Future render-quality work should attack the problem differently — Lab-space color matching, semantic preprocessing, or a measured panel-ink palette — not single-knob RGB tweaks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,243 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -18,61 +18,16 @@ use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
#[AsMessageHandler]
|
||||
final class RenderImageMessageHandler
|
||||
{
|
||||
/**
|
||||
* Waveshare Spectra 6 palette. Values are the dither's *mapping targets* —
|
||||
* panel still displays each nibble as its actual ink colour regardless of
|
||||
* what RGB we tell the algorithm.
|
||||
*
|
||||
* Baseline (frozen at render-baseline-2026-05-14):
|
||||
* 0x1 WHITE = [245, 245, 240]
|
||||
* 0x5 BLUE = [24, 64, 192]
|
||||
* Experiment #5 (BLUE → [8, 32, 220]) — made sky greenish, reverted.
|
||||
* Experiment #6 (WHITE → [255, 248, 230]) — warmer cream pulls warm
|
||||
* skin pixels toward WHITE before error diffusion can drift them
|
||||
* toward BLUE, without changing how sky maps.
|
||||
*/
|
||||
public const PALETTE = [
|
||||
// Waveshare Spectra 6 palette — must match gen_screens.py
|
||||
private const PALETTE = [
|
||||
0x0 => [26, 26, 26 ], // BLACK
|
||||
0x1 => [255, 248, 230], // WHITE (warmed in experiment #6)
|
||||
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
|
||||
];
|
||||
|
||||
// ── Render tunables ──────────────────────────────────────────────────────
|
||||
// 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%, prevents desaturated photos collapsing to dither
|
||||
* noise; greens stay vibrant). */
|
||||
public const SATURATION_PCT = 130;
|
||||
|
||||
/** gammaImage(GAMMA). 1.0 = no change; >1 lifts midtones, <1 crushes. */
|
||||
public const GAMMA = 1.0;
|
||||
|
||||
/** sharpenImage(radius=0, SHARPEN_SIGMA). Baseline: 0.8 (light). */
|
||||
public const SHARPEN_SIGMA = 0.8;
|
||||
|
||||
/** blurImage(radius=0, BLUR_SIGMA) applied *before* dither. 0 = no blur. */
|
||||
public const BLUR_SIGMA = 0.0;
|
||||
|
||||
/** evaluateImage(MULTIPLY, BLUE_CHANNEL_MUL, CHANNEL_BLUE) — scales the
|
||||
* source's blue channel before dither. 1.0 = no change. Reverted from
|
||||
* experiment #4 (0.95) which made shadow regions appear MORE blue, not
|
||||
* less — likely because reducing source blue still left positive blue
|
||||
* error after a BLACK mapping, which the dither then spent on
|
||||
* neighbours, creating blue dither dots in dark regions. */
|
||||
public const BLUE_CHANNEL_MUL = 1.0;
|
||||
|
||||
/** Imagick dither method. Sticking with Floyd-Steinberg — Riemersma
|
||||
* produced visible Hilbert-curve "ink-spill" streaks in skin/sky. */
|
||||
public const DITHER_METHOD = \Imagick::DITHERMETHOD_FLOYDSTEINBERG;
|
||||
|
||||
public function __construct(
|
||||
private readonly ImageRepository $imageRepo,
|
||||
private readonly RenderedAssetRepository $assetRepo,
|
||||
@@ -161,35 +116,12 @@ final class RenderImageMessageHandler
|
||||
// histogram percentiles and produces gentle, correct stretching.
|
||||
$imagick->normalizeImage();
|
||||
|
||||
// Midtone lift before the saturation + dither passes. Spectra-6's
|
||||
// tonal range is squeezed (effectively ~3 bits per channel after
|
||||
// dither); without a gamma push, faces and shadow detail get
|
||||
// crushed into BLACK and the photo reads as too dark on the panel.
|
||||
if (self::GAMMA !== 1.0) {
|
||||
$imagick->gammaImage(self::GAMMA);
|
||||
}
|
||||
|
||||
// Saturation boost. Dark desaturated photos otherwise map almost
|
||||
// entirely to BLACK with scattered noise dots from error diffusion;
|
||||
// too much saturation and skin tones go ruddy / sky goes synthetic.
|
||||
$imagick->modulateImage(100, self::SATURATION_PCT, 100);
|
||||
// Boost saturation 130%. Dark desaturated photos otherwise map almost
|
||||
// entirely to BLACK with scattered noise dots from error diffusion.
|
||||
$imagick->modulateImage(100, 130, 100);
|
||||
|
||||
// Light sharpen so edges survive the dithering scatter.
|
||||
$imagick->sharpenImage(0, self::SHARPEN_SIGMA);
|
||||
|
||||
// Optional pre-dither blur to soften sharp colour transitions.
|
||||
if (self::BLUR_SIGMA > 0) {
|
||||
$imagick->blurImage(0, self::BLUR_SIGMA);
|
||||
}
|
||||
|
||||
// Optional blue-channel knock-down. Spectra-6 has only 6 inks; skin
|
||||
// tones in outdoor photos pick up a sky cast that the dither would
|
||||
// otherwise map to BLUE. Scaling the blue channel down a few % pulls
|
||||
// borderline-bluish-skin pixels off the BLUE attractor while leaving
|
||||
// real sky safely above it.
|
||||
if (self::BLUE_CHANNEL_MUL !== 1.0) {
|
||||
$imagick->evaluateImage(\Imagick::EVALUATE_MULTIPLY, self::BLUE_CHANNEL_MUL, \Imagick::CHANNEL_BLUE);
|
||||
}
|
||||
$imagick->sharpenImage(0, 0.8);
|
||||
|
||||
// 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.
|
||||
@@ -229,7 +161,7 @@ final class RenderImageMessageHandler
|
||||
$palImagick->resetIterator();
|
||||
$palStrip = $palImagick->appendImages(false);
|
||||
|
||||
$imagick->remapImage($palStrip, self::DITHER_METHOD);
|
||||
$imagick->remapImage($palStrip, \Imagick::DITHERMETHOD_FLOYDSTEINBERG);
|
||||
$palStrip->destroy();
|
||||
$palImagick->destroy();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user