fix: apply tonal adjustments to photo before letterbox bars are added
CI / test (push) Has been cancelled
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:
@@ -95,13 +95,34 @@ final class RenderImageMessageHandler
|
||||
$imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN);
|
||||
$imagick->autoOrient();
|
||||
|
||||
// Fit the source into the target box without cropping aggressively;
|
||||
// composite onto a white canvas of exact target dims so an aspect
|
||||
// mismatch (e.g. portrait crop served to a landscape device) shows
|
||||
// up as letterbox bars instead of a brutally center-cropped slice.
|
||||
// For correctly-cropped photos the source already matches the target
|
||||
// aspect and `thumbnailImage` produces target dims with no padding.
|
||||
// Fit the source into the target box without cropping aggressively.
|
||||
// For correctly-cropped photos the source matches the target aspect
|
||||
// and `thumbnailImage` produces target dims with no padding; for an
|
||||
// aspect mismatch the photo comes out smaller than the target and
|
||||
// gets composited onto a white canvas below so it shows up as a
|
||||
// letterbox instead of a brutally center-cropped slice.
|
||||
$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) {
|
||||
$canvas = new \Imagick();
|
||||
$canvas->newImage($width, $height, new \ImagickPixel('white'));
|
||||
@@ -123,20 +144,6 @@ final class RenderImageMessageHandler
|
||||
$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
|
||||
$palImagick = new \Imagick();
|
||||
foreach (self::PALETTE as $rgb) {
|
||||
|
||||
Reference in New Issue
Block a user