From befb7876da6e370f0a14184b9efafe3ef110c47c Mon Sep 17 00:00:00 2001 From: Martin Ficzel Date: Fri, 12 Jul 2024 18:53:00 +0200 Subject: [PATCH] TASK: Calculate a preliminary crop to the target aspect for images with focalPoint The calculation of the preliminary crop ensures: 1. The crop to the target dimension is as large as possible inside the original image dimensions 2. The crop is placed in a way to ensure the focal point is as central as possible inside the original image dimensions --- .../ImageDimensionCalculationHelperThingy.php | 53 ++++++++ .../Model/Adjustment/MarkPointAdjustment.php | 94 +++++++++++++++ .../Model/FocalPointSupportInterface.php | 6 + .../Classes/Domain/Model/FocalPointTrait.php | 18 +++ .../ImageThumbnailGenerator.php | 67 +++++++++- .../Domain/Service/ThumbnailService.php | 48 ++++++-- ...geDimensionCalculationHelperThingyTest.php | 114 ++++++++++++++++++ 7 files changed, 386 insertions(+), 14 deletions(-) create mode 100644 Neos.Media/Classes/Domain/Model/Adjustment/MarkPointAdjustment.php diff --git a/Neos.Media/Classes/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingy.php b/Neos.Media/Classes/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingy.php index 50f84153fd5..fb6e76fc73b 100644 --- a/Neos.Media/Classes/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingy.php +++ b/Neos.Media/Classes/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingy.php @@ -14,7 +14,10 @@ */ use Imagine\Image\BoxInterface; +use Imagine\Image\Point; +use Imagine\Image\PointInterface; use Neos\Media\Domain\Model\ImageInterface; +use Neos\Media\Domain\ValueObject\Configuration\AspectRatio; use Neos\Media\Imagine\Box; class ImageDimensionCalculationHelperThingy @@ -185,4 +188,54 @@ public static function calculateFinalDimensions(BoxInterface $imageSize, BoxInte } return $requestedDimensions; } + + /** + * @param BoxInterface $originalDimensions + * @param PointInterface $originalFocalPoint + * @param BoxInterface $requestedDimensions + * @return array{cropOffset: Point, cropDimensions: Box, focalPoint: Point} + */ + public static function calculateCropConfigurationAndFocalPointForThumbnail( + BoxInterface $originalDimensions, + PointInterface $originalFocalPoint, + BoxInterface $requestedDimensions, + ): array { + $originalAspect = new AspectRatio($originalDimensions->getWidth(), $originalDimensions->getHeight()); + $finalAspect = new AspectRatio($requestedDimensions->getWidth(), $requestedDimensions->getHeight()); + + if ($originalAspect->getRatio() >= $finalAspect->getRatio()) { + // leading dimension = height, width is cropped + $factor = $requestedDimensions->getHeight() / $originalDimensions->getHeight(); + $cropBox = new \Imagine\Image\Box((int)$requestedDimensions->getWidth() / $factor, $requestedDimensions->getHeight() / $factor); + $cropX = $originalFocalPoint->getX() - (int)($cropBox->getWidth() / 2); + $cropXMax = $originalDimensions->getWidth() - $cropBox->getWidth(); + if ($cropX < 0) { + $cropX = 0; + } elseif ($cropX > $cropXMax) { + $cropX = $cropXMax; + } + $cropOffset = new Point($cropX, 0); + } else { + // leading dimension = width, height is cropped + $factor = $requestedDimensions->getWidth() / $originalDimensions->getWidth(); + $cropBox = new Box((int)$requestedDimensions->getWidth() / $factor, $requestedDimensions->getHeight() / $factor); + $cropY = $originalFocalPoint->getY() - (int)($cropBox->getHeight() / 2); + $cropYMax = $originalDimensions->getHeight() - $cropBox->getHeight(); + if ($cropY < 0) { + $cropY = 0; + } elseif ($cropY > $cropYMax) { + $cropY = $cropYMax; + } + $cropOffset = new Point(0, $cropY); + } + + return [ + 'cropOffset' => $cropOffset, + 'cropDimensions' => $cropBox, + 'focalPoint' => new Point( + (int)round(($originalFocalPoint->getX() - $cropOffset->getX()) * $factor), + (int)round(($originalFocalPoint->getY() - $cropOffset->getY()) * $factor) + ) + ]; + } } diff --git a/Neos.Media/Classes/Domain/Model/Adjustment/MarkPointAdjustment.php b/Neos.Media/Classes/Domain/Model/Adjustment/MarkPointAdjustment.php new file mode 100644 index 00000000000..0c48e691b9e --- /dev/null +++ b/Neos.Media/Classes/Domain/Model/Adjustment/MarkPointAdjustment.php @@ -0,0 +1,94 @@ +x = $x; + } + + public function setY(int $y): void + { + $this->y = $y; + } + + public function setRadius(int $radius): void + { + $this->radius = $radius; + } + + public function setThickness(int $thickness): void + { + $this->thickness = $thickness; + } + + public function setColor(string $color): void + { + $this->color = $color; + } + + + public function applyToImage(ImagineImageInterface $image) + { + $palette = new Palette\RGB(); + $color = $palette->color($this->color); + $image->draw() + ->circle( + new Point($this->x, $this->y), + $this->radius, + $color, + false, + $this->thickness + ) + ; + + return $image; + } + + public function canBeApplied(ImagineImageInterface $image) + { + if (is_null($this->x) || is_null($this->y) || is_null($this->radius)) { + return false; + } + return true; + } +} diff --git a/Neos.Media/Classes/Domain/Model/FocalPointSupportInterface.php b/Neos.Media/Classes/Domain/Model/FocalPointSupportInterface.php index e23cc46b884..9b860e11268 100644 --- a/Neos.Media/Classes/Domain/Model/FocalPointSupportInterface.php +++ b/Neos.Media/Classes/Domain/Model/FocalPointSupportInterface.php @@ -13,6 +13,8 @@ * source code. */ +use Imagine\Image\PointInterface; + /** * Interface for assets which provide methods for focal points */ @@ -25,4 +27,8 @@ public function setFocalPointX(?int $x): void; public function getFocalPointY(): ?int; public function setFocalPointY(?int $y): void; + + public function hasFocalPoint(): bool; + + public function getFocalPoint(): ?PointInterface; } diff --git a/Neos.Media/Classes/Domain/Model/FocalPointTrait.php b/Neos.Media/Classes/Domain/Model/FocalPointTrait.php index b01e7f7b300..ec27fe011ff 100644 --- a/Neos.Media/Classes/Domain/Model/FocalPointTrait.php +++ b/Neos.Media/Classes/Domain/Model/FocalPointTrait.php @@ -12,6 +12,8 @@ */ use Doctrine\ORM\Mapping as ORM; +use Imagine\Image\Point; +use Imagine\Image\PointInterface; /** * Trait for assets which provide methods for focal points @@ -50,4 +52,20 @@ public function setFocalPointY(?int $y): void { $this->focalPointY = $y; } + + public function hasFocalPoint(): bool + { + if ($this->focalPointX !== null && $this->focalPointY !== null) { + return true; + } + return false; + } + + public function getFocalPoint(): ?PointInterface + { + if ($this->hasFocalPoint()) { + return new Point($this->focalPointX, $this->focalPointY); + } + return null; + } } diff --git a/Neos.Media/Classes/Domain/Model/ThumbnailGenerator/ImageThumbnailGenerator.php b/Neos.Media/Classes/Domain/Model/ThumbnailGenerator/ImageThumbnailGenerator.php index 692646a9be6..70a2e9e217f 100644 --- a/Neos.Media/Classes/Domain/Model/ThumbnailGenerator/ImageThumbnailGenerator.php +++ b/Neos.Media/Classes/Domain/Model/ThumbnailGenerator/ImageThumbnailGenerator.php @@ -12,12 +12,17 @@ */ use Neos\Flow\Annotations as Flow; +use Neos\Media\Domain\Model\Adjustment\CropImageAdjustment; +use Neos\Media\Domain\Model\Adjustment\ImageDimensionCalculationHelperThingy; +use Neos\Media\Domain\Model\Adjustment\MarkPointAdjustment; use Neos\Media\Domain\Model\Adjustment\QualityImageAdjustment; use Neos\Media\Domain\Model\Adjustment\ResizeImageAdjustment; +use Neos\Media\Domain\Model\FocalPointSupportInterface; use Neos\Media\Domain\Model\ImageInterface; use Neos\Media\Domain\Model\Thumbnail; use Neos\Media\Domain\Service\ImageService; use Neos\Media\Exception; +use Neos\Media\Imagine\Box; /** * A system-generated preview version of an Image @@ -57,11 +62,6 @@ public function canRefresh(Thumbnail $thumbnail) public function refresh(Thumbnail $thumbnail) { try { - /** - * @todo ... add additional crop to ensure that the focal point is in view - * in view after resizing ... needs common understanding wit - * the thumbnail service here: Packages/Neos/Neos.Media/Classes/Domain/Service/ThumbnailService.php:151 - */ $adjustments = [ new ResizeImageAdjustment( [ @@ -80,6 +80,58 @@ public function refresh(Thumbnail $thumbnail) ) ]; + $asset = $thumbnail->getOriginalAsset(); + $focalPointCropConfiguration = null; + if ($asset instanceof FocalPointSupportInterface && $asset->hasFocalPoint()) { + // in case we have a focal point we calculate the target dimension and add an + // additional crop to ensure that the focal point stays inside the final image + + $originalFocalPoint = $asset->getFocalPoint(); + $originalDimensions = new Box($asset->getWidth(), $asset->getHeight()); + $requestedDimensions = ImageDimensionCalculationHelperThingy::calculateRequestedDimensions( + originalDimensions: $originalDimensions, + width: $thumbnail->getConfigurationValue('width'), + maximumWidth: $thumbnail->getConfigurationValue('maximumWidth'), + height: $thumbnail->getConfigurationValue('height'), + maximumHeight: $thumbnail->getConfigurationValue('maximumHeight'), + ratioMode: $thumbnail->getConfigurationValue('ratioMode'), + allowUpScaling: $thumbnail->getConfigurationValue('allowUpScaling'), + ); + + $focalPointCropConfiguration = ImageDimensionCalculationHelperThingy::calculateCropConfigurationAndFocalPointForThumbnail( + originalDimensions: $originalDimensions, + originalFocalPoint: $originalFocalPoint, + requestedDimensions: $requestedDimensions, + ); + + $adjustments = array_merge( + [ + new CropImageAdjustment( + [ + 'x' => $focalPointCropConfiguration['cropOffset']->getX(), + 'y' => $focalPointCropConfiguration['cropOffset']->getY(), + 'width' => $focalPointCropConfiguration['cropDimensions']->getWidth(), + 'height' => $focalPointCropConfiguration['cropDimensions']->getHeight(), + ] + ) + ], + $adjustments, + [ + // this is for debugging purposes only + // @todo remove before merging + new MarkPointAdjustment( + [ + 'x' => $focalPointCropConfiguration['focalPoint']->getX(), + 'y' => $focalPointCropConfiguration['focalPoint']->getY(), + 'radius' => 5, + 'color' => '#0f0', + 'thickness' => 4 + ] + ), + ] + ); + } + $targetFormat = $thumbnail->getConfigurationValue('format'); $processedImageInfo = $this->imageService->processImage($thumbnail->getOriginalAsset()->getResource(), $adjustments, $targetFormat); @@ -87,6 +139,11 @@ public function refresh(Thumbnail $thumbnail) $thumbnail->setWidth($processedImageInfo['width']); $thumbnail->setHeight($processedImageInfo['height']); $thumbnail->setQuality($processedImageInfo['quality']); + + if (is_array($focalPointCropConfiguration)) { + $thumbnail->setFocalPointX($focalPointCropConfiguration['focalPoint']->getX()); + $thumbnail->setFocalPointY($focalPointCropConfiguration['focalPoint']->getY()); + } } catch (\Exception $exception) { $message = sprintf('Unable to generate thumbnail for the given image (filename: %s, SHA1: %s)', $thumbnail->getOriginalAsset()->getResource()->getFilename(), $thumbnail->getOriginalAsset()->getResource()->getSha1()); throw new Exception\NoThumbnailAvailableException($message, 1433109654, $exception); diff --git a/Neos.Media/Classes/Domain/Service/ThumbnailService.php b/Neos.Media/Classes/Domain/Service/ThumbnailService.php index 55e5a141bd9..75a8e0262d3 100644 --- a/Neos.Media/Classes/Domain/Service/ThumbnailService.php +++ b/Neos.Media/Classes/Domain/Service/ThumbnailService.php @@ -18,6 +18,7 @@ use Neos\Flow\Persistence\Exception\IllegalObjectTypeException; use Neos\Flow\Persistence\PersistenceManagerInterface; use Neos\Flow\ResourceManagement\ResourceManager; +use Neos\Media\Domain\Model\Adjustment\ImageDimensionCalculationHelperThingy; use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Model\FocalPointSupportInterface; use Neos\Media\Domain\Model\ImageInterface; @@ -25,6 +26,7 @@ use Neos\Media\Domain\Model\ThumbnailConfiguration; use Neos\Media\Domain\Repository\ThumbnailRepository; use Neos\Media\Exception\ThumbnailServiceException; +use Neos\Media\Imagine\Box; use Neos\Utility\Arrays; use Neos\Utility\MediaTypes; use Psr\Log\LoggerInterface; @@ -85,6 +87,12 @@ class ThumbnailService */ protected $throwableStorage; + /** + * @var ImageDimensionCalculationHelperThingy + * @Flow\Inject + */ + protected $imageDimensionCalculationHelperThingy; + /** * Returns a thumbnail of the given asset * @@ -148,15 +156,37 @@ public function getThumbnail(AssetInterface $asset, ThumbnailConfiguration $conf if ($thumbnail === null) { $thumbnail = new Thumbnail($asset, $configuration); - if ($asset instanceof FocalPointSupportInterface) { - // @todo: needs common understanding of dimension change with resize adjustment - // - if a focal point was set - // - calculate target dimensions here - // - calculate new focalPointAfter transformation - // - store focal point in new image - // has to work closely with: Packages/Neos/Neos.Media/Classes/Domain/Model/ThumbnailGenerator/ImageThumbnailGenerator.php:58 - $thumbnail->setFocalPointX($asset->getFocalPointX() ? $asset->getFocalPointX() + 1 : null); - $thumbnail->setFocalPointY($asset->getFocalPointY() ? $asset->getFocalPointY() + 1 : null); + // predict dimensions async image thumbnails, this is not needed for immediately calculated images as those + // values are stored again after calculating + if ($async === true && $asset instanceof ImageInterface) { + $originalDimensions = new Box($asset->getWidth(), $asset->getHeight()); + + $requestedDimensions = ImageDimensionCalculationHelperThingy::calculateRequestedDimensions( + originalDimensions: $originalDimensions, + width: $configuration->getWidth(), + maximumWidth: $configuration->getMaximumWidth(), + height: $configuration->getHeight(), + maximumHeight: $configuration->getMaximumHeight(), + ratioMode: $configuration->getRatioMode(), + allowUpScaling: $configuration->isUpScalingAllowed() + ); + + $thumbnail->setWidth($requestedDimensions->getWidth()); + $thumbnail->setHeight($requestedDimensions->getHeight()); + + // calculate focal point for new thumbnails + if ($asset instanceof FocalPointSupportInterface && $asset->hasFocalPoint()) { + $originalFocalPoint = $asset->getFocalPoint(); + + $focalPointCropConfiguration = ImageDimensionCalculationHelperThingy::calculateCropConfigurationAndFocalPointForThumbnail( + originalDimensions: $originalDimensions, + originalFocalPoint: $originalFocalPoint, + requestedDimensions: $requestedDimensions, + ); + + $thumbnail->setFocalPointX($focalPointCropConfiguration['focalPoint']->getX()); + $thumbnail->setFocalPointY($focalPointCropConfiguration['focalPoint']->getY()); + } } // If the thumbnail strategy failed to generate a valid thumbnail diff --git a/Neos.Media/Tests/Unit/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingyTest.php b/Neos.Media/Tests/Unit/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingyTest.php index afbfcbe0d4a..d026c1d9553 100644 --- a/Neos.Media/Tests/Unit/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingyTest.php +++ b/Neos.Media/Tests/Unit/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingyTest.php @@ -11,6 +11,9 @@ * source code. */ +use Imagine\Image\BoxInterface; +use Imagine\Image\Point; +use Imagine\Image\PointInterface; use Neos\Media\Domain\Model\Adjustment\ImageDimensionCalculationHelperThingy; use Neos\Media\Imagine\Box; use Neos\Flow\Tests\UnitTestCase; @@ -174,4 +177,115 @@ public function combinationsOfMaximumAndMinimumWidthAndHeightAreCalculatedCorrec ) ); } + + public static function calculateCropConfigurationCentersFocalPointDataProvider(): \Generator + { + yield 'square to square' => [ + new \Imagine\Image\Box(400, 400), + new Point(200, 200), + new Box(200, 200), + + new Point(0, 0), + new Box(400, 400), + new Point(100, 100), + ]; + + yield 'portrait to portrait' => [ + new Box(800, 400), + new Point(400, 200), + new Box(400, 200), + + new Point(0, 0), + new Box(800, 400), + new Point(200, 100), + ]; + + yield 'portrait to square fp left' => [ + new Box(800, 400), + new Point(50, 200), + new Box(400, 400), + + new Point(0, 0), + new Box(400, 400), + new Point(50, 200), + ]; + + yield 'portrait to square fp center' => [ + new Box(800, 400), + new Point(400, 200), + new Box(400, 400), + + new Point(200, 0), + new Box(400, 400), + new Point(200, 200), + ]; + + yield 'portrait to square fp right' => [ + new Box(800, 400), + new Point(700, 100), + new Box(400, 400), + + new Point(400, 0), + new Box(400, 400), + new Point(300, 100), + ]; + + yield 'landscape to square fp center' => [ + new Box(400, 800), + new Point(200, 400), + new Box(400, 400), + + new Point(0, 200), + new Box(400, 400), + new Point(200, 200), + ]; + + yield 'landscape to square fp top' => [ + new Box(400, 800), + new Point(350, 50), + new Box(400, 400), + + new Point(0, 0), + new Box(400, 400), + new Point(350, 50), + ]; + + yield 'landscape to square fp bottom' => [ + new Box(400, 800), + new Point(300, 750), + new Box(200, 200), + + new Point(0, 400), + new Box(400, 400), + new Point(150, 175), + ]; + } + + /** + * @dataProvider calculateCropConfigurationCentersFocalPointDataProvider + * @test + */ + public function calculateCropConfigurationCentersFocalPoint( + BoxInterface $originalDimensions, + PointInterface $originalFocalPoint, + BoxInterface $requestedDimensions, + PointInterface $expectedCropOffset, + BoxInterface $expectedCropDimensions, + PointInterface $expectedCroppedFocalPoint + ): void { + $focalPointCropConfiguration = ImageDimensionCalculationHelperThingy::calculateCropConfigurationAndFocalPointForThumbnail( + $originalDimensions, + $originalFocalPoint, + $requestedDimensions + ); + + $this->assertEquals($expectedCropOffset->getX(), $focalPointCropConfiguration['cropOffset']->getX()); + $this->assertEquals($expectedCropOffset->getY(), $focalPointCropConfiguration['cropOffset']->getY()); + + $this->assertEquals($expectedCropDimensions->getWidth(), $focalPointCropConfiguration['cropDimensions']->getWidth()); + $this->assertEquals($expectedCropDimensions->getWidth(), $focalPointCropConfiguration['cropDimensions']->getWidth()); + + $this->assertEquals($expectedCroppedFocalPoint->getX(), $focalPointCropConfiguration['focalPoint']->getX()); + $this->assertEquals($expectedCroppedFocalPoint->getY(), $focalPointCropConfiguration['focalPoint']->getY()); + } }