diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8be4350 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +resources/models/*.onnx filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index ee4707c..837fea9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /vendor/ +/.transformers-cache/ +/examples/focus-crop/ /.vscode/ .phpunit.result.cache .idea diff --git a/README.md b/README.md index 51bb8b1..d44ecdf 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,11 @@ $target = 'image_100x100.jpg'; $image->crop(100, 100, Image::GRAVITY_TOP_LEFT); $image->save($target, 'jpg', 100); +//automatically keep salient subjects in frame +$image = new Image(\file_get_contents('image.jpg')); +$image->crop(400, 300, Image::GRAVITY_AUTO); +$image->save('automatic.jpg', 'jpg', 100); + $image = new Image(\file_get_contents('image.jpg')); $target = 'image_border.jpg'; $image->setBorder(2, "#ff0000"); //add border 2 px, red @@ -43,9 +48,52 @@ $image->save($target, 'png', 100); ``` +### Automatic Cropping + +Use `Image::GRAVITY_AUTO` to position the crop around the most visually salient parts of an image: + +```php +$image->crop(400, 300, Image::GRAVITY_AUTO); +``` + +Automatic cropping uses the bundled full U2NET saliency model. Empty or uniform saliency maps fall back to a centered crop. Applications do not need to download or configure a model. + +Detection can run separately in a worker and be stored as JSON. The result contains the normalized saliency mask and its dimensions. Passing it to `crop()` skips model inference in the image worker: + +```php +// Detection worker +$image = new Image(\file_get_contents('image.jpg')); +$detectionJson = json_encode($image->detect(), JSON_THROW_ON_ERROR); +// Store $detectionJson in the database. + +// Image worker +$image = new Image(\file_get_contents('image.jpg')); +$detection = json_decode($detectionJson, true, flags: JSON_THROW_ON_ERROR); +$image->crop(400, 300, Image::GRAVITY_AUTO, $detection); +``` + +ONNX Runtime is installed per platform. Add its verified download hook to the root `composer.json` of the application using this library: + +```json +{ + "scripts": { + "post-install-cmd": "OnnxRuntime\\Vendor::check", + "post-update-cmd": "OnnxRuntime\\Vendor::check" + } +} +``` + +Then run: + +```bash +composer install +``` + +The provided Linux runtime targets glibc. Alpine and other musl-based systems require a compatible custom ONNX Runtime library. + ## System requirements -Utopia Image requires PHP 8.1 or later. We recommend using the latest PHP version whenever possible. +Utopia Image requires PHP 8.1 or later with the Imagick, GD, and FFI extensions. We recommend using the latest PHP version whenever possible. ## Testing diff --git a/composer.json b/composer.json index c9fb314..f8f04eb 100644 --- a/composer.json +++ b/composer.json @@ -16,12 +16,24 @@ } }, "scripts": { - "test": "phpunit --testsuite unit" + "lint": "./vendor/bin/pint --test", + "format": "./vendor/bin/pint", + "check": "./vendor/bin/phpstan analyse --level max src tests --memory-limit=512M", + "test": "phpunit --testsuite unit", + "post-install-cmd": "OnnxRuntime\\Vendor::check", + "post-update-cmd": "OnnxRuntime\\Vendor::check" }, "require": { "php": ">=8.1", + "ext-ffi": "*", "ext-imagick": "*", - "ext-gd": "*" + "ext-gd": "*", + "ankane/onnxruntime": "^0.2.9" + }, + "require-dev": { + "phpunit/phpunit": "10.5.*", + "phpstan/phpstan": "2.1.*", + "laravel/pint": "1.24.*" }, "suggest": { "ext-imagick": "Imagick extension is required for Imagick adapter" diff --git a/resources/models/NOTICE.md b/resources/models/NOTICE.md new file mode 100644 index 0000000..410090d --- /dev/null +++ b/resources/models/NOTICE.md @@ -0,0 +1,14 @@ +# U2NET Model + +`u2net.onnx` is an ONNX export of the full U2NET model from the U-2-Net project: + +- Source: https://github.com/xuebinqin/U-2-Net +- ONNX distribution: https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx +- MD5: `60024c5c889badc19c04ad937298a77b` +- SHA-256: `8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491` +- License: Apache License 2.0 + +Copyright 2020 Xuebin Qin. + +Licensed under the Apache License, Version 2.0. You may obtain a copy at +https://www.apache.org/licenses/LICENSE-2.0. diff --git a/resources/models/u2net.onnx b/resources/models/u2net.onnx new file mode 100644 index 0000000..d5e2c4d --- /dev/null +++ b/resources/models/u2net.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491 +size 175997641 diff --git a/src/Image/Image.php b/src/Image/Image.php index ff086b8..32ae12a 100644 --- a/src/Image/Image.php +++ b/src/Image/Image.php @@ -6,9 +6,12 @@ use Imagick; use ImagickDraw; use ImagickPixel; +use OnnxRuntime\Model; class Image { + public const GRAVITY_AUTO = 'auto'; + public const GRAVITY_CENTER = 'center'; public const GRAVITY_TOP_LEFT = 'top-left'; @@ -41,12 +44,14 @@ class Image private int $rotation = 0; + private static ?Model $saliencyModel = null; + /** * @throws \ImagickException */ public function __construct(string $data) { - $this->image = new Imagick(); + $this->image = new Imagick; $this->image->readImageBlob($data); @@ -84,6 +89,7 @@ public function __construct(string $data) public static function getGravityTypes(): array { return [ + Image::GRAVITY_AUTO, Image::GRAVITY_CENTER, Image::GRAVITY_TOP_LEFT, Image::GRAVITY_TOP, @@ -97,13 +103,16 @@ public static function getGravityTypes(): array } /** + * @param null|array{width: int, height: int, mask: array} $detection + * * @throws \Throwable */ - public function crop(int $width, int $height, string $gravity = Image::GRAVITY_CENTER): self + public function crop(int $width, int $height, string $gravity = Image::GRAVITY_CENTER, ?array $detection = null): self { // if no changes to Gravity, Width or Height, don't process image - if ($gravity === Image::GRAVITY_CENTER - && ( + if (($gravity === Image::GRAVITY_CENTER || $gravity === Image::GRAVITY_AUTO) + && + ( ($width !== 0 && $height !== 0) && ($width === $this->width && $height === $this->height) )) { @@ -139,40 +148,51 @@ public function crop(int $width, int $height, string $gravity = Image::GRAVITY_C } $x = $y = 0; - switch ($gravity) { - case self::GRAVITY_TOP_LEFT: - $x = 0; - $y = 0; - break; - case self::GRAVITY_TOP: - $x = ($resizeWidth / 2) - ($width / 2); - break; - case self::GRAVITY_TOP_RIGHT: - $x = $resizeWidth - $width; - break; - case self::GRAVITY_LEFT: - $y = ($resizeHeight / 2) - ($height / 2); - break; - case self::GRAVITY_RIGHT: - $x = $resizeWidth - $width; - $y = ($resizeHeight / 2) - ($height / 2); - break; - case self::GRAVITY_BOTTOM_LEFT: - $x = 0; - $y = $resizeHeight - $height; - break; - case self::GRAVITY_BOTTOM: - $x = ($resizeWidth / 2) - ($width / 2); - $y = $resizeHeight - $height; - break; - case self::GRAVITY_BOTTOM_RIGHT: - $x = $resizeWidth - $width; - $y = $resizeHeight - $height; - break; - default: - $x = ($resizeWidth / 2) - ($width / 2); - $y = ($resizeHeight / 2) - ($height / 2); - break; + if ($gravity === self::GRAVITY_AUTO) { + $detection = $detection === null ? $this->detect() : $this->normalizeDetection($detection); + [$maskX, $maskY] = $this->findSalientCrop( + $detection['mask'], + max(1, intval(round($detection['width'] * $width / $resizeWidth))), + max(1, intval(round($detection['height'] * $height / $resizeHeight))) + ); + $x = min($resizeWidth - $width, $maskX * $resizeWidth / $detection['width']); + $y = min($resizeHeight - $height, $maskY * $resizeHeight / $detection['height']); + } else { + switch ($gravity) { + case self::GRAVITY_TOP_LEFT: + $x = 0; + $y = 0; + break; + case self::GRAVITY_TOP: + $x = ($resizeWidth / 2) - ($width / 2); + break; + case self::GRAVITY_TOP_RIGHT: + $x = $resizeWidth - $width; + break; + case self::GRAVITY_LEFT: + $y = ($resizeHeight / 2) - ($height / 2); + break; + case self::GRAVITY_RIGHT: + $x = $resizeWidth - $width; + $y = ($resizeHeight / 2) - ($height / 2); + break; + case self::GRAVITY_BOTTOM_LEFT: + $x = 0; + $y = $resizeHeight - $height; + break; + case self::GRAVITY_BOTTOM: + $x = ($resizeWidth / 2) - ($width / 2); + $y = $resizeHeight - $height; + break; + case self::GRAVITY_BOTTOM_RIGHT: + $x = $resizeWidth - $width; + $y = $resizeHeight - $height; + break; + default: + $x = ($resizeWidth / 2) - ($width / 2); + $y = ($resizeHeight / 2) - ($height / 2); + break; + } } $x = \intval($x); $y = \intval($y); @@ -203,6 +223,190 @@ public function crop(int $width, int $height, string $gravity = Image::GRAVITY_C return $this; } + /** + * Detects image saliency without modifying the image. + * The result can be persisted and later passed to crop() as $detection. + * + * @return array{width: int, height: int, mask: list>} + */ + public function detect(): array + { + return $this->normalizeDetection([ + 'width' => 320, + 'height' => 320, + 'mask' => $this->detectSaliency(), + ]); + } + + /** + * @param array{width: int, height: int, mask: array} $detection + * @return array{width: int, height: int, mask: list>} + */ + private function normalizeDetection(array $detection): array + { + $width = $detection['width']; + $height = $detection['height']; + $mask = $detection['mask']; + + if ($width < 1 || $height < 1 || count($mask) !== $height) { + throw new Exception('Invalid saliency detection result'); + } + + $normalized = []; + foreach ($mask as $row) { + if (! is_array($row) || count($row) !== $width) { + throw new Exception('Invalid saliency detection result'); + } + + $normalizedRow = []; + foreach ($row as $value) { + if (! is_numeric($value)) { + throw new Exception('Invalid saliency detection result'); + } + + $normalizedRow[] = max(0.0, min(1.0, (float) $value)); + } + $normalized[] = $normalizedRow; + } + + return [ + 'width' => $width, + 'height' => $height, + 'mask' => $normalized, + ]; + } + + /** + * @return list> + */ + protected function detectSaliency(): array + { + self::$saliencyModel ??= new Model(dirname(__DIR__, 2).'/resources/models/u2net.onnx'); + + $image = clone $this->image; + $image->setFirstIterator(); + $image = $image->getImage(); + $image->setImageBackgroundColor('white'); + $image->setImageAlphaChannel(Imagick::ALPHACHANNEL_REMOVE); + $image->transformImageColorspace(Imagick::COLORSPACE_RGB); + $image->resizeImage(320, 320, Imagick::FILTER_LANCZOS, 1, false); + + /** @var non-empty-list $pixels */ + $pixels = $image->exportImagePixels(0, 0, 320, 320, 'RGB', Imagick::PIXEL_CHAR); + $maximumPixel = max(1, max($pixels)); + $channels = [[], [], []]; + $means = [0.485, 0.456, 0.406]; + $deviations = [0.229, 0.224, 0.225]; + for ($y = 0; $y < 320; $y++) { + $rows = [[], [], []]; + for ($x = 0; $x < 320; $x++) { + $offset = ($y * 320 + $x) * 3; + for ($channel = 0; $channel < 3; $channel++) { + $rows[$channel][] = (($pixels[$offset + $channel] / $maximumPixel) - $means[$channel]) / $deviations[$channel]; + } + } + for ($channel = 0; $channel < 3; $channel++) { + $channels[$channel][] = $rows[$channel]; + } + } + + /** @var list $inputs */ + $inputs = self::$saliencyModel->inputs(); + /** @var list $outputs */ + $outputs = self::$saliencyModel->outputs(); + $inputName = $inputs[0]['name']; + $outputName = $outputs[0]['name']; + /** @var array>}}> $prediction */ + $prediction = self::$saliencyModel->predict([$inputName => [$channels]], [$outputName]); + $mask = $prediction[$outputName][0][0] ?? null; + if (! is_array($mask) || count($mask) !== 320) { + throw new Exception('U2NET returned an invalid saliency mask'); + } + + $minimum = INF; + $maximum = -INF; + foreach ($mask as $row) { + if (count($row) !== 320) { + throw new Exception('U2NET returned an invalid saliency mask'); + } + foreach ($row as $value) { + $minimum = min($minimum, (float) $value); + $maximum = max($maximum, (float) $value); + } + } + + $range = $maximum - $minimum; + if ($range < 1e-6) { + return array_fill(0, 320, array_fill(0, 320, 0.0)); + } + + $normalized = []; + foreach ($mask as $row) { + $normalized[] = array_map(fn ($value): float => ((float) $value - $minimum) / $range, $row); + } + + return $normalized; + } + + /** + * @param list> $mask + * @return array{int, int} + */ + protected function findSalientCrop(array $mask, int $width, int $height): array + { + $maskHeight = count($mask); + $maskWidth = count($mask[0] ?? []); + $width = min($maskWidth, $width); + $height = min($maskHeight, $height); + $centerX = ($maskWidth - $width) / 2; + $centerY = ($maskHeight - $height) / 2; + + if ($maskWidth === 0 || $maskHeight === 0) { + return [0, 0]; + } + + $integral = [array_fill(0, $maskWidth + 1, 0.0)]; + $minimum = INF; + $maximum = -INF; + foreach ($mask as $y => $row) { + $integral[$y + 1] = [0.0]; + $rowSum = 0.0; + for ($x = 0; $x < $maskWidth; $x++) { + $value = (float) ($row[$x] ?? 0.0); + $minimum = min($minimum, $value); + $maximum = max($maximum, $value); + $rowSum += $value; + $integral[$y + 1][$x + 1] = $integral[$y][$x + 1] + $rowSum; + } + } + + if ($maximum - $minimum < 1e-6) { + return [intval(round($centerX)), intval(round($centerY))]; + } + + $bestX = intval(round($centerX)); + $bestY = intval(round($centerY)); + $bestScore = -INF; + $bestDistance = INF; + for ($y = 0; $y <= $maskHeight - $height; $y++) { + for ($x = 0; $x <= $maskWidth - $width; $x++) { + $score = $integral[$y + $height][$x + $width] + - $integral[$y][$x + $width] + - $integral[$y + $height][$x] + + $integral[$y][$x]; + $distance = ($x - $centerX) ** 2 + ($y - $centerY) ** 2; + if ($score > $bestScore + 1e-9 || (abs($score - $bestScore) <= 1e-9 && $distance < $bestDistance)) { + $bestScore = $score; + $bestDistance = $distance; + $bestX = $x; + $bestY = $y; + } + } + } + + return [$bestX, $bestY]; + } + /** * @param int $borderWidth The size of the border in pixels * @param string $borderColor The color of the border in hex format @@ -232,13 +436,13 @@ public function setBorder(int $borderWidth, string $borderColor): self */ public function setBorderRadius(int $cornerRadius): self { - $mask = new Imagick(); + $mask = new Imagick; $mask->newImage($this->width, $this->height, new ImagickPixel('transparent'), 'png'); $rectwidth = ($this->borderWidth > 0 ? ($this->width - ($this->borderWidth + 1)) : $this->width - 1); $rectheight = ($this->borderWidth > 0 ? ($this->height - ($this->borderWidth + 1)) : $this->height - 1); - $shape = new ImagickDraw(); + $shape = new ImagickDraw; $shape->setFillColor(new ImagickPixel('black')); $shape->roundRectangle($this->borderWidth, $this->borderWidth, $rectwidth, $rectheight, $cornerRadius, $cornerRadius); @@ -246,13 +450,13 @@ public function setBorderRadius(int $cornerRadius): self $this->image->compositeImage($mask, Imagick::COMPOSITE_DSTIN, 0, 0); if ($this->borderWidth > 0) { - $bc = new ImagickPixel(); + $bc = new ImagickPixel; $bc->setColor($this->borderColor); - $strokeCanvas = new Imagick(); + $strokeCanvas = new Imagick; $strokeCanvas->newImage($this->width, $this->height, new ImagickPixel('transparent'), 'png'); - $shape2 = new ImagickDraw(); + $shape2 = new ImagickDraw; $shape2->setFillColor(new ImagickPixel('transparent')); $shape2->setStrokeWidth($this->borderWidth); $shape2->setStrokeColor($bc); @@ -337,8 +541,8 @@ public function output(string $type, int $quality = 75): ?string public function save(?string $path = null, string $type = '', int $quality = 75): ?string { // Create directory with write permissions - if ($path !== null && !file_exists(\dirname($path)) && ! @mkdir(\dirname($path), 0755, true)) { - throw new Exception('Can\'t create directory ' . \dirname($path)); + if ($path !== null && ! file_exists(\dirname($path)) && ! @mkdir(\dirname($path), 0755, true)) { + throw new Exception('Can\'t create directory '.\dirname($path)); } // Apply original metadata rotation diff --git a/tests/Image/ImageTest.php b/tests/Image/ImageTest.php index ea99663..c343798 100644 --- a/tests/Image/ImageTest.php +++ b/tests/Image/ImageTest.php @@ -20,7 +20,7 @@ private function requireEncoder(string $format): void self::markTestSkipped("The {$format} encoder is not available."); } - $probe = new \Imagick(); + $probe = new \Imagick; try { $probe->newImage(1, 1, 'white'); $probe->setImageFormat($format); @@ -36,22 +36,22 @@ private function requireEncoder(string $format): void private function jpegWithExifOrientation(int $orientation): string { - $source = new \Imagick(); + $source = new \Imagick; $source->newImage(20, 10, 'red', 'jpg'); $jpeg = $source->getImageBlob(); $exif = "Exif\0\0II*\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0" - . pack('v', $orientation) - . "\0\0\0\0\0\0"; - $segment = "\xff\xe1" . pack('n', \strlen($exif) + 2) . $exif; + .pack('v', $orientation) + ."\0\0\0\0\0\0"; + $segment = "\xff\xe1".pack('n', \strlen($exif) + 2).$exif; - return substr($jpeg, 0, 2) . $segment . substr($jpeg, 2); + return substr($jpeg, 0, 2).$segment.substr($jpeg, 2); } - public function testJpeg(): void + public function test_jpeg(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/100x100.jpg'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/100x100.jpg'; $image->crop(100, 100); @@ -69,10 +69,10 @@ public function testJpeg(): void unlink($target); } - public function testPng(): void + public function test_png(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/100x100.png'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/100x100.png'; $image->crop(100, 100); @@ -90,10 +90,10 @@ public function testPng(): void unlink($target); } - public function testCrop100x100(): void + public function test_crop100x100(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/100x100.jpg'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/100x100.jpg'; $image->crop(100, 100); @@ -111,11 +111,11 @@ public function testCrop100x100(): void unlink($target); } - public function testCropGravityNw(): void + public function test_crop_gravity_nw(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/NW.jpg'; - $original = __DIR__ . '/../resources/resize/NW.jpg'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/NW.jpg'; + $original = __DIR__.'/../resources/resize/NW.jpg'; $image->crop(50, 200, Image::GRAVITY_TOP_LEFT); @@ -138,11 +138,11 @@ public function testCropGravityNw(): void unlink($target); } - public function testCropGravityN(): void + public function test_crop_gravity_n(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-3.gif') ?: ''); - $target = __DIR__ . '/N.gif'; - $original = __DIR__ . '/../resources/resize/N.gif'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-3.gif') ?: ''); + $target = __DIR__.'/N.gif'; + $original = __DIR__.'/../resources/resize/N.gif'; $image->crop(100, 50, Image::GRAVITY_TOP); @@ -165,11 +165,11 @@ public function testCropGravityN(): void unlink($target); } - public function testCropGravityNe(): void + public function test_crop_gravity_ne(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/NE.jpg'; - $original = __DIR__ . '/../resources/resize/NE.jpg'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/NE.jpg'; + $original = __DIR__.'/../resources/resize/NE.jpg'; $image->crop(50, 200, Image::GRAVITY_TOP_RIGHT); @@ -192,11 +192,11 @@ public function testCropGravityNe(): void unlink($target); } - public function testCropGravitySw(): void + public function test_crop_gravity_sw(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/SW.jpg'; - $original = __DIR__ . '/../resources/resize/SW.jpg'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/SW.jpg'; + $original = __DIR__.'/../resources/resize/SW.jpg'; $image->crop(50, 200, Image::GRAVITY_BOTTOM_LEFT); @@ -219,11 +219,11 @@ public function testCropGravitySw(): void unlink($target); } - public function testCropGravityS(): void + public function test_crop_gravity_s(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-3.gif') ?: ''); - $target = __DIR__ . '/S.gif'; - $original = __DIR__ . '/../resources/resize/S.gif'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-3.gif') ?: ''); + $target = __DIR__.'/S.gif'; + $original = __DIR__.'/../resources/resize/S.gif'; $image->crop(100, 50, Image::GRAVITY_BOTTOM); @@ -246,11 +246,11 @@ public function testCropGravityS(): void unlink($target); } - public function testCropGravitySe(): void + public function test_crop_gravity_se(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/SE.jpg'; - $original = __DIR__ . '/../resources/resize/SE.jpg'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/SE.jpg'; + $original = __DIR__.'/../resources/resize/SE.jpg'; $image->crop(50, 200, Image::GRAVITY_BOTTOM_RIGHT); @@ -273,11 +273,11 @@ public function testCropGravitySe(): void unlink($target); } - public function testCropGravityC(): void + public function test_crop_gravity_c(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/C.jpg'; - $original = __DIR__ . '/../resources/resize/C.jpg'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/C.jpg'; + $original = __DIR__.'/../resources/resize/C.jpg'; $image->crop(150, 200, Image::GRAVITY_CENTER); @@ -300,11 +300,11 @@ public function testCropGravityC(): void unlink($target); } - public function testCropGravityW(): void + public function test_crop_gravity_w(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-3.gif') ?: ''); - $target = __DIR__ . '/W.gif'; - $original = __DIR__ . '/../resources/resize/W.gif'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-3.gif') ?: ''); + $target = __DIR__.'/W.gif'; + $original = __DIR__.'/../resources/resize/W.gif'; $image->crop(50, 100, Image::GRAVITY_LEFT); @@ -327,11 +327,11 @@ public function testCropGravityW(): void unlink($target); } - public function testCropGravityE(): void + public function test_crop_gravity_e(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/E.jpg'; - $original = __DIR__ . '/../resources/resize/E.jpg'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/E.jpg'; + $original = __DIR__.'/../resources/resize/E.jpg'; $image->crop(50, 200, Image::GRAVITY_RIGHT); @@ -354,12 +354,12 @@ public function testCropGravityE(): void unlink($target); } - public function testCropGravityPreservesAspectRatio(): void + public function test_crop_gravity_preserves_aspect_ratio(): void { - $source = new \Imagick(); + $source = new \Imagick; $source->newImage(2, 4, 'red', 'png'); - $draw = new \ImagickDraw(); + $draw = new \ImagickDraw; $draw->setFillColor('blue'); $draw->rectangle(0, 2, 1, 3); $source->drawImage($draw); @@ -367,7 +367,7 @@ public function testCropGravityPreservesAspectRatio(): void $image = new Image($source->getImageBlob()); $image->crop(4, 2, Image::GRAVITY_TOP); - $result = new \Imagick(); + $result = new \Imagick; $result->readImageBlob($image->output('png', 100) ?: ''); $color = $result->getImagePixelColor(2, 1)->getColor(); @@ -400,12 +400,12 @@ public static function gravityProvider(): \Iterator } #[DataProvider('gravityProvider')] - public function testCropGravityPositions(string $gravity, bool $horizontal, string $expectedChannel): void + public function test_crop_gravity_positions(string $gravity, bool $horizontal, string $expectedChannel): void { - $source = new \Imagick(); + $source = new \Imagick; $source->newImage($horizontal ? 6 : 2, $horizontal ? 2 : 6, 'red', 'png'); - $draw = new \ImagickDraw(); + $draw = new \ImagickDraw; $draw->setFillColor('green'); $draw->rectangle($horizontal ? 2 : 0, $horizontal ? 0 : 2, $horizontal ? 3 : 1, $horizontal ? 1 : 3); $draw->setFillColor('blue'); @@ -415,7 +415,7 @@ public function testCropGravityPositions(string $gravity, bool $horizontal, stri $image = new Image($source->getImageBlob()); $image->crop(2, 2, $gravity); - $result = new \Imagick(); + $result = new \Imagick; $result->readImageBlob($image->output('png', 100) ?: ''); $color = $result->getImagePixelColor(1, 1)->getColor(); @@ -431,10 +431,212 @@ public function testCropGravityPositions(string $gravity, bool $horizontal, stri }], $color[$expectedChannel]); } - public function testCrop100x400(): void + public function test_gravity_types_include_auto(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/100x400.jpg'; + $this->assertContains(Image::GRAVITY_AUTO, Image::getGravityTypes()); + } + + public function test_crop_auto_uses_horizontal_saliency(): void + { + $image = new class($this->createHorizontalStripeImage()) extends Image + { + /** + * @return list> + */ + protected function detectSaliency(): array + { + return array_fill(0, 320, [...array_fill(0, 213, 0.0), ...array_fill(0, 107, 1.0)]); + } + }; + + $image->crop(2, 2, Image::GRAVITY_AUTO); + + $result = new \Imagick; + $result->readImageBlob($image->output('png', 100) ?: ''); + $color = $result->getImagePixelColor(1, 1)->getColor(); + + $this->assertGreaterThan($color['r'], $color['b']); + $this->assertGreaterThan($color['g'], $color['b']); + } + + public function test_crop_auto_uses_vertical_saliency(): void + { + $image = new class($this->createVerticalStripeImage()) extends Image + { + /** + * @return list> + */ + protected function detectSaliency(): array + { + return [ + ...array_fill(0, 213, array_fill(0, 320, 0.0)), + ...array_fill(0, 107, array_fill(0, 320, 1.0)), + ]; + } + }; + + $image->crop(2, 2, Image::GRAVITY_AUTO); + + $result = new \Imagick; + $result->readImageBlob($image->output('png', 100) ?: ''); + $color = $result->getImagePixelColor(1, 1)->getColor(); + + $this->assertGreaterThan($color['r'], $color['b']); + $this->assertGreaterThan($color['g'], $color['b']); + } + + public function test_crop_auto_centers_flat_saliency(): void + { + $image = new class($this->createHorizontalStripeImage()) extends Image + { + /** + * @return list> + */ + protected function detectSaliency(): array + { + return array_fill(0, 320, array_fill(0, 320, 0.0)); + } + }; + + $image->crop(2, 2, Image::GRAVITY_AUTO); + + $result = new \Imagick; + $result->readImageBlob($image->output('png', 100) ?: ''); + $color = $result->getImagePixelColor(1, 1)->getColor(); + + $this->assertGreaterThan($color['r'], $color['g']); + $this->assertGreaterThan($color['b'], $color['g']); + } + + public function test_detect_returns_persistable_saliency_result(): void + { + $image = new class($this->createHorizontalStripeImage()) extends Image + { + protected function detectSaliency(): array + { + return array_fill(0, 320, array_fill(0, 320, 0.5)); + } + }; + + $detection = $image->detect(); + + $this->assertSame(320, $detection['width']); + $this->assertSame(320, $detection['height']); + $this->assertCount(320, $detection['mask']); + $this->assertCount(320, $detection['mask'][0]); + $this->assertSame(0.5, $detection['mask'][0][0]); + } + + public function test_crop_auto_uses_precomputed_detection(): void + { + $image = new class($this->createHorizontalStripeImage()) extends Image + { + protected function detectSaliency(): array + { + throw new \RuntimeException('Detection should not run'); + } + }; + + $image->crop(2, 2, Image::GRAVITY_AUTO, [ + 'width' => 320, + 'height' => 320, + 'mask' => array_fill(0, 320, [...array_fill(0, 213, 0.0), ...array_fill(0, 107, 1.0)]), + ]); + + $result = new \Imagick; + $result->readImageBlob($image->output('png', 100) ?: ''); + $color = $result->getImagePixelColor(1, 1)->getColor(); + + $this->assertGreaterThan($color['r'], $color['b']); + $this->assertGreaterThan($color['g'], $color['b']); + } + + public function test_crop_auto_prefers_center_on_equal_scores(): void + { + $image = new class($this->createHorizontalStripeImage()) extends Image + { + /** + * @param list> $mask + * @return array{int, int} + */ + public function selectCrop(array $mask, int $width, int $height): array + { + return $this->findSalientCrop($mask, $width, $height); + } + }; + + $this->assertSame([2, 0], $image->selectCrop([[1.0, 0.0, 1.0, 0.0]], 1, 1)); + } + + public function test_crop_auto_clamps_mask_coordinates_to_image_bounds(): void + { + $source = new \Imagick; + $source->newImage(1000, 100, 'white', 'png'); + $image = new class($source->getImageBlob()) extends Image + { + /** + * @return list> + */ + protected function detectSaliency(): array + { + return array_fill(0, 320, [...array_fill(0, 160, 0.0), ...array_fill(0, 160, 1.0)]); + } + }; + + $image->crop(501, 100, Image::GRAVITY_AUTO); + + $result = new \Imagick; + $result->readImageBlob($image->output('png', 100) ?: ''); + $this->assertSame(501, $result->getImageWidth()); + $this->assertSame(100, $result->getImageHeight()); + } + + public function test_crop_auto_with_u2net(): void + { + $image = new Image(\file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $image->crop(100, 200, Image::GRAVITY_AUTO); + + $result = new \Imagick; + $result->readImageBlob($image->output('png', 100) ?: ''); + + $this->assertSame(100, $result->getImageWidth()); + $this->assertSame(200, $result->getImageHeight()); + } + + private function createHorizontalStripeImage(): string + { + $source = new \Imagick; + $source->newImage(6, 2, 'red', 'png'); + + $draw = new \ImagickDraw; + $draw->setFillColor('green'); + $draw->rectangle(2, 0, 3, 1); + $draw->setFillColor('blue'); + $draw->rectangle(4, 0, 5, 1); + $source->drawImage($draw); + + return $source->getImageBlob(); + } + + private function createVerticalStripeImage(): string + { + $source = new \Imagick; + $source->newImage(2, 6, 'red', 'png'); + + $draw = new \ImagickDraw; + $draw->setFillColor('green'); + $draw->rectangle(0, 2, 1, 3); + $draw->setFillColor('blue'); + $draw->rectangle(0, 4, 1, 5); + $source->drawImage($draw); + + return $source->getImageBlob(); + } + + public function test_crop100x400(): void + { + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/100x400.jpg'; $image->crop(100, 400); @@ -452,10 +654,10 @@ public function testCrop100x400(): void unlink($target); } - public function testCrop400x100(): void + public function test_crop400x100(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/400x100.jpg'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/400x100.jpg'; $image->crop(400, 100); @@ -473,10 +675,10 @@ public function testCrop400x100(): void unlink($target); } - public function testCrop100x100Webp(): void + public function test_crop100x100_webp(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/100x100.webp'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/100x100.webp'; $image->crop(100, 100); @@ -495,11 +697,11 @@ public function testCrop100x100Webp(): void unlink($target); } - public function testCrop100x100WebpQuality30(): void + public function test_crop100x100_webp_quality30(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/100x100-q30.webp'; - $original = __DIR__ . '/../resources/resize/100x100-q30.webp'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/100x100-q30.webp'; + $original = __DIR__.'/../resources/resize/100x100-q30.webp'; $image->crop(100, 100); @@ -525,9 +727,9 @@ public function testCrop100x100WebpQuality30(): void unlink($target); } - public function testWebpBlobOutput(): void + public function test_webp_blob_output(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); $image->crop(100, 100); @@ -538,14 +740,14 @@ public function testWebpBlobOutput(): void $this->assertSame('RIFF', substr($blob, 0, 4)); $this->assertSame('WEBP', substr($blob, 8, 4)); - $probe = new \Imagick(); + $probe = new \Imagick; $probe->readImageBlob($blob); $this->assertSame(100, $probe->getImageWidth()); $this->assertSame(100, $probe->getImageHeight()); $this->assertContains($probe->getImageFormat(), ['PAM', 'WEBP']); } - public function testRepeatedOutputAppliesExifRotationOnce(): void + public function test_repeated_output_applies_exif_rotation_once(): void { $image = new Image($this->jpegWithExifOrientation(6)); @@ -554,9 +756,9 @@ public function testRepeatedOutputAppliesExifRotationOnce(): void $this->assertIsString($firstBlob); $this->assertIsString($secondBlob); - $first = new \Imagick(); + $first = new \Imagick; $first->readImageBlob($firstBlob); - $second = new \Imagick(); + $second = new \Imagick; $second->readImageBlob($secondBlob); $this->assertSame(10, $first->getImageWidth()); @@ -565,10 +767,10 @@ public function testRepeatedOutputAppliesExifRotationOnce(): void $this->assertSame($first->getImageHeight(), $second->getImageHeight()); } - public function testSavePreservesImageForSubsequentExports(): void + public function test_save_preserves_image_for_subsequent_exports(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/reusable.jpg'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/reusable.jpg'; try { $image->save($target, 'jpg', 75); @@ -577,7 +779,7 @@ public function testSavePreservesImageForSubsequentExports(): void $this->assertIsString($blob); $this->assertNotEmpty($blob); - $probe = new \Imagick(); + $probe = new \Imagick; $probe->readImageBlob($blob); $this->assertSame('PNG', $probe->getImageFormat()); } finally { @@ -587,17 +789,17 @@ public function testSavePreservesImageForSubsequentExports(): void } } - public function testSaveWritesFilenameZero(): void + public function test_save_writes_filename_zero(): void { $cwd = getcwd(); $this->assertIsString($cwd); - $directory = sys_get_temp_dir() . '/utopia-image-' . bin2hex(random_bytes(8)); + $directory = sys_get_temp_dir().'/utopia-image-'.bin2hex(random_bytes(8)); $this->assertTrue(mkdir($directory)); - $target = $directory . '/0'; + $target = $directory.'/0'; try { $this->assertTrue(chdir($directory)); - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); $this->assertNull($image->save('0', 'jpg', 75)); $this->assertFileExists($target); $this->assertNotEmpty(file_get_contents($target)); @@ -610,10 +812,10 @@ public function testSaveWritesFilenameZero(): void } } - public function testWebpFromWebpInput(): void + public function test_webp_from_webp_input(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/resize/100x100.webp') ?: ''); - $target = __DIR__ . '/roundtrip.webp'; + $image = new Image(file_get_contents(__DIR__.'/../resources/resize/100x100.webp') ?: ''); + $target = __DIR__.'/roundtrip.webp'; $image->crop(50, 50); @@ -630,12 +832,12 @@ public function testWebpFromWebpInput(): void unlink($target); } - public function testCrop100x100Avif(): void + public function test_crop100x100_avif(): void { $this->requireEncoder('AVIF'); - $image = new Image(file_get_contents(filename: __DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/100x100.avif'; + $image = new Image(file_get_contents(filename: __DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/100x100.avif'; $image->crop(100, 100); @@ -657,12 +859,12 @@ public function testCrop100x100Avif(): void unlink($target); } - public function testCrop100x100AvifQuality30(): void + public function test_crop100x100_avif_quality30(): void { $this->requireEncoder('AVIF'); - $image = new Image(file_get_contents(filename: __DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/100x100-q30.avif'; + $image = new Image(file_get_contents(filename: __DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/100x100-q30.avif'; $image->crop(100, 100); @@ -681,13 +883,13 @@ public function testCrop100x100AvifQuality30(): void unlink($target); } - public function testCrop100x100Heic(): void + public function test_crop100x100_heic(): void { $this->requireEncoder('HEIC'); - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/100x100.heic'; - $original = __DIR__ . '/../resources/resize/100x100.heic'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/100x100.heic'; + $original = __DIR__.'/../resources/resize/100x100.heic'; $image->crop(100, 100); @@ -713,13 +915,13 @@ public function testCrop100x100Heic(): void unlink($target); } - public function testCrop100x100HeicQuality30(): void + public function test_crop100x100_heic_quality30(): void { $this->requireEncoder('HEIC'); - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/100x100-q30.heic'; - $original = __DIR__ . '/../resources/resize/100x100.heic'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/100x100-q30.heic'; + $original = __DIR__.'/../resources/resize/100x100.heic'; $image->crop(100, 100); @@ -745,11 +947,11 @@ public function testCrop100x100HeicQuality30(): void unlink($target); } - public function testCrop100x100Png(): void + public function test_crop100x100_png(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/100x100.png'; - $original = __DIR__ . '/../resources/resize/100x100.png'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/100x100.png'; + $original = __DIR__.'/../resources/resize/100x100.png'; $image->crop(100, 100); @@ -770,11 +972,11 @@ public function testCrop100x100Png(): void unlink($target); } - public function testCrop100x100PngQuality30(): void + public function test_crop100x100_png_quality30(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/100x100-q30.jpg'; - $original = __DIR__ . '/../resources/resize/100x100-q30.jpg'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/100x100-q30.jpg'; + $original = __DIR__.'/../resources/resize/100x100-q30.jpg'; $image->crop(100, 100); @@ -795,11 +997,11 @@ public function testCrop100x100PngQuality30(): void unlink($target); } - public function testCrop100x100Gif(): void + public function test_crop100x100_gif(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-3.gif') ?: ''); - $target = __DIR__ . '/100x100.gif'; - $original = __DIR__ . '/../resources/resize/100x100.gif'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-3.gif') ?: ''); + $target = __DIR__.'/100x100.gif'; + $original = __DIR__.'/../resources/resize/100x100.gif'; $image->crop(100, 100); @@ -819,11 +1021,11 @@ public function testCrop100x100Gif(): void unlink($target); } - public function testBorder5Red(): void + public function test_border5_red(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/border_5_red.jpg'; - $original = __DIR__ . '/../resources/resize/border_5_red.jpg'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/border_5_red.jpg'; + $original = __DIR__.'/../resources/resize/border_5_red.jpg'; $image->setBorder(5, '#ff0000'); @@ -839,11 +1041,11 @@ public function testBorder5Red(): void unlink($target); } - public function testRotate45(): void + public function test_rotate45(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/rotate_45.jpg'; - $original = __DIR__ . '/../resources/resize/rotate_45.jpg'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/rotate_45.jpg'; + $original = __DIR__.'/../resources/resize/rotate_45.jpg'; $image->setRotation(45); @@ -861,11 +1063,11 @@ public function testRotate45(): void unlink($target); } - public function testOpacity02(): void + public function test_opacity02(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/opacity_0.2.png'; - $original = __DIR__ . '/../resources/resize/opacity_0.2.png'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/opacity_0.2.png'; + $original = __DIR__.'/../resources/resize/opacity_0.2.png'; $image->setOpacity(0.2); @@ -881,11 +1083,11 @@ public function testOpacity02(): void unlink($target); } - public function testBorderRadius500(): void + public function test_border_radius500(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/border_radius_500.png'; - $original = __DIR__ . '/../resources/resize/border_radius_500.png'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/border_radius_500.png'; + $original = __DIR__.'/../resources/resize/border_radius_500.png'; $image->setBorderRadius(500); @@ -901,11 +1103,11 @@ public function testBorderRadius500(): void unlink($target); } - public function testCrop100Op05(): void + public function test_crop100_op05(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/100x100_OP_0.5.png'; - $original = __DIR__ . '/../resources/resize/100x100_OP_0.5.png'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/100x100_OP_0.5.png'; + $original = __DIR__.'/../resources/resize/100x100_OP_0.5.png'; $image->crop(100, 100); $image->setOpacity(0.5); @@ -924,11 +1126,11 @@ public function testCrop100Op05(): void unlink($target); } - public function testCrop100BR50(): void + public function test_crop100_b_r50(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/kitten-1.jpg') ?: ''); - $target = __DIR__ . '/100x100_BR_50.png'; - $original = __DIR__ . '/../resources/resize/100x100_BR_50.png'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: ''); + $target = __DIR__.'/100x100_BR_50.png'; + $original = __DIR__.'/../resources/resize/100x100_BR_50.png'; $image->crop(100, 100); $image->setOpacity(0.5); @@ -945,10 +1147,10 @@ public function testCrop100BR50(): void unlink($target); } - public function testGifSmallLastFrame(): void + public function test_gif_small_last_frame(): void { - $image = new Image(file_get_contents(__DIR__ . '/../resources/disk-a/last-frame-1px.gif') ?: ''); - $target = __DIR__ . '/last-frame-1px-output.gif'; + $image = new Image(file_get_contents(__DIR__.'/../resources/disk-a/last-frame-1px.gif') ?: ''); + $target = __DIR__.'/last-frame-1px-output.gif'; $image->crop(0, 0); @@ -970,11 +1172,11 @@ public function testGifSmallLastFrame(): void * Animated WebP stores delta/partial frames. Cropping without coalesce * scales those fragments independently and produces ghosting artifacts. */ - public function testCropAnimatedWebpPreservesFrames(): void + public function test_crop_animated_webp_preserves_frames(): void { - $source = __DIR__ . '/../resources/disk-a/anim-delta.webp'; + $source = __DIR__.'/../resources/disk-a/anim-delta.webp'; $image = new Image(file_get_contents($source) ?: ''); - $target = __DIR__ . '/anim-delta-32x32.webp'; + $target = __DIR__.'/anim-delta-32x32.webp'; $image->crop(32, 32); $image->save($target, 'webp', 100); @@ -1014,11 +1216,11 @@ public function testCropAnimatedWebpPreservesFrames(): void * Consecutive identical frames are hold/pause frames. Cropping must keep * total playback delay — deconstructImages() + WebP encode can zero it out. */ - public function testCropAnimatedWebpPreservesHoldFrames(): void + public function test_crop_animated_webp_preserves_hold_frames(): void { - $sequence = new \Imagick(); + $sequence = new \Imagick; foreach (['#ff0000', '#ff0000', '#0000ff'] as $color) { - $frame = new \Imagick(); + $frame = new \Imagick; $frame->newImage(40, 40, new \ImagickPixel($color)); $frame->setImageDelay(40); $frame->setImageDispose(\Imagick::DISPOSE_NONE); @@ -1035,7 +1237,7 @@ public function testCropAnimatedWebpPreservesHoldFrames(): void $this->assertNotFalse($outputBlob); $this->assertNotNull($outputBlob); - $output = new \Imagick(); + $output = new \Imagick; $output->readImageBlob($outputBlob); $totalDelay = 0;