diff --git a/.gitignore b/.gitignore index b0fb682..c7b823d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ /vendor/ +/.transformers-cache/ +/examples/focus-crop/ /.vscode/ .phpunit.result.cache .idea -.DS_Store \ No newline at end of file +.DS_Store diff --git a/Dockerfile-php-8.1 b/Dockerfile-php-8.1 index 32a187d..a739331 100644 --- a/Dockerfile-php-8.1 +++ b/Dockerfile-php-8.1 @@ -1,17 +1,23 @@ -FROM composer:2.0 AS step0 +FROM composer:2 AS step0 WORKDIR /src/ COPY composer.lock /src/ COPY composer.json /src/ -RUN composer update --ignore-platform-reqs --optimize-autoloader \ - --no-plugins --no-scripts --prefer-dist +RUN composer install --ignore-platform-reqs --optimize-autoloader \ + --no-scripts --prefer-dist FROM appwrite/utopia-base:php-8.1-0.1.1 AS final LABEL maintainer="team@appwrite.io" +RUN apk add --no-cache libffi \ + && apk add --no-cache --virtual .build-deps $PHPIZE_DEPS libffi-dev \ + && docker-php-ext-install ffi \ + && apk del .build-deps \ + && printf 'ffi.enable=true\n' > /usr/local/etc/php/conf.d/ffi-enable.ini + WORKDIR /code COPY --from=step0 /src/vendor /code/vendor diff --git a/Dockerfile-php-8.2 b/Dockerfile-php-8.2 index d29a841..cc62cd1 100644 --- a/Dockerfile-php-8.2 +++ b/Dockerfile-php-8.2 @@ -1,17 +1,23 @@ -FROM composer:2.0 AS step0 +FROM composer:2 AS step0 WORKDIR /src/ COPY composer.lock /src/ COPY composer.json /src/ -RUN composer update --ignore-platform-reqs --optimize-autoloader \ - --no-plugins --no-scripts --prefer-dist +RUN composer install --ignore-platform-reqs --optimize-autoloader \ + --no-scripts --prefer-dist FROM appwrite/utopia-base:php-8.2-0.1.1 AS final LABEL maintainer="team@appwrite.io" +RUN apk add --no-cache libffi \ + && apk add --no-cache --virtual .build-deps $PHPIZE_DEPS libffi-dev \ + && docker-php-ext-install ffi \ + && apk del .build-deps \ + && printf 'ffi.enable=true\n' > /usr/local/etc/php/conf.d/ffi-enable.ini + WORKDIR /code COPY --from=step0 /src/vendor /code/vendor diff --git a/Dockerfile-php-8.3 b/Dockerfile-php-8.3 index 8fc1fc0..690ce2e 100644 --- a/Dockerfile-php-8.3 +++ b/Dockerfile-php-8.3 @@ -1,17 +1,23 @@ -FROM composer:2.0 AS step0 +FROM composer:2 AS step0 WORKDIR /src/ COPY composer.lock /src/ COPY composer.json /src/ -RUN composer update --ignore-platform-reqs --optimize-autoloader \ - --no-plugins --no-scripts --prefer-dist +RUN composer install --ignore-platform-reqs --optimize-autoloader \ + --no-scripts --prefer-dist FROM appwrite/utopia-base:php-8.3-0.1.1 AS final LABEL maintainer="team@appwrite.io" +RUN apk add --no-cache libffi \ + && apk add --no-cache --virtual .build-deps $PHPIZE_DEPS libffi-dev \ + && docker-php-ext-install ffi \ + && apk del .build-deps \ + && printf 'ffi.enable=true\n' > /usr/local/etc/php/conf.d/ffi-enable.ini + WORKDIR /code COPY --from=step0 /src/vendor /code/vendor diff --git a/README.md b/README.md index c571970..812f0a8 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,14 @@ use Utopia\Image\Image; //crop image $image = new Image(\file_get_contents('image.jpg')); $target = 'image_100x100.jpg'; -$image->crop(100, 100, Image::GRAVITY_NORTHWEST); +$image->crop(100, 100, Image::GRAVITY_TOP_LEFT); $image->save($target, 'jpg', 100); +//crop around subjects described with natural language +$image = new Image(\file_get_contents('image.jpg')); +$image->crop(400, 300, focus: 'person holding a phone'); +$image->save('focused.jpg', 'jpg', 100); + $image = new Image(\file_get_contents('image.jpg')); $target = 'image_border.jpg'; $image->setBorder(2, "#ff0000"); //add border 2 px, red @@ -41,9 +46,38 @@ $image->save($target, 'png', 100); ``` +### Focus Cropping + +The optional `focus` argument uses zero-shot object detection to position the crop around matching subjects: + +```php +$image->crop(400, 300, focus: 'person'); +$image->crop(400, 300, focus: 'red car'); +``` + +When multiple subjects match, the crop keeps all of them when possible and otherwise prioritizes the strongest detections. If nothing matches, the supplied gravity is used as a fallback: + +```php +$image->crop(400, 300, Image::GRAVITY_TOP, focus: 'red car'); +``` + +Focus cropping requires PHP FFI. The platform-specific inference runtime is installed through Composer, which requires allowing its reviewed installer plugin: + +```bash +composer config allow-plugins.codewithkyrian/platform-package-installer true +``` + +The prebuilt Linux inference runtime targets glibc. Alpine and other musl-based images require a musl-compatible ONNX Runtime build. + +The quantized model is cached on first use. Production deployments should download it during the build instead of during an image request: + +```bash +./vendor/bin/transformers download Xenova/owlvit-base-patch32 zero-shot-object-detection +``` + ## System Requirements -Utopia Image requires PHP 8.0 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. ## Copyright and license diff --git a/composer.json b/composer.json index 350dd7a..ac87ef3 100644 --- a/composer.json +++ b/composer.json @@ -10,13 +10,18 @@ "scripts": { "lint": "./vendor/bin/pint --test", "format": "./vendor/bin/pint", - "check": "./vendor/bin/phpstan analyse --level max src tests", + "check": "./vendor/bin/phpstan analyse --level max src tests --memory-limit=512M", "test": "phpunit" }, "require": { "php": ">=8.1", + "ext-ffi": "*", "ext-imagick": "*", - "ext-gd": "*" + "ext-gd": "*", + "codewithkyrian/transformers": "^0.6.2", + "symfony/console": "^6.4", + "symfony/string": "^6.4", + "symfony/yaml": "^6.4" }, "require-dev": { "phpunit/phpunit": "10.5.*", @@ -26,5 +31,10 @@ "suggest": { "ext-imagick": "Imagick extension is required for Imagick adapter" }, + "config": { + "allow-plugins": { + "codewithkyrian/platform-package-installer": true + } + }, "minimum-stability": "dev" } diff --git a/composer.lock b/composer.lock index 259ac06..6ed8335 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,1473 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cc1033906d1aa0b11062e961a1b28b98", - "packages": [], + "content-hash": "1e0d9ccab684731619cc649043f8adb9", + "packages": [ + { + "name": "codewithkyrian/jinja-php", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/CodeWithKyrian/jinja-php.git", + "reference": "17e61211ab00b81c81238791e6b86374715466f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CodeWithKyrian/jinja-php/zipball/17e61211ab00b81c81238791e6b86374715466f8", + "reference": "17e61211ab00b81c81238791e6b86374715466f8", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.89", + "pestphp/pest": "^2.36.0|^3.5.0", + "phpstan/phpstan": "^2.1", + "symfony/var-dumper": "^6.3|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Core/Utils.php" + ], + "psr-4": { + "Codewithkyrian\\Jinja\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com" + } + ], + "description": "A minimalistic PHP implementation of the Jinja templating engine, specifically designed for parsing and rendering ML chat templates.", + "support": { + "issues": "https://github.com/CodeWithKyrian/jinja-php/issues", + "source": "https://github.com/CodeWithKyrian/jinja-php/tree/2.1.0" + }, + "time": "2025-10-27T11:28:00+00:00" + }, + { + "name": "codewithkyrian/platform-package-installer", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/CodeWithKyrian/platform-package-installer.git", + "reference": "5f1b64d1d38b0e3f57668d7994a26e7a03beb1e4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CodeWithKyrian/platform-package-installer/zipball/5f1b64d1d38b0e3f57668d7994a26e7a03beb1e4", + "reference": "5f1b64d1d38b0e3f57668d7994a26e7a03beb1e4", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.1 || ^2.0", + "composer-runtime-api": "*", + "php": "^8.1", + "symfony/yaml": "^6.4 || ^7.2 || ^8.0" + }, + "require-dev": { + "composer/composer": "~1.0 || ~2.0", + "pestphp/pest": "^2.36.0|^3.5.0", + "symfony/var-dumper": "^6.0|^7.0|^8.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Codewithkyrian\\PlatformPackageInstaller\\Plugin", + "plugin-modifies-downloads": true, + "plugin-modifies-install-path": true + }, + "autoload": { + "psr-4": { + "Codewithkyrian\\PlatformPackageInstaller\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com" + } + ], + "description": "A Composer plugin that provides fine-grained control over platform-specific package distribution for PHP projects", + "support": { + "issues": "https://github.com/CodeWithKyrian/platform-package-installer/issues", + "source": "https://github.com/CodeWithKyrian/platform-package-installer/tree/1.1.4" + }, + "time": "2025-09-17T11:10:46+00:00" + }, + { + "name": "codewithkyrian/transformers", + "version": "0.6.2", + "source": { + "type": "git", + "url": "https://github.com/CodeWithKyrian/transformers-php.git", + "reference": "8b7f37cf0c8efab892d47530aa0026feb45d0145" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CodeWithKyrian/transformers-php/zipball/8b7f37cf0c8efab892d47530aa0026feb45d0145", + "reference": "8b7f37cf0c8efab892d47530aa0026feb45d0145", + "shasum": "" + }, + "require": { + "codewithkyrian/jinja-php": "^2.0", + "codewithkyrian/platform-package-installer": "^1.0", + "ext-ffi": "*", + "imagine/imagine": "^1.3", + "php": "^8.1", + "psr/log": "^1.1 || ^2.0 || ^3.0", + "rindow/rindow-math-matrix": "^2.1", + "rindow/rindow-matlib-ffi": "^1.1", + "rindow/rindow-openblas-ffi": "^1.0", + "rokka/imagine-vips": "^0.40", + "symfony/console": "^6.4|^7.0|^8.0" + }, + "require-dev": { + "ffi/var-dumper": "^1.0", + "pestphp/pest": "^2.36.0|^3.5.0", + "symfony/var-dumper": "^6.4.11|^7.1.5|^8.0" + }, + "suggest": { + "ext-gd": "Required to use the GD Driver for image processing", + "ext-imagick": "Required to use the Imagick Driver for image processing" + }, + "bin": [ + "bin/transformers" + ], + "type": "platform-package", + "extra": { + "platform-urls": { + "linux-arm64": "https://github.com/codewithkyrian/transformers-php/releases/download/{version}/dist-linux-arm64.tar.gz", + "darwin-arm64": "https://github.com/codewithkyrian/transformers-php/releases/download/{version}/dist-darwin-arm64.tar.gz", + "linux-x86_64": "https://github.com/codewithkyrian/transformers-php/releases/download/{version}/dist-linux-x86_64.tar.gz", + "darwin-x86_64": "https://github.com/codewithkyrian/transformers-php/releases/download/{version}/dist-darwin-x86_64.tar.gz", + "windows-x86_64": "https://github.com/codewithkyrian/transformers-php/releases/download/{version}/dist-windows-x86_64.zip" + } + }, + "autoload": { + "files": [ + "src/Pipelines/Pipeline.php", + "src/Utils/Helpers.php" + ], + "psr-4": { + "Codewithkyrian\\Transformers\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Kyrian Obikwelu", + "email": "koshnawaza@gmail.com" + } + ], + "description": "State-of-the-art Machine Learning for PHP. Run Transformers in PHP", + "keywords": [ + "CodeWithKyrian", + "ai", + "machine learning", + "natural language processing", + "nlp", + "php", + "transformers", + "transformers-php" + ], + "support": { + "issues": "https://github.com/CodeWithKyrian/transformers-php/issues", + "source": "https://github.com/CodeWithKyrian/transformers-php/tree/0.6.2" + }, + "time": "2025-09-15T15:17:33+00:00" + }, + { + "name": "imagine/imagine", + "version": "dev-develop", + "source": { + "type": "git", + "url": "https://github.com/php-imagine/Imagine.git", + "reference": "dd57a4c290ff4d223d17bcd219dac9ac8bf1cc16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-imagine/Imagine/zipball/dd57a4c290ff4d223d17bcd219dac9ac8bf1cc16", + "reference": "dd57a4c290ff4d223d17bcd219dac9ac8bf1cc16", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5 || ^8.4 || ^9.3" + }, + "suggest": { + "ext-exif": "to read EXIF metadata", + "ext-gd": "to use the GD implementation", + "ext-gmagick": "to use the Gmagick implementation", + "ext-imagick": "to use the Imagick implementation" + }, + "default-branch": true, + "type": "library", + "extra": { + "branch-alias": { + "dev-develop": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Imagine\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bulat Shakirzyanov", + "email": "mallluhuct@gmail.com", + "homepage": "http://avalanche123.com" + } + ], + "description": "Image processing for PHP", + "homepage": "http://imagine.readthedocs.org/", + "keywords": [ + "drawing", + "graphics", + "image manipulation", + "image processing" + ], + "support": { + "issues": "https://github.com/php-imagine/Imagine/issues", + "source": "https://github.com/php-imagine/Imagine/tree/1.5.4" + }, + "time": "2026-06-04T10:05:48+00:00" + }, + { + "name": "interop-phpobjects/polite-math", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/interop-phpobjects/polite-math.git", + "reference": "621246cdc108b1388307097e06361ca5b9259467" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/interop-phpobjects/polite-math/zipball/621246cdc108b1388307097e06361ca5b9259467", + "reference": "621246cdc108b1388307097e06361ca5b9259467", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Interop\\Polite\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Interoperability of interfaces around the Math", + "keywords": [ + "interop", + "interoperability", + "math" + ], + "support": { + "issues": "https://github.com/interop-phpobjects/polite-math/issues", + "source": "https://github.com/interop-phpobjects/polite-math/tree/1.0.7" + }, + "time": "2024-04-07T07:10:27+00:00" + }, + { + "name": "jcupitt/vips", + "version": "v2.6.1", + "source": { + "type": "git", + "url": "https://github.com/libvips/php-vips.git", + "reference": "6dbba1bb3a4ba1e39edb71016a2aa6da5bff32ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/libvips/php-vips/zipball/6dbba1bb3a4ba1e39edb71016a2aa6da5bff32ee", + "reference": "6dbba1bb3a4ba1e39edb71016a2aa6da5bff32ee", + "shasum": "" + }, + "require": { + "ext-ffi": "*", + "php": ">=7.4", + "psr/log": "^1.1.3|^2.0|^3.0" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpdocumentor/shim": "^3.3", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jcupitt\\Vips\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Cupitt", + "email": "jcupitt@gmail.com", + "homepage": "https://github.com/jcupitt", + "role": "Developer" + } + ], + "description": "A high-level interface to the libvips image processing library.", + "homepage": "https://github.com/libvips/php-vips", + "keywords": [ + "image", + "libvips", + "processing" + ], + "support": { + "issues": "https://github.com/libvips/php-vips/issues", + "source": "https://github.com/libvips/php-vips/tree/v2.6.1" + }, + "time": "2025-12-10T14:03:20+00:00" + }, + { + "name": "phenx/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "psr/container", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "707984727bd5b2b670e59559d3ed2500240cf875" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/707984727bd5b2b670e59559d3ed2500240cf875", + "reference": "707984727bd5b2b670e59559d3ed2500240cf875", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "default-branch": true, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container" + }, + "time": "2023-09-22T11:11:30+00:00" + }, + { + "name": "psr/log", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "default-branch": true, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "rindow/rindow-math-matrix", + "version": "2.1.2", + "source": { + "type": "git", + "url": "https://github.com/rindow/rindow-math-matrix.git", + "reference": "6d6622b4495d6325e4065430d143d6f4c3b5f0c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rindow/rindow-math-matrix/zipball/6d6622b4495d6325e4065430d143d6f4c3b5f0c4", + "reference": "6d6622b4495d6325e4065430d143d6f4c3b5f0c4", + "shasum": "" + }, + "require": { + "interop-phpobjects/polite-math": "^1.0.7", + "php": "^8.1" + }, + "suggest": { + "rindow/math-plot": "for OpenCL tunning", + "rindow/rindow-math-matrix-matlibffi": "^1.0.4" + }, + "bin": [ + "bin/rindow-math-matrix" + ], + "type": "library", + "autoload": { + "psr-4": { + "Rindow\\Math\\Matrix\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "The fundamental package for scientific matrix operation", + "keywords": [ + "N-dimension", + "math", + "matrix", + "operation", + "rindow" + ], + "support": { + "issues": "https://github.com/rindow/rindow-math-matrix/issues", + "source": "https://github.com/rindow/rindow-math-matrix/tree/2.1.2" + }, + "time": "2025-04-13T13:08:33+00:00" + }, + { + "name": "rindow/rindow-matlib-ffi", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/rindow/rindow-matlib-ffi.git", + "reference": "b5eddacc8a0fbc640cfdb90d6a40414ba6c76529" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rindow/rindow-matlib-ffi/zipball/b5eddacc8a0fbc640cfdb90d6a40414ba6c76529", + "reference": "b5eddacc8a0fbc640cfdb90d6a40414ba6c76529", + "shasum": "" + }, + "require": { + "interop-phpobjects/polite-math": "^1.0.7", + "php": "^8.1" + }, + "require-dev": { + "ext-ffi": "*", + "rindow/rindow-math-buffer-ffi": "^1.0.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Rindow\\Matlib\\FFI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "The math matrix library for FFI on PHP", + "keywords": [ + "ffi", + "math", + "matrix", + "rindow" + ], + "support": { + "issues": "https://github.com/rindow/rindow-matlib-ffi/issues", + "source": "https://github.com/rindow/rindow-matlib-ffi/tree/1.1.3" + }, + "time": "2025-04-13T12:19:57+00:00" + }, + { + "name": "rindow/rindow-openblas-ffi", + "version": "1.0.6", + "source": { + "type": "git", + "url": "https://github.com/rindow/rindow-openblas-ffi.git", + "reference": "efcddb9b24ac9d2d2f3a7d1092fbd5f66dccbb5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rindow/rindow-openblas-ffi/zipball/efcddb9b24ac9d2d2f3a7d1092fbd5f66dccbb5e", + "reference": "efcddb9b24ac9d2d2f3a7d1092fbd5f66dccbb5e", + "shasum": "" + }, + "require": { + "interop-phpobjects/polite-math": "^1.0.7", + "php": "^8.1" + }, + "require-dev": { + "ext-ffi": "*", + "rindow/rindow-math-buffer-ffi": "^1.0.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Rindow\\OpenBLAS\\FFI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "The Openblas library for FFI on PHP", + "keywords": [ + "ffi", + "math", + "openblas", + "rindow" + ], + "support": { + "issues": "https://github.com/rindow/rindow-openblas-ffi/issues", + "source": "https://github.com/rindow/rindow-openblas-ffi/tree/1.0.6" + }, + "time": "2025-04-13T12:42:45+00:00" + }, + { + "name": "rokka/imagine-vips", + "version": "0.40.0", + "source": { + "type": "git", + "url": "https://github.com/rokka-io/imagine-vips.git", + "reference": "c1ad61d63fad5e83adf8ff10a40b2ceeace3132a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rokka-io/imagine-vips/zipball/c1ad61d63fad5e83adf8ff10a40b2ceeace3132a", + "reference": "c1ad61d63fad5e83adf8ff10a40b2ceeace3132a", + "shasum": "" + }, + "require": { + "imagine/imagine": "^1.0", + "jcupitt/vips": "^2.1.1 || ^1.0.3", + "phenx/php-font-lib": "^0.5.2 || ^1.0", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.4", + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^8 || ^9" + }, + "suggest": { + "ext-gd": "to use the GD implementation fallback for saving unsupported file formats", + "ext-imagick": "to use the Imagick implementation fallback for saving unsupported file formats" + }, + "type": "library", + "autoload": { + "psr-0": { + "Imagine": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "rokka", + "email": "rokka@rokka.io", + "homepage": "https://rokka.io" + } + ], + "description": "libvips adapter for imagine", + "homepage": "https://github.com/rokka-io/imagine-vips", + "keywords": [ + "drawing", + "graphics", + "image manipulation", + "image processing", + "libvips", + "php-vips", + "vips" + ], + "support": { + "issues": "https://github.com/rokka-io/imagine-vips/issues", + "source": "https://github.com/rokka-io/imagine-vips/tree/0.40.0" + }, + "time": "2025-01-16T08:04:03+00:00" + }, + { + "name": "symfony/console", + "version": "6.4.x-dev", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "8693e056d048147f1af39101a98734aaef52fcbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/8693e056d048147f1af39101a98734aaef52fcbb", + "reference": "8693e056d048147f1af39101a98734aaef52fcbb", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^5.4|^6.0|^7.0" + }, + "conflict": { + "symfony/dependency-injection": "<5.4", + "symfony/dotenv": "<5.4", + "symfony/event-dispatcher": "<5.4", + "symfony/lock": "<5.4", + "symfony/process": "<5.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/6.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-11T07:57:57+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "default-branch": true, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/main" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "1.x-dev", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "default-branch": true, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/1.x" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "1.x-dev", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "1932ad15e9008457c91732fe826e954cc37336d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/1932ad15e9008457c91732fe826e954cc37336d3", + "reference": "1932ad15e9008457c91732fe826e954cc37336d3", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "default-branch": true, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/1.x" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "1.x-dev", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "default-branch": true, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/1.x" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "1.x-dev", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "default-branch": true, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/1.x" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "default-branch": true, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/main" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T09:55:08+00:00" + }, + { + "name": "symfony/string", + "version": "6.4.x-dev", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "9e104d31a626664eae583a94b3f21f22e6ead0d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/9e104d31a626664eae583a94b3f21f22e6ead0d4", + "reference": "9e104d31a626664eae583a94b3f21f22e6ead0d4", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/intl": "^6.2|^7.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/6.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-26T15:43:19+00:00" + }, + { + "name": "symfony/yaml", + "version": "6.4.x-dev", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "05d1794a312440084ee7782e55db7b5acafda120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/05d1794a312440084ee7782e55db7b5acafda120", + "reference": "05d1794a312440084ee7782e55db7b5acafda120", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<5.4" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/6.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-29T12:01:05+00:00" + } + ], "packages-dev": [ { "name": "laravel/pint", @@ -1786,9 +3251,10 @@ "prefer-lowest": false, "platform": { "php": ">=8.1", + "ext-ffi": "*", "ext-imagick": "*", "ext-gd": "*" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/src/Image/Image.php b/src/Image/Image.php index 146c19a..276a094 100644 --- a/src/Image/Image.php +++ b/src/Image/Image.php @@ -2,11 +2,14 @@ namespace Utopia\Image; +use Codewithkyrian\Transformers\Pipelines\Pipeline; use Exception; use Imagick; use ImagickDraw; use ImagickPixel; +use function Codewithkyrian\Transformers\Pipelines\pipeline; + class Image { public const GRAVITY_CENTER = 'center'; @@ -41,6 +44,8 @@ class Image private int $rotation = 0; + private static ?Pipeline $focusDetector = null; + /** * @throws \ImagickException */ @@ -99,10 +104,13 @@ public static function getGravityTypes(): array /** * @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, ?string $focus = null): self { + $focus = trim($focus ?? ''); + // if no changes to Gravity, Width or Height, don't process image - if ($gravity === Image::GRAVITY_CENTER && + if ($focus === '' && + $gravity === Image::GRAVITY_CENTER && ( (! empty($width) && ! empty($height)) && ($width == $this->width && $height == $this->height) @@ -125,9 +133,12 @@ public function crop(int $width, int $height, string $gravity = Image::GRAVITY_C $width = $this->width; } + $focusRegions = $focus === '' ? [] : $this->detectFocus($focus); + $hasFocus = $focusRegions !== []; + $resizeWidth = $this->width; $resizeHeight = $this->height; - if ($gravity !== Image::GRAVITY_CENTER) { + if ($gravity !== Image::GRAVITY_CENTER || $hasFocus) { $targetAspect = $width / $height; if ($targetAspect > $originalAspect) { $resizeWidth = $width; @@ -139,40 +150,85 @@ 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 ($hasFocus) { + $left = min(array_column($focusRegions, 'xmin')); + $top = min(array_column($focusRegions, 'ymin')); + $right = max(array_column($focusRegions, 'xmax')); + $bottom = max(array_column($focusRegions, 'ymax')); + + if (($right - $left) * $resizeWidth <= $width && ($bottom - $top) * $resizeHeight <= $height) { + $candidates = [[($left + $right) / 2, ($top + $bottom) / 2]]; + } else { + $candidates = [[($left + $right) / 2, ($top + $bottom) / 2], ...array_map( + fn (array $region): array => [ + ($region['xmin'] + $region['xmax']) / 2, + ($region['ymin'] + $region['ymax']) / 2, + ], + $focusRegions + )]; + } + + $bestScore = -1.0; + foreach ($candidates as [$candidateX, $candidateY]) { + $candidateLeft = max(0, min($resizeWidth - $width, ($candidateX * $resizeWidth) - ($width / 2))); + $candidateTop = max(0, min($resizeHeight - $height, ($candidateY * $resizeHeight) - ($height / 2))); + $candidateRight = $candidateLeft + $width; + $candidateBottom = $candidateTop + $height; + $score = 0.0; + + foreach ($focusRegions as $region) { + $regionLeft = $region['xmin'] * $resizeWidth; + $regionTop = $region['ymin'] * $resizeHeight; + $regionRight = $region['xmax'] * $resizeWidth; + $regionBottom = $region['ymax'] * $resizeHeight; + $intersectionWidth = max(0, min($candidateRight, $regionRight) - max($candidateLeft, $regionLeft)); + $intersectionHeight = max(0, min($candidateBottom, $regionBottom) - max($candidateTop, $regionTop)); + $regionArea = ($regionRight - $regionLeft) * ($regionBottom - $regionTop); + $score += ($intersectionWidth * $intersectionHeight / $regionArea) * $region['score']; + } + + if ($score > $bestScore) { + $bestScore = $score; + $x = $candidateLeft; + $y = $candidateTop; + } + } + } 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); @@ -181,7 +237,7 @@ public function crop(int $width, int $height, string $gravity = Image::GRAVITY_C $this->image = $this->image->coalesceImages(); foreach ($this->image as $frame) { - if ($gravity === self::GRAVITY_CENTER) { + if ($gravity === self::GRAVITY_CENTER && ! $hasFocus) { $frame->cropThumbnailImage($width, $height); } else { $frame->scaleImage($resizeWidth, $resizeHeight, false); @@ -193,7 +249,7 @@ public function crop(int $width, int $height, string $gravity = Image::GRAVITY_C $this->image->deconstructImages(); } else { foreach ($this->image as $frame) { - if ($gravity === self::GRAVITY_CENTER) { + if ($gravity === self::GRAVITY_CENTER && ! $hasFocus) { $this->image->cropThumbnailImage($width, $height); } else { $this->image->scaleImage($resizeWidth, $resizeHeight, false); @@ -207,6 +263,61 @@ public function crop(int $width, int $height, string $gravity = Image::GRAVITY_C return $this; } + /** + * @return list + */ + protected function detectFocus(string $focus): array + { + self::$focusDetector ??= pipeline('zero-shot-object-detection'); + + $path = tempnam(sys_get_temp_dir(), 'utopia-image-focus-'); + if ($path === false) { + throw new Exception('Failed to create image for focus detection'); + } + + try { + if (file_put_contents($path, $this->image->getImageBlob(), LOCK_EX) === false) { + throw new Exception('Failed to create image for focus detection'); + } + + $detections = (self::$focusDetector)($path, [$focus], threshold: 0.1, percentage: true, topK: PHP_INT_MAX); + } finally { + @unlink($path); + } + + if (! is_array($detections)) { + return []; + } + + $regions = []; + foreach ($detections as $detection) { + if (! is_array($detection) || ! isset($detection['box']) || ! is_array($detection['box'])) { + continue; + } + + $box = $detection['box']; + if (! isset($box['xmin'], $box['ymin'], $box['xmax'], $box['ymax'])) { + continue; + } + if (! is_numeric($box['xmin']) || ! is_numeric($box['ymin']) || ! is_numeric($box['xmax']) || ! is_numeric($box['ymax'])) { + continue; + } + + $xmin = max(0.0, min(1.0, (float) $box['xmin'])); + $ymin = max(0.0, min(1.0, (float) $box['ymin'])); + $xmax = max(0.0, min(1.0, (float) $box['xmax'])); + $ymax = max(0.0, min(1.0, (float) $box['ymax'])); + $score = isset($detection['score']) && is_numeric($detection['score']) ? (float) $detection['score'] : 1.0; + if ($xmin >= $xmax || $ymin >= $ymax) { + continue; + } + + $regions[] = compact('xmin', 'ymin', 'xmax', 'ymax', 'score'); + } + + return $regions; + } + /** * @param int $borderWidth The size of the border in pixels * @param string $borderColor The color of the border in hex format diff --git a/tests/Image/ImageTest.php b/tests/Image/ImageTest.php index 01ef850..53eac3f 100644 --- a/tests/Image/ImageTest.php +++ b/tests/Image/ImageTest.php @@ -397,6 +397,126 @@ public function test_crop_gravity_positions(string $gravity, bool $horizontal, s }], $color[$expectedChannel]); } + public function test_crop_focus_uses_detected_region(): void + { + $image = new class($this->createHorizontalStripeImage()) extends Image + { + public string $focus = ''; + + /** + * @return list + */ + protected function detectFocus(string $focus): array + { + $this->focus = $focus; + + return [['xmin' => 0.67, 'ymin' => 0.0, 'xmax' => 1.0, 'ymax' => 1.0, 'score' => 0.9]]; + } + }; + + $image->crop(2, 2, focus: 'blue object'); + + $result = new \Imagick; + $result->readImageBlob($image->output('png', 100) ?: ''); + $color = $result->getImagePixelColor(1, 1)->getColor(); + + $this->assertSame('blue object', $image->focus); + $this->assertGreaterThan($color['r'], $color['b']); + $this->assertGreaterThan($color['g'], $color['b']); + } + + public function test_crop_focus_keeps_all_regions_when_possible(): void + { + $image = new class($this->createHorizontalStripeImage()) extends Image + { + /** + * @return list + */ + protected function detectFocus(string $focus): array + { + return [ + ['xmin' => 0.0, 'ymin' => 0.0, 'xmax' => 0.32, 'ymax' => 1.0, 'score' => 0.8], + ['xmin' => 0.34, 'ymin' => 0.0, 'xmax' => 0.65, 'ymax' => 1.0, 'score' => 0.7], + ]; + } + }; + + $image->crop(4, 2, focus: 'subjects'); + + $result = new \Imagick; + $result->readImageBlob($image->output('png', 100) ?: ''); + $left = $result->getImagePixelColor(0, 1)->getColor(); + $right = $result->getImagePixelColor(3, 1)->getColor(); + + $this->assertGreaterThan($left['g'], $left['r']); + $this->assertGreaterThan($right['r'], $right['g']); + $this->assertGreaterThan($right['b'], $right['g']); + } + + public function test_crop_focus_prioritizes_stronger_region_when_all_cannot_fit(): void + { + $image = new class($this->createHorizontalStripeImage()) extends Image + { + /** + * @return list + */ + protected function detectFocus(string $focus): array + { + return [ + ['xmin' => 0.0, 'ymin' => 0.0, 'xmax' => 0.32, 'ymax' => 1.0, 'score' => 0.2], + ['xmin' => 0.68, 'ymin' => 0.0, 'xmax' => 1.0, 'ymax' => 1.0, 'score' => 0.9], + ]; + } + }; + + $image->crop(2, 2, focus: 'subjects'); + + $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_focus_uses_gravity_when_nothing_is_detected(): void + { + $image = new class($this->createHorizontalStripeImage()) extends Image + { + /** + * @return list + */ + protected function detectFocus(string $focus): array + { + return []; + } + }; + + $image->crop(2, 2, Image::GRAVITY_LEFT, focus: 'missing object'); + + $result = new \Imagick; + $result->readImageBlob($image->output('png', 100) ?: ''); + $color = $result->getImagePixelColor(1, 1)->getColor(); + + $this->assertGreaterThan($color['g'], $color['r']); + $this->assertGreaterThan($color['b'], $color['r']); + } + + 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(); + } + public function test_crop100x400(): void { $image = new Image(\file_get_contents(__DIR__.'/../resources/disk-a/kitten-1.jpg') ?: '');