fix: apply tonal adjustments to photo before letterbox bars are added
CI / test (push) Has been cancelled

Compositing the white letterbox bars before contrastStretch meant the
bars (pure white, often 60%+ of the canvas for an aspect mismatch) were
included in the per-channel histogram. The "lightest 1% to clip to white"
threshold therefore landed inside the bars themselves, raising the
effective white point so the photo's lighter tones got over-clipped to
white and the photo ended up washed out.

Reordering: thumbnail → contrastStretch/modulate/sharpen on the photo
alone → composite onto white canvas → rotate. The contrastStretch's
percentage now uses the photo's actual pixel count too, not the full
canvas, so the 1% clip is honest about how many pixels of real photo
content it's looking at.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 16:32:26 -04:00
parent 80cdac18dc
commit c633601d90
@@ -95,13 +95,34 @@ final class RenderImageMessageHandler
$imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN); $imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN);
$imagick->autoOrient(); $imagick->autoOrient();
// Fit the source into the target box without cropping aggressively; // Fit the source into the target box without cropping aggressively.
// composite onto a white canvas of exact target dims so an aspect // For correctly-cropped photos the source matches the target aspect
// mismatch (e.g. portrait crop served to a landscape device) shows // and `thumbnailImage` produces target dims with no padding; for an
// up as letterbox bars instead of a brutally center-cropped slice. // aspect mismatch the photo comes out smaller than the target and
// For correctly-cropped photos the source already matches the target // gets composited onto a white canvas below so it shows up as a
// aspect and `thumbnailImage` produces target dims with no padding. // letterbox instead of a brutally center-cropped slice.
$imagick->thumbnailImage($width, $height, true); $imagick->thumbnailImage($width, $height, true);
$imagick->setImageColorspace(\Imagick::COLORSPACE_SRGB);
// Per-pixel adjustments run on the PHOTO before the white canvas is
// composited under it — otherwise the bars dominate the histogram and
// the contrast stretch over-clips the photo content to white.
// Auto-levels: stretch the tonal range, clipping 1% at each end.
// Fixes underexposed/dark photos so the full palette range is used.
$photoPixels = $imagick->getImageWidth() * $imagick->getImageHeight();
$imagick->contrastStretchImage((int) ($photoPixels * 0.01), (int) ($photoPixels * 0.01));
// 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, 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.
if ($imagick->getImageWidth() !== $width || $imagick->getImageHeight() !== $height) { if ($imagick->getImageWidth() !== $width || $imagick->getImageHeight() !== $height) {
$canvas = new \Imagick(); $canvas = new \Imagick();
$canvas->newImage($width, $height, new \ImagickPixel('white')); $canvas->newImage($width, $height, new \ImagickPixel('white'));
@@ -123,20 +144,6 @@ final class RenderImageMessageHandler
$imagick->rotateImage(new \ImagickPixel('white'), -90); $imagick->rotateImage(new \ImagickPixel('white'), -90);
} }
$imagick->setImageColorspace(\Imagick::COLORSPACE_SRGB);
// Auto-levels: stretch the tonal range, clipping 1% at each end.
// Fixes underexposed/dark photos so the full palette range is used.
$pixels = $width * $height;
$imagick->contrastStretchImage((int) ($pixels * 0.01), (int) ($pixels * 0.01));
// 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, 0.8);
// Build a strip of 6 palette pixels for remapImage // Build a strip of 6 palette pixels for remapImage
$palImagick = new \Imagick(); $palImagick = new \Imagick();
foreach (self::PALETTE as $rgb) { foreach (self::PALETTE as $rgb) {