Skip to content

Add automatic saliency cropping - #46

Open
TorstenDittmann wants to merge 5 commits into
mainfrom
feat/semantic-focus-crop
Open

Add automatic saliency cropping#46
TorstenDittmann wants to merge 5 commits into
mainfrom
feat/semantic-focus-crop

Conversation

@TorstenDittmann

@TorstenDittmann TorstenDittmann commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add Image::GRAVITY_AUTO without changing the existing three-argument crop() API
  • use the bundled full U2NET model through ankane/onnxruntime to detect visually salient regions
  • score candidate crop windows with an integral saliency map and prefer centered placement on ties
  • fall back to a centered crop for empty or uniform saliency maps
  • clamp mask-derived coordinates to preserve exact output dimensions at image boundaries
  • cache the ONNX model once per PHP worker
  • replace Alpine test images with Debian Bullseye/glibc images for the supported ONNX Runtime binaries

Usage

$image->crop(400, 300, Image::GRAVITY_AUTO);

Model And Runtime

  • full U2NET ONNX model, 175,997,641 bytes
  • SHA-256: 8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491
  • model source and Apache-2.0 attribution are recorded in resources/models/NOTICE.md
  • the model is stored with Git LFS because it exceeds GitHub's regular 100 MiB blob limit
  • release/source archives must be configured to include Git LFS objects before publishing the package
  • applications must add OnnxRuntime\Vendor::check to root Composer post-install and post-update scripts
  • prebuilt Linux ONNX Runtime artifacts require glibc; Alpine/musl needs a compatible custom runtime

Performance

Measured on an Apple M3 Pro with a 1280x837 JPEG cropped to 180x320:

  • first automatic crop: about 491 ms
  • warm automatic crop: about 405-438 ms
  • regular centered crop: about 14 ms
  • first-crop process RSS: about 550 MiB
  • long-running process RSS: about 766-768 MiB

Native ONNX Runtime and Imagick allocations are not fully represented by PHP's memory counter. This mode is best suited to asynchronous or controlled-concurrency image processing.

Testing

  • PHP 8.1 Docker: 58 tests, 323 assertions
  • PHP 8.2 Docker: 58 tests, 323 assertions
  • PHP 8.3 Docker: 58 tests, 323 assertions
  • vendor/bin/pint --test
  • PHPStan level max
  • composer validate --strict --no-check-publish
  • composer audit --locked

All validation passed. PHPUnit reports one existing deprecation.

@greptile-apps

greptile-apps Bot commented Jul 13, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a GRAVITY_AUTO crop mode to Image::crop() that uses a bundled U2NET ONNX saliency model to find the most visually salient region and position the crop window there, falling back to centered cropping when the saliency map is uniform. The integral-image sliding-window algorithm and center-proximity tiebreaking in findSalientCrop() are correct, and the PHPUnit tests (both mocked-saliency unit tests and a live-inference fixture test) cover the key edge cases well.

  • detectSaliency() preprocesses the image and runs U2NET inference via ankane/onnxruntime; the output is normalized to [0, 1] before the crop-window search.
  • findSalientCrop() builds a 2-D summed-area table and scores every candidate crop window in O(H×W) time, selecting the highest-scoring window nearest the image center on ties.
  • ankane/onnxruntime and ext-ffi are added to require (not suggest), making the ML inference stack a mandatory dependency for all consumers of the library.

Confidence Score: 3/5

The saliency crop feature works correctly for typical well-exposed images, but produces silently wrong model inputs for any image whose brightest pixel is below 255, and the hard ML runtime dependency in require is a breaking change for all library consumers.

The normalization in detectSaliency() divides by the image's own maximum pixel value instead of the fixed 255.0 that U2NET was trained with. For an underexposed image (max pixel ≈ 180), every channel value is inflated by 1.4× before mean subtraction, pushing the input distribution significantly off the model's training range — silently producing degraded or wrong saliency maps with no error or warning. This affects a meaningful class of real images and is a straightforward fix.

Files Needing Attention: src/Image/Image.php — the normalization divisor in detectSaliency(); composer.json — the hard ML runtime dependency

Important Files Changed

Filename Overview
src/Image/Image.php Adds GRAVITY_AUTO constant + detectSaliency() / findSalientCrop() methods. The integral-image algorithm and score-with-tiebreak logic are correct, but the pixel normalization in detectSaliency() uses the image's own max pixel value instead of the fixed 255.0 divisor that U2NET was trained with, causing silent preprocessing errors for non-full-range images.
composer.json Adds ankane/onnxruntime and ext-ffi to hard require, making a heavy ML inference stack mandatory for all consumers of this library even when focus/auto-cropping is never used.
tests/Image/ImageTest.php Adds well-structured tests for GRAVITY_AUTO: unit tests mock detectSaliency to verify saliency-guided crop positioning, flat-saliency centering, and equal-score tie-breaking; one integration test exercises real U2NET inference against the kitten fixture.
Dockerfile-php-8.3 Switches from appwrite/utopia-base Alpine image to php:8.3-cli-bullseye with manual ImageMagick copy and FFI extension install; changes composer update to reproducible composer install.
resources/models/u2net.onnx 176 MB U2NET model added via Git LFS; provenance documented in NOTICE.md with MD5, SHA-256, upstream source, and Apache 2.0 license attribution.

Reviews (3): Last reviewed commit: "feat: add automatic saliency cropping" | Re-trigger Greptile

Comment thread src/Image/Image.php Outdated
Comment on lines +269 to +286
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Hard failure when inference runtime is unavailable

If pipeline() or the pipeline invocation throws (e.g., missing or incompatible ONNX Runtime, model not yet downloaded), the exception propagates straight through crop() and kills the entire image operation. Because the focus feature is additive and the caller already supplies a gravity fallback, users likely expect the method to fall back to gravity rather than crash. The PR notes this explicitly as a design choice, but it will cause unhandled exceptions for any caller on Alpine/musl or any environment where the binary artifacts have not been pre-downloaded, even if they are fine with a gravity-based result.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Image/Image.php
Line: 269-286

Comment:
**Hard failure when inference runtime is unavailable**

If `pipeline()` or the pipeline invocation throws (e.g., missing or incompatible ONNX Runtime, model not yet downloaded), the exception propagates straight through `crop()` and kills the entire image operation. Because the `focus` feature is additive and the caller already supplies a gravity fallback, users likely expect the method to fall back to gravity rather than crash. The PR notes this explicitly as a design choice, but it will cause unhandled exceptions for any caller on Alpine/musl or any environment where the binary artifacts have not been pre-downloaded, even if they are fine with a gravity-based result.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

Comment thread src/Image/Image.php Outdated
{
self::$focusDetector ??= pipeline('zero-shot-object-detection');

$path = tempnam(sys_get_temp_dir(), 'utopia-image-focus-');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 tempnam prefix truncated on Windows

The prefix 'utopia-image-focus-' is 19 characters. PHP's tempnam() documentation states that on Windows only the first 3 characters of the prefix are used, so the created file gets the prefix uto instead. Keeping the prefix at 5 characters or fewer would work reliably on all platforms.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Image/Image.php
Line: 273

Comment:
**`tempnam` prefix truncated on Windows**

The prefix `'utopia-image-focus-'` is 19 characters. PHP's `tempnam()` documentation states that on Windows only the first 3 characters of the prefix are used, so the created file gets the prefix `uto` instead. Keeping the prefix at 5 characters or fewer would work reliably on all platforms.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

@TorstenDittmann TorstenDittmann changed the title Add semantic focus cropping Add automatic saliency cropping Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant