{comparison.title}
+{comparison.description}
+diff --git a/.dockerignore b/.dockerignore
index dc3dbed..83a9633 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -2,3 +2,4 @@
autogravity
models/*.onnx
!models/u2net-int8.onnx
+!models/face_detection_yunet_2023mar.onnx
diff --git a/.gitignore b/.gitignore
index 667f415..be2f710 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
/autogravity
/models/*.onnx
!/models/u2net-int8.onnx
+!/models/face_detection_yunet_2023mar.onnx
/docs/node_modules
/docs/.output
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index dff9085..b5beae9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -11,7 +11,7 @@ the common development tasks:
```sh
mise install
mise run ci # formatting, vet, race-enabled tests, and build
-mise run model # download and verify U²-Net
+mise run model # download and verify the ONNX models
```
GitHub Actions runs `mise run ci` and validates the Docker image for both
@@ -34,19 +34,19 @@ Additional natural photographs cover a dog low in a portrait and two puppies in
grass. A licensed panda eating bamboo is also included as a regression for a
previously reported failure with a similar image.
-To also run the real U²-Net model through the HTTP handler:
+To also run the real YuNet and U²-Net models:
```sh
export ONNXRUNTIME_LIB=/absolute/path/to/libonnxruntime.dylib
make test-integration
```
-This downloads and verifies the model, then runs race-enabled tests including
-subject-location checks, mirrored-image consistency, and equivalent raw and
-multipart results. The integration suite requires a working runtime and model;
-it fails rather than silently skipping when they are missing. GitHub Actions
-installs the pinned runtime and runs this suite on every pull request and push
-to `main`. Default tests need neither the runtime nor network access.
+This downloads and verifies the models, then runs race-enabled tests including
+face and subject-location checks, mirrored-image consistency, and equivalent
+raw and multipart results. The integration suite requires a working runtime and
+models; it fails rather than silently skipping when they are missing. GitHub
+Actions installs the pinned runtime and runs this suite on every pull request
+and push to `main`. Default tests need neither the runtime nor network access.
Integration tests follow `MODEL_PRECISION` (default `int8`) and honor explicit
`MODEL_PATH` overrides. `make test-integration-fp32` tests the FP32 environment
@@ -110,8 +110,10 @@ reduce single-request latency when more CPU cores are available.
```text
cmd/autogravity/ HTTP server and lifecycle
+internal/facedetection/ YuNet preprocessing, inference, and face selection
internal/imageutil/ decoding, EXIF orientation, resize, normalization
+internal/ortenv/ shared ONNX Runtime lifecycle
internal/saliency/ ONNX Runtime model session and inference
internal/gravity/ saliency-weighted focal-point calculation
-models/ local model location (ONNX files are gitignored)
+models/ checked-in and downloaded model artifacts and licenses
```
diff --git a/Dockerfile b/Dockerfile
index 559705e..5ed917f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -19,8 +19,10 @@ RUN mkdir -p /opt/onnxruntime \
ADD --checksum=sha256:8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491 --chmod=0444 \
https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx /opt/models/u2net.onnx
COPY models/u2net-int8.onnx /opt/models/u2net-int8.onnx
-COPY models/README.md models/U2NET_LICENSE /opt/models/
+COPY models/face_detection_yunet_2023mar.onnx /opt/models/face_detection_yunet_2023mar.onnx
+COPY models/README.md models/U2NET_LICENSE models/YUNET_LICENSE /opt/models/
RUN echo "b340186f56660b6665e494aab912e5f8e9adbc2317181c77fd01aa226f06553b /opt/models/u2net-int8.onnx" | sha256sum -c -
+RUN echo "8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4 /opt/models/face_detection_yunet_2023mar.onnx" | sha256sum -c -
WORKDIR /src
COPY go.mod go.sum ./
diff --git a/Makefile b/Makefile
index 70ca895..d46cc1c 100644
--- a/Makefile
+++ b/Makefile
@@ -1,11 +1,13 @@
FP32_MODEL_PATH := models/u2net.onnx
FP32_MODEL_URL := https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx
FP32_MODEL_SHA256 := 8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491
+FACE_MODEL_PATH := models/face_detection_yunet_2023mar.onnx
+FACE_MODEL_SHA256 := 8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4
VERSION ?= dev
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT)
-.PHONY: build run test test-integration test-integration-fp32 evaluate model model-fp32 model-int8
+.PHONY: build run test test-integration test-integration-fp32 evaluate model model-fp32 model-int8 model-face
build:
go build -ldflags="$(LDFLAGS)" -o autogravity ./cmd/autogravity
@@ -19,17 +21,20 @@ test:
test-integration: model
go test -race -tags=integration ./...
-test-integration-fp32: model-fp32
+test-integration-fp32: model-fp32 model-face
MODEL_PATH= MODEL_PRECISION=fp32 go test -race -tags=integration ./...
evaluate: model
go test -tags=integration,evaluation -run TestEvaluateDifficultScenes -v ./cmd/autogravity
-model: model-fp32 model-int8
+model: model-fp32 model-int8 model-face
model-int8:
@echo "b340186f56660b6665e494aab912e5f8e9adbc2317181c77fd01aa226f06553b models/u2net-int8.onnx" | shasum -a 256 -c
+model-face:
+ @echo "$(FACE_MODEL_SHA256) $(FACE_MODEL_PATH)" | shasum -a 256 -c
+
model-fp32:
@if [ ! -f "$(FP32_MODEL_PATH)" ]; then \
curl -fL --retry 3 -o "$(FP32_MODEL_PATH)" "$(FP32_MODEL_URL)"; \
diff --git a/README.md b/README.md
index 161b4a1..f82e111 100644
--- a/README.md
+++ b/README.md
@@ -2,16 +2,18 @@
# autogravity
-`autogravity` is a small Go HTTP service that finds the main visual subject in
-an image. It runs U²-Net with ONNX Runtime, selects the strongest connected
-salient region, and returns its weighted centroid as normalized X/Y coordinates.
-It never crops, stores, or modifies the submitted image.
+`autogravity` is a small Go HTTP service that finds the best crop focus in an
+image. It prioritizes a confidently detected face using YuNet, then falls back
+to the weighted centroid of the strongest U²-Net salient region. Results are
+normalized X/Y coordinates. It never crops, stores, identifies, or modifies the
+submitted image.
## Requirements
- Go 1.25 or newer
-- The included INT8 U²-Net model (approximately 42 MiB). `make model` verifies it
- and downloads/verifies the FP32 fallback (approximately 168 MiB).
+- The included INT8 U²-Net model (approximately 42 MiB) and YuNet face detector
+ (approximately 230 KiB). `make model` verifies them and downloads/verifies the
+ FP32 U²-Net fallback (approximately 168 MiB).
- An ONNX Runtime shared library. Version 1.23.2 is used by the Docker image and
matches the pinned Go binding.
@@ -39,6 +41,8 @@ The server listens on `:8080`. These environment variables are available:
| `ADDR` | `:8080` | HTTP listen address |
| `MODEL_PRECISION` | `int8` | `int8` or `fp32`, on every architecture |
| `MODEL_PATH` | unset | Explicit model path; overrides `MODEL_PRECISION` |
+| `FACE_MODEL_PATH` | `models/face_detection_yunet_2023mar.onnx` | YuNet face-detection model path |
+| `FACE_SCORE_THRESHOLD` | `0.85` | Minimum face confidence in `(0.0, 1.0]` |
| `ONNXRUNTIME_LIB` | required | Full ONNX Runtime shared-library path |
| `MAX_CONCURRENT_ANALYSES` | available CPUs | Maximum images decoded and inferred concurrently |
| `ONNX_INTRA_OP_THREADS` | `1` | CPU threads used within each ONNX operator |
@@ -72,14 +76,16 @@ Measured on September 6, 2026, with macOS 26.5.2 (arm64), 18 GiB RAM, Go 1.25.14
and ONNX Runtime 1.23.2. Each result is the median of five sequential benchmark
samples using `-benchtime=3s` and the checked-in synthetic images.
-Timings include decoding, orientation handling, resizing and normalization to
-320 × 320, inference, and focal-point calculation. They exclude model startup,
-file reads, uploads, and HTTP overhead. Performance varies with hardware and
-input images; see [benchmark instructions](CONTRIBUTING.md#benchmarks) to measure
-your environment.
+These historical timings measure the saliency fallback: decoding, orientation
+handling, resizing and normalization to 320 × 320, U²-Net inference, and
+focal-point calculation. Face-selected requests skip U²-Net and are typically
+substantially faster; every request still pays for the lightweight face check.
+Timings exclude model startup, file reads, uploads, and HTTP overhead.
+Performance varies with hardware and input images; see
+[benchmark instructions](CONTRIBUTING.md#benchmarks) to measure your environment.
By default, the server runs one analysis per effective CPU using one shared
-model session. With Go 1.25, this respects Linux container CPU limits through
+session per model. With Go 1.25, this respects Linux container CPU limits through
the runtime's container-aware `GOMAXPROCS` setting. ONNX Runtime uses one
intra-op thread per analysis, preventing its internal worker pool from
multiplying with request concurrency. Set explicit CPU and memory limits for
@@ -88,9 +94,10 @@ the tighter constraint.
## Docker
-The image bundles the verified INT8 model and downloads the verified FP32 model
-and CPU-only ONNX Runtime during the build. Both models are included on both
-`linux/amd64` and `linux/arm64`; selection is by environment, not architecture.
+The image bundles the verified INT8 and face models and downloads the verified
+FP32 model and CPU-only ONNX Runtime during the build. All models are included
+on `linux/amd64` and `linux/arm64`; U²-Net precision selection is by
+environment, not architecture.
```sh
docker build -t autogravity .
@@ -156,22 +163,31 @@ Example response:
"x": 0.68,
"y": 0.37
},
- "confidence": 0.91
+ "confidence": 0.91,
+ "source": "face"
}
```
Coordinates are in `[0.0, 1.0]`, measured from the oriented image's top-left
-corner. EXIF orientation is applied before analysis. Images are fitted within
-the model's 320x320 input using neutral padding, without stretching or
-cropping. Padding is excluded from the focal-point calculation. Pixels reaching
-at least half the peak activation are grouped into connected regions, and the
-region with the greatest total saliency supplies the focal point. Confidence is
-the peak activation in the model's fused saliency map, clamped to `[0.0, 1.0]`.
+corner. EXIF orientation is applied before analysis. A YuNet face at or above
+the configured score threshold has priority, with the most prominent face's
+bounding-box center supplying the focal point. If no reliable face is found,
+the image is fitted within U²-Net's 320x320 input using neutral padding, without
+stretching or cropping. Padding is excluded from the calculation. Pixels
+reaching at least half the peak activation are grouped into connected regions,
+and the region with the greatest total saliency supplies the fallback point.
+
+`source` is `face` or `saliency`. `confidence` is the selected model's score:
+YuNet's face score for `face`, or the peak fused-map activation for `saliency`.
+Scores are clamped to `[0.0, 1.0]` but are not calibrated probabilities and
+should not be compared across sources. Face detection does not perform identity
+recognition. Blurred, obscured, or highly stylized faces may use the saliency
+fallback.
Requests are limited to 10 MiB and decoded images to 20 megapixels. Separate
upload and analysis admission limits bound buffered-body and decoded-image
-memory without allowing slow uploads to reserve inference capacity. The model
-is loaded once at startup and its shared inference session is reused safely
+memory without allowing slow uploads to reserve inference capacity. Both models
+are loaded once at startup and their inference sessions are reused safely
across requests.
## Telemetry and shutdown
@@ -182,24 +198,27 @@ letters, digits, `_`, or `-` and is at most 64 characters. Logs never include
uploaded image data, filenames, query strings, or request headers.
Prometheus metrics are exposed at `/metrics`, including HTTP rates and latency,
-pipeline stage latency, active inference count, outcomes, cancellations, Go
-runtime statistics, process statistics, and build information. Keep this
-endpoint private at the ingress layer.
+pipeline stage latency, face-detection outcomes, selected gravity sources,
+active inference count, cancellations, Go runtime statistics, process
+statistics, and build information. Keep this endpoint private at the ingress
+layer.
Every analysis has a configurable deadline. Cancellation propagates into ONNX
Runtime and terminates that request's native inference without affecting other
concurrent requests. On SIGINT or SIGTERM, readiness becomes false immediately,
new analyses receive 503 with `Retry-After`, and active requests may finish for
`SHUTDOWN_TIMEOUT`. Once the grace period expires, their contexts are cancelled,
-native inference is terminated, connections are closed, and the model is then
-released. Set the orchestrator termination grace period longer than
+native inference is terminated, connections are closed, and the model sessions
+are then released. Set the orchestrator termination grace period longer than
`SHUTDOWN_TIMEOUT`.
## Model quality
-Full U²-Net fixes the person-in-room fixture previously missed by U²-NetP, but
-still misses two difficult scenes. See the [fixture evaluation](internal/testimages/testdata/README.md)
-for measured outputs and unchanged expected regions.
+YuNet prioritizes clear faces but intentionally falls back when its confidence
+is below the configured threshold. Full U²-Net fixes the person-in-room fixture
+previously missed by U²-NetP, but still misses two difficult scenes. See the
+[fixture evaluation](internal/testimages/testdata/README.md) for measured outputs
+and unchanged expected regions.
## Contributing
diff --git a/cmd/autogravity/integration_test.go b/cmd/autogravity/integration_test.go
index bda63b1..3419362 100644
--- a/cmd/autogravity/integration_test.go
+++ b/cmd/autogravity/integration_test.go
@@ -18,6 +18,7 @@ import (
"testing"
"time"
+ "autogravity/internal/facedetection"
"autogravity/internal/imageutil"
"autogravity/internal/saliency"
"autogravity/internal/testimages"
@@ -43,6 +44,124 @@ func TestAnalyzeRealModel(t *testing.T) {
})
}
+// facePriorityCase bounds are visually annotated around the intended primary
+// face before inference. Cases without a visible face exercise saliency fallback.
+type facePriorityCase struct {
+ name string
+ wantSource string
+ minX, maxX, minY, maxY float64
+}
+
+func TestAnalyzeGeneratedFacePriorityMatrix(t *testing.T) {
+ library := os.Getenv("ONNXRUNTIME_LIB")
+ if library == "" {
+ t.Fatal("integration tests require ONNXRUNTIME_LIB")
+ }
+ saliencyPath := os.Getenv("MODEL_PATH")
+ if saliencyPath == "" {
+ selected, err := configuredModelPath()
+ if err != nil {
+ t.Fatal(err)
+ }
+ saliencyPath = filepath.Join("..", "..", selected)
+ }
+ facePath := os.Getenv("FACE_MODEL_PATH")
+ if facePath == "" {
+ facePath = filepath.Join("..", "..", "models", "face_detection_yunet_2023mar.onnx")
+ }
+
+ saliencyModel, err := saliency.NewWithOptions(library, saliencyPath, saliency.Options{IntraOpThreads: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := saliencyModel.Close(); err != nil {
+ t.Error(err)
+ }
+ })
+ faceModel, err := facedetection.NewWithOptions(library, facePath, facedetection.Options{IntraOpThreads: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := faceModel.Close(); err != nil {
+ t.Error(err)
+ }
+ })
+ app := newApplicationWithFace(saliencyModel, faceModel, 2)
+
+ cases := []facePriorityCase{
+ {"generated-face-center.jpg", "face", 0.40, 0.60, 0.15, 0.40},
+ {"generated-face-left.jpg", "face", 0.16, 0.38, 0.10, 0.40},
+ {"generated-face-right.jpg", "face", 0.67, 0.88, 0.10, 0.42},
+ {"generated-face-three-quarter.jpg", "face", 0.24, 0.54, 0.14, 0.48},
+ {"generated-face-occluded.jpg", "face", 0.22, 0.74, 0.10, 0.52},
+ {"generated-face-low-light.jpg", "face", 0.43, 0.76, 0.10, 0.50},
+ {"generated-faces-primary.jpg", "face", 0.13, 0.44, 0.10, 0.48},
+ {"generated-no-face-landscape.jpg", "saliency", 0, 0, 0, 0},
+ {"generated-no-face-back-facing.jpg", "saliency", 0, 0, 0, 0},
+ {"generated-no-face-blurred.jpg", "saliency", 0, 0, 0, 0},
+ }
+
+ analyze := func(t *testing.T, name string, data []byte) analyzeResponse {
+ t.Helper()
+ response := httptest.NewRecorder()
+ app.handleAnalyze(response, fixtureRequest(t, testimages.Fixture{Name: name, ContentType: "image/jpeg"}, false, data))
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d: %s", response.Code, response.Body.String())
+ }
+ var result analyzeResponse
+ if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
+ t.Fatal(err)
+ }
+ return result
+ }
+
+ for _, test := range cases {
+ t.Run(test.name, func(t *testing.T) {
+ data := testimages.Read(t, test.name)
+ img, err := imageutil.Decode(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var flipped bytes.Buffer
+ if err := png.Encode(&flipped, imaging.FlipH(img)); err != nil {
+ t.Fatal(err)
+ }
+
+ original := analyze(t, test.name, data)
+ mirrored := analyze(t, "mirrored.png", flipped.Bytes())
+ t.Logf("original=%+v mirrored=%+v", original, mirrored)
+ for orientation, result := range map[string]analyzeResponse{
+ "original": original,
+ "mirrored": mirrored,
+ } {
+ if result.Source != test.wantSource {
+ t.Errorf("%s source = %q, want %q", orientation, result.Source, test.wantSource)
+ }
+ if result.Confidence < 0 || result.Confidence > 1 {
+ t.Errorf("%s confidence = %.4f", orientation, result.Confidence)
+ }
+ }
+ if test.wantSource != "face" {
+ return
+ }
+ if original.Gravity.X < test.minX || original.Gravity.X > test.maxX ||
+ original.Gravity.Y < test.minY || original.Gravity.Y > test.maxY {
+ t.Errorf("original primary face outside manually annotated region: %+v", original)
+ }
+ if mirrored.Gravity.X < 1-test.maxX || mirrored.Gravity.X > 1-test.minX ||
+ mirrored.Gravity.Y < test.minY || mirrored.Gravity.Y > test.maxY {
+ t.Errorf("mirrored primary face outside reflected annotation: %+v", mirrored)
+ }
+ if math.Abs(mirrored.Gravity.X-(1-original.Gravity.X)) > 0.04 ||
+ math.Abs(mirrored.Gravity.Y-original.Gravity.Y) > 0.04 {
+ t.Errorf("face selection is not reflection-consistent: original=%+v mirrored=%+v", original, mirrored)
+ }
+ })
+ }
+}
+
func TestConcurrentInferenceIsConsistent(t *testing.T) {
library := os.Getenv("ONNXRUNTIME_LIB")
if library == "" {
@@ -126,6 +245,46 @@ func TestConcurrentInferenceIsConsistent(t *testing.T) {
}
}
+func TestModelsShareRuntimeEnvironment(t *testing.T) {
+ library := os.Getenv("ONNXRUNTIME_LIB")
+ if library == "" {
+ t.Fatal("integration tests require ONNXRUNTIME_LIB")
+ }
+ saliencyPath := filepath.Join("..", "..", "models", "u2net-int8.onnx")
+ facePath := filepath.Join("..", "..", "models", "face_detection_yunet_2023mar.onnx")
+
+ saliencyModel, err := saliency.NewWithOptions(library, saliencyPath, saliency.Options{IntraOpThreads: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = saliencyModel.Close() })
+ faceModel, err := facedetection.NewWithOptions(library, facePath, facedetection.Options{IntraOpThreads: 1})
+ if err != nil {
+ _ = saliencyModel.Close()
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = faceModel.Close() })
+
+ img, err := imageutil.Decode(testimages.Read(t, "person-room.jpg"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if detections, err := faceModel.Detect(context.Background(), img); err != nil || len(detections) == 0 {
+ t.Fatalf("face detection = %+v, %v", detections, err)
+ }
+ if err := faceModel.Close(); err != nil {
+ t.Fatal(err)
+ }
+ // Closing one session must not unload the process-wide runtime while the
+ // saliency session still owns a reference.
+ if _, err := saliencyModel.Infer(context.Background(), make([]float32, 3*saliency.InputWidth*saliency.InputHeight)); err != nil {
+ t.Fatal(err)
+ }
+ if err := saliencyModel.Close(); err != nil {
+ t.Fatal(err)
+ }
+}
+
// Missing prerequisites fail explicitly so CI cannot silently skip real inference.
func runSubjectCases(t *testing.T, cases []subjectCase) {
t.Helper()
diff --git a/cmd/autogravity/main.go b/cmd/autogravity/main.go
index ac46507..743b01e 100644
--- a/cmd/autogravity/main.go
+++ b/cmd/autogravity/main.go
@@ -5,8 +5,10 @@ import (
"encoding/json"
"errors"
"fmt"
+ "image"
"io"
"log/slog"
+ "math"
"mime"
"net"
"net/http"
@@ -19,6 +21,7 @@ import (
"syscall"
"time"
+ "autogravity/internal/facedetection"
"autogravity/internal/gravity"
"autogravity/internal/imageutil"
"autogravity/internal/saliency"
@@ -28,6 +31,7 @@ const (
maxRequestBytes = 10 << 20 // 10 MiB, including multipart overhead.
maxConcurrentUploads = 4 // Bounds buffered bodies without reserving inference.
defaultIntraOpThreads = 1 // Avoid N requests multiplying ONNX worker threads.
+ defaultFaceModelPath = "models/face_detection_yunet_2023mar.onnx"
defaultAnalysisTimeout = 30 * time.Second
defaultShutdownTimeout = 30 * time.Second
)
@@ -43,8 +47,13 @@ type analyzer interface {
Infer(context.Context, []float32) ([]float32, error)
}
+type faceAnalyzer interface {
+ Detect(context.Context, image.Image) ([]facedetection.Detection, error)
+}
+
type application struct {
model analyzer
+ faceModel faceAnalyzer
uploadSlots chan struct{}
analysisSlots chan struct{}
inputBuffers chan []float32
@@ -56,6 +65,7 @@ type application struct {
type analyzeResponse struct {
Gravity gravity.Point `json:"gravity"`
Confidence float64 `json:"confidence"`
+ Source string `json:"source"`
}
type healthResponse struct {
@@ -81,6 +91,11 @@ func run() error {
return fmt.Errorf("startup: %w", err)
}
runtimePath := os.Getenv("ONNXRUNTIME_LIB")
+ faceModelPath := envOrDefault("FACE_MODEL_PATH", defaultFaceModelPath)
+ faceScoreThreshold, err := unitEnvFloat("FACE_SCORE_THRESHOLD", facedetection.DefaultScoreThreshold)
+ if err != nil {
+ return fmt.Errorf("startup: %w", err)
+ }
maxConcurrentAnalyses, err := positiveEnvInt("MAX_CONCURRENT_ANALYSES", runtime.GOMAXPROCS(0))
if err != nil {
return fmt.Errorf("startup: %w", err)
@@ -104,7 +119,7 @@ func run() error {
if err != nil {
return fmt.Errorf("startup: %w", err)
}
- slog.Info("model loaded", "path", modelPath, "architecture", runtime.GOARCH,
+ slog.Info("saliency model loaded", "path", modelPath, "architecture", runtime.GOARCH,
"analysis_slots", maxConcurrentAnalyses, "intra_op_threads", intraOpThreads)
defer func() {
if err := model.Close(); err != nil {
@@ -112,7 +127,21 @@ func run() error {
}
}()
- app := newApplication(model, maxConcurrentAnalyses)
+ faceModel, err := facedetection.NewWithOptions(runtimePath, faceModelPath, facedetection.Options{
+ ScoreThreshold: faceScoreThreshold,
+ IntraOpThreads: intraOpThreads,
+ })
+ if err != nil {
+ return fmt.Errorf("startup: %w", err)
+ }
+ slog.Info("face model loaded", "path", faceModelPath, "score_threshold", faceScoreThreshold)
+ defer func() {
+ if err := faceModel.Close(); err != nil {
+ slog.Error("failed to close face model", "error", err)
+ }
+ }()
+
+ app := newApplicationWithFace(model, faceModel, maxConcurrentAnalyses)
app.analysisTimeout = analysisTimeout
mux := http.NewServeMux()
mux.HandleFunc("/analyze", app.handleAnalyze)
@@ -282,7 +311,7 @@ func (app *application) handleAnalyze(w http.ResponseWriter, r *http.Request) {
<-app.uploadSlots
uploadSlotHeld = false
- preprocessStarted := time.Now()
+ decodeStarted := time.Now()
img, err := imageutil.Decode(data)
if err != nil {
switch {
@@ -296,7 +325,36 @@ func (app *application) handleAnalyze(w http.ResponseWriter, r *http.Request) {
outcome = "invalid_image"
return
}
+ app.telemetry.stageDuration.WithLabelValues("decode").Observe(time.Since(decodeStarted).Seconds())
+
+ if app.faceModel != nil {
+ faceStarted := time.Now()
+ app.telemetry.inferenceActive.Inc()
+ faces, faceErr := app.faceModel.Detect(ctx, img)
+ app.telemetry.inferenceActive.Dec()
+ app.telemetry.stageDuration.WithLabelValues("face_detection").Observe(time.Since(faceStarted).Seconds())
+ if ctx.Err() != nil {
+ observeCancellation()
+ writeContextError(w, ctx)
+ return
+ }
+ if faceErr != nil {
+ app.telemetry.faceDetectionOutcomes.WithLabelValues("error").Inc()
+ slog.Error("face detection failed; using saliency fallback", "error", faceErr)
+ } else if face, ok := facedetection.Primary(faces); ok {
+ app.telemetry.faceDetectionOutcomes.WithLabelValues("selected").Inc()
+ x, y := face.Center()
+ app.telemetry.gravitySources.WithLabelValues("face").Inc()
+ writeJSON(w, http.StatusOK, analyzeResponse{
+ Gravity: gravity.Point{X: x, Y: y}, Confidence: face.Confidence, Source: "face",
+ })
+ return
+ } else {
+ app.telemetry.faceDetectionOutcomes.WithLabelValues("none").Inc()
+ }
+ }
+ preprocessStarted := time.Now()
var input []float32
select {
case input = <-app.inputBuffers:
@@ -357,12 +415,18 @@ func (app *application) handleAnalyze(w http.ResponseWriter, r *http.Request) {
return
}
- writeJSON(w, http.StatusOK, analyzeResponse{Gravity: point, Confidence: confidence})
+ app.telemetry.gravitySources.WithLabelValues("saliency").Inc()
+ writeJSON(w, http.StatusOK, analyzeResponse{Gravity: point, Confidence: confidence, Source: "saliency"})
}
func newApplication(model analyzer, maxConcurrentAnalyses int) *application {
+ return newApplicationWithFace(model, nil, maxConcurrentAnalyses)
+}
+
+func newApplicationWithFace(model analyzer, faceModel faceAnalyzer, maxConcurrentAnalyses int) *application {
return &application{
model: model,
+ faceModel: faceModel,
uploadSlots: make(chan struct{}, maxConcurrentUploads),
analysisSlots: make(chan struct{}, maxConcurrentAnalyses),
inputBuffers: make(chan []float32, maxConcurrentAnalyses),
@@ -470,3 +534,15 @@ func positiveEnvDuration(key string, fallback time.Duration) (time.Duration, err
}
return parsed, nil
}
+
+func unitEnvFloat(key string, fallback float64) (float64, error) {
+ value := strings.TrimSpace(os.Getenv(key))
+ if value == "" {
+ return fallback, nil
+ }
+ parsed, err := strconv.ParseFloat(value, 64)
+ if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed <= 0 || parsed > 1 {
+ return 0, fmt.Errorf("%s must be greater than zero and at most one, got %q", key, value)
+ }
+ return parsed, nil
+}
diff --git a/cmd/autogravity/main_test.go b/cmd/autogravity/main_test.go
index 9d4e55f..68242a1 100644
--- a/cmd/autogravity/main_test.go
+++ b/cmd/autogravity/main_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"image"
"image/color"
"image/png"
@@ -16,6 +17,7 @@ import (
"testing"
"time"
+ "autogravity/internal/facedetection"
"autogravity/internal/saliency"
)
@@ -27,6 +29,12 @@ type fakeAnalyzer struct {
type contextAnalyzer struct{}
+type faceAnalyzerFunc func(context.Context, image.Image) ([]facedetection.Detection, error)
+
+func (f faceAnalyzerFunc) Detect(ctx context.Context, img image.Image) ([]facedetection.Detection, error) {
+ return f(ctx, img)
+}
+
func (contextAnalyzer) Infer(ctx context.Context, _ []float32) ([]float32, error) {
<-ctx.Done()
return nil, ctx.Err()
@@ -67,6 +75,30 @@ func TestConfiguredModelPath(t *testing.T) {
}
}
+func TestUnitEnvFloat(t *testing.T) {
+ tests := []struct {
+ name, value string
+ want float64
+ invalid bool
+ }{
+ {name: "default", want: 0.85},
+ {name: "configured", value: "0.65", want: 0.65},
+ {name: "zero", value: "0", invalid: true},
+ {name: "above one", value: "1.01", invalid: true},
+ {name: "NaN", value: "NaN", invalid: true},
+ {name: "not a number", value: "nope", invalid: true},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ t.Setenv("FACE_SCORE_THRESHOLD", test.value)
+ got, err := unitEnvFloat("FACE_SCORE_THRESHOLD", 0.85)
+ if (err != nil) != test.invalid || got != test.want {
+ t.Fatalf("unitEnvFloat() = %v, %v; want %v, invalid=%v", got, err, test.want, test.invalid)
+ }
+ })
+ }
+}
+
type blockingReader struct {
started chan struct{}
release chan struct{}
@@ -138,6 +170,100 @@ func TestHandleAnalyzeRawImage(t *testing.T) {
if body.Confidence < 0 || body.Confidence > 1 {
t.Fatalf("confidence is not normalized: %v", body.Confidence)
}
+ if body.Source != "saliency" {
+ t.Fatalf("source = %q, want saliency", body.Source)
+ }
+}
+
+func TestHandleAnalyzePrioritizesFace(t *testing.T) {
+ saliencyCalls := 0
+ model := analyzerFunc(func([]float32) ([]float32, error) {
+ saliencyCalls++
+ return nil, errors.New("saliency should not run")
+ })
+ face := facedetection.Detection{
+ Bounds: facedetection.Box{MinX: 0.3, MinY: 0.1, MaxX: 0.7, MaxY: 0.5},
+ Confidence: 0.93,
+ }
+ faces := faceAnalyzerFunc(func(context.Context, image.Image) ([]facedetection.Detection, error) {
+ return []facedetection.Detection{face}, nil
+ })
+ app := newApplicationWithFace(model, faces, 2)
+ request := httptest.NewRequest(http.MethodPost, "/analyze", bytes.NewReader(testPNG(t)))
+ request.Header.Set("Content-Type", "image/png")
+ response := httptest.NewRecorder()
+
+ app.handleAnalyze(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d: %s", response.Code, response.Body.String())
+ }
+ var body analyzeResponse
+ if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if body.Gravity.X != 0.5 || body.Gravity.Y != 0.3 {
+ t.Fatalf("gravity = %+v, want face center", body.Gravity)
+ }
+ if body.Confidence != face.Confidence || body.Source != "face" {
+ t.Fatalf("response = %+v, want face source", body)
+ }
+ if saliencyCalls != 0 {
+ t.Fatalf("saliency calls = %d, want zero", saliencyCalls)
+ }
+}
+
+func TestHandleAnalyzeUsesSaliencyFallback(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ faces faceAnalyzerFunc
+ }{
+ {
+ name: "no face",
+ faces: func(context.Context, image.Image) ([]facedetection.Detection, error) {
+ return nil, nil
+ },
+ },
+ {
+ name: "face detector error",
+ faces: func(context.Context, image.Image) ([]facedetection.Detection, error) {
+ return nil, errors.New("private face detector error")
+ },
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ saliencyCalls := 0
+ model := analyzerFunc(func([]float32) ([]float32, error) {
+ saliencyCalls++
+ result := make([]float32, saliency.InputWidth*saliency.InputHeight)
+ result[len(result)/2] = 0.75
+ return result, nil
+ })
+ app := newApplicationWithFace(model, test.faces, 2)
+ request := httptest.NewRequest(http.MethodPost, "/analyze", bytes.NewReader(testPNG(t)))
+ request.Header.Set("Content-Type", "image/png")
+ response := httptest.NewRecorder()
+
+ app.handleAnalyze(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d: %s", response.Code, response.Body.String())
+ }
+ var body analyzeResponse
+ if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if body.Source != "saliency" || body.Confidence != 0.75 {
+ t.Fatalf("response = %+v, want saliency fallback", body)
+ }
+ if saliencyCalls != 1 {
+ t.Fatalf("saliency calls = %d, want one", saliencyCalls)
+ }
+ if bytes.Contains(response.Body.Bytes(), []byte("private face detector error")) {
+ t.Fatal("private face detector error exposed")
+ }
+ })
+ }
}
func TestHandleAnalyzeMultipartImage(t *testing.T) {
@@ -282,6 +408,30 @@ func TestHandleAnalyzeDeadlineCancelsInference(t *testing.T) {
}
}
+func TestHandleAnalyzeDeadlineCancelsFaceDetection(t *testing.T) {
+ faces := faceAnalyzerFunc(func(ctx context.Context, _ image.Image) ([]facedetection.Detection, error) {
+ <-ctx.Done()
+ return nil, ctx.Err()
+ })
+ app := newApplicationWithFace(analyzerFunc(func([]float32) ([]float32, error) {
+ t.Fatal("saliency ran after face-detection timeout")
+ return nil, nil
+ }), faces, 1)
+ app.analysisTimeout = 10 * time.Millisecond
+ request := httptest.NewRequest(http.MethodPost, "/analyze", bytes.NewReader(testPNG(t)))
+ request.Header.Set("Content-Type", "image/png")
+ response := httptest.NewRecorder()
+
+ app.handleAnalyze(response, request)
+
+ if response.Code != http.StatusGatewayTimeout {
+ t.Fatalf("status = %d, want 504; body = %s", response.Code, response.Body.String())
+ }
+ if len(app.uploadSlots) != 0 || len(app.analysisSlots) != 0 {
+ t.Fatal("admission slots leaked after face-detection cancellation")
+ }
+}
+
func TestHandleAnalyzeChecksCancellationAfterInference(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
app := newApplication(cancelAfterAnalyzer{cancel: cancel}, 1)
diff --git a/cmd/autogravity/telemetry.go b/cmd/autogravity/telemetry.go
index 189869e..42aa6c0 100644
--- a/cmd/autogravity/telemetry.go
+++ b/cmd/autogravity/telemetry.go
@@ -13,14 +13,16 @@ import (
)
type telemetry struct {
- registry *prometheus.Registry
- httpRequests *prometheus.CounterVec
- httpDuration *prometheus.HistogramVec
- httpActive prometheus.Gauge
- stageDuration *prometheus.HistogramVec
- analyses *prometheus.CounterVec
- inferenceActive prometheus.Gauge
- cancellations *prometheus.CounterVec
+ registry *prometheus.Registry
+ httpRequests *prometheus.CounterVec
+ httpDuration *prometheus.HistogramVec
+ httpActive prometheus.Gauge
+ stageDuration *prometheus.HistogramVec
+ analyses *prometheus.CounterVec
+ inferenceActive prometheus.Gauge
+ cancellations *prometheus.CounterVec
+ faceDetectionOutcomes *prometheus.CounterVec
+ gravitySources *prometheus.CounterVec
}
func newTelemetry(modelPrecision string, maxConcurrentAnalyses int) *telemetry {
@@ -49,15 +51,24 @@ func newTelemetry(modelPrecision string, maxConcurrentAnalyses int) *telemetry {
}, []string{"outcome"}),
inferenceActive: prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: "autogravity", Name: "inference_active",
- Help: "ONNX inference calls currently running.",
+ Help: "Model inference pipelines currently running.",
}),
cancellations: prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "autogravity", Name: "cancellations_total",
Help: "Cancelled analyses by reason.",
}, []string{"reason"}),
+ faceDetectionOutcomes: prometheus.NewCounterVec(prometheus.CounterOpts{
+ Namespace: "autogravity", Name: "face_detection_total",
+ Help: "Face-detection attempts by outcome.",
+ }, []string{"outcome"}),
+ gravitySources: prometheus.NewCounterVec(prometheus.CounterOpts{
+ Namespace: "autogravity", Name: "gravity_sources_total",
+ Help: "Successful analyses by selected gravity source.",
+ }, []string{"source"}),
}
registry.MustRegister(t.httpRequests, t.httpDuration, t.httpActive, t.stageDuration,
- t.analyses, t.inferenceActive, t.cancellations, prometheus.NewGoCollector(),
+ t.analyses, t.inferenceActive, t.cancellations, t.faceDetectionOutcomes,
+ t.gravitySources, prometheus.NewGoCollector(),
prometheus.NewProcessCollector(prometheus.ProcessCollectorOpts{}))
registry.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Namespace: "autogravity", Name: "build_info", Help: "Build and model configuration.",
diff --git a/docs/public/face-priority/blurred-face.jpg b/docs/public/face-priority/blurred-face.jpg
new file mode 100644
index 0000000..799ead8
Binary files /dev/null and b/docs/public/face-priority/blurred-face.jpg differ
diff --git a/docs/public/face-priority/clear-portrait.jpg b/docs/public/face-priority/clear-portrait.jpg
new file mode 100644
index 0000000..be4169b
Binary files /dev/null and b/docs/public/face-priority/clear-portrait.jpg differ
diff --git a/docs/public/face-priority/multiple-faces.jpg b/docs/public/face-priority/multiple-faces.jpg
new file mode 100644
index 0000000..4e6a8f5
Binary files /dev/null and b/docs/public/face-priority/multiple-faces.jpg differ
diff --git a/docs/src/components/FacePriorityDemo.tsx b/docs/src/components/FacePriorityDemo.tsx
new file mode 100644
index 0000000..71ff509
--- /dev/null
+++ b/docs/src/components/FacePriorityDemo.tsx
@@ -0,0 +1,259 @@
+const centerPortrait = '/face-priority/clear-portrait.jpg'
+const multiplePortraits = '/face-priority/multiple-faces.jpg'
+const blurredPortrait = '/face-priority/blurred-face.jpg'
+
+type Tone = 'before' | 'face' | 'fallback'
+
+type Point = {
+ x: number
+ y: number
+}
+
+type FaceBox = {
+ minX: number
+ minY: number
+ maxX: number
+ maxY: number
+ primary?: boolean
+}
+
+type Frame = {
+ phase: 'Before' | 'After'
+ strategy: string
+ source: 'saliency' | 'face'
+ point: Point
+ confidence: number
+ tone: Tone
+ alt: string
+ faces?: FaceBox[]
+}
+
+type Comparison = {
+ id: string
+ image: string
+ eyebrow: string
+ title: string
+ description: string
+ result: string
+ before: Frame
+ after: Frame
+}
+
+const comparisons: Comparison[] = [
+ {
+ id: 'clear-portrait',
+ image: centerPortrait,
+ eyebrow: 'Clear portrait',
+ title: 'A face outranks clothing',
+ description:
+ 'Saliency settles on the subject’s torso. Face priority moves the crop anchor 34% of the image height upward.',
+ result: '−33.7% y',
+ before: {
+ phase: 'Before',
+ strategy: 'Saliency only',
+ source: 'saliency',
+ point: { x: 0.4760418385, y: 0.6078201235 },
+ confidence: 1,
+ tone: 'before',
+ alt: 'Centered portrait with the saliency-only focal point on the subject’s torso',
+ },
+ after: {
+ phase: 'After',
+ strategy: 'Face priority',
+ source: 'face',
+ point: { x: 0.4849133417, y: 0.2705245813 },
+ confidence: 0.9316979048,
+ tone: 'face',
+ alt: 'The same centered portrait with the face-priority focal point on the subject’s face',
+ faces: [
+ {
+ minX: 0.4216492542,
+ minY: 0.1171214063,
+ maxX: 0.5481774292,
+ maxY: 0.4239277563,
+ primary: true,
+ },
+ ],
+ },
+ },
+ {
+ id: 'multiple-faces',
+ image: multiplePortraits,
+ eyebrow: 'Multiple faces',
+ title: 'The most prominent face wins',
+ description:
+ 'Both faces clear the threshold. Area-weighted priority selects the larger foreground face.',
+ result: '2 faces detected',
+ before: {
+ phase: 'Before',
+ strategy: 'Saliency only',
+ source: 'saliency',
+ point: { x: 0.3159505554, y: 0.6147700424 },
+ confidence: 1,
+ tone: 'before',
+ alt: 'Portrait with two people and the saliency-only focal point on the foreground subject’s torso',
+ },
+ after: {
+ phase: 'After',
+ strategy: 'Face priority',
+ source: 'face',
+ point: { x: 0.318769598, y: 0.3367853218 },
+ confidence: 0.9424964754,
+ tone: 'face',
+ alt: 'The same two-person portrait with both faces detected and the foreground face selected',
+ faces: [
+ {
+ minX: 0.233408108,
+ minY: 0.1396942124,
+ maxX: 0.404131088,
+ maxY: 0.5338764311,
+ primary: true,
+ },
+ {
+ minX: 0.7376734428,
+ minY: 0.2746350121,
+ maxX: 0.8044086017,
+ maxY: 0.4290162492,
+ },
+ ],
+ },
+ },
+ {
+ id: 'blurred-face',
+ image: blurredPortrait,
+ eyebrow: 'No reliable face',
+ title: 'Fallback stays intact',
+ description:
+ 'A strongly blurred face does not pass the detector threshold, so U²-Net supplies the same saliency point as before.',
+ result: '0 faces detected',
+ before: {
+ phase: 'Before',
+ strategy: 'Saliency only',
+ source: 'saliency',
+ point: { x: 0.4884092059, y: 0.6575083137 },
+ confidence: 1,
+ tone: 'before',
+ alt: 'Portrait with a strongly blurred face and its saliency focal point',
+ },
+ after: {
+ phase: 'After',
+ strategy: 'Saliency fallback',
+ source: 'saliency',
+ point: { x: 0.4884092059, y: 0.6575083137 },
+ confidence: 1,
+ tone: 'fallback',
+ alt: 'The same blurred portrait with the unchanged saliency fallback point',
+ },
+ },
+]
+
+function percentage(value: number) {
+ return `${value * 100}%`
+}
+
+function coordinate(value: number) {
+ return value.toFixed(4)
+}
+
+function FaceBounds({ box }: { box: FaceBox }) {
+ return (
+
+ )
+}
+
+function FocusMarker({ point, tone }: { point: Point; tone: Tone }) {
+ return (
+
+ )
+}
+
+function ComparisonFrame({ image, frame }: { image: string; frame: Frame }) {
+ return (
+
+ {frame.faces?.map((face, index) => (
+
{comparison.description}
+- A small Go HTTP service that finds the main visual subject in an - image. It runs U²-Net with ONNX Runtime and returns the - weighted centroid of the strongest salient region as normalized X/Y coordinates. It never - crops, stores, or modifies the submitted image. + A small Go HTTP service that finds the best crop focus in an image. + It prioritizes confidently detected faces with YuNet, then falls + back to U²-Net saliency. It never crops, stores, identifies, or + modifies the submitted image.
+ Green boxes are detected faces, the thicker box is the selected + primary face, and each crosshair is the returned normalized gravity + coordinate. No identity recognition is performed. +
+
@@ -69,7 +84,7 @@ function DocsPage() {
Or build from source with Go 1.25 or newer.{' '}
make model downloads and verifies the ONNX
- model.
+ models.
@@ -97,9 +112,19 @@ function DocsPage() {
MODEL_PATH
- models/u2net.onnx
+ unset
U²-Net model path
+
+ FACE_MODEL_PATH
+ models/face_detection_yunet_2023mar.onnx
+ YuNet model path
+
+
+ FACE_SCORE_THRESHOLD
+ 0.85
+ Minimum reliable face score
+
ONNXRUNTIME_LIB
required
@@ -120,7 +145,7 @@ function DocsPage() {
@@ -141,7 +166,9 @@ function DocsPage() {
0.37{'\n'}
{' '}{'}'},{'\n'}
{' '}"confidence":{' '}
- 0.91{'\n'}
+ 0.91,{'\n'}
+ {' '}"source":{' '}
+ "face"{'\n'}
{'}'}
@@ -182,17 +209,18 @@ function DocsPage() {
Coordinates are in [0.0, 1.0] , measured
from the oriented image's top-left corner. EXIF orientation is
- applied before analysis. Images are fitted within the model's
- 320×320 input using neutral padding, without stretching or cropping.
- Confidence is the peak activation in the model's fused saliency
- map.
+ applied before analysis. A reliable face supplies its bounding-box
+ center; otherwise U²-Net supplies the saliency centroid. The{' '}
+ source field identifies which strategy was
+ selected. Confidence is that strategy's model score, not an
+ identity match or a calibrated probability.
@@ -202,8 +230,8 @@ function DocsPage() {
Requests are limited to 10 MiB and decoded images to 20 megapixels.
Separate upload and analysis admission limits bound buffered-body and
decoded-image memory without allowing slow uploads to reserve
- inference capacity. The model is loaded once at startup and its
- shared inference session is reused across requests.
+ inference capacity. Both models are loaded once at startup and their
+ inference sessions are reused across requests.
@@ -227,9 +255,10 @@ function DocsPage() {
- Apple M3 Pro, CPU-only ONNX Runtime 1.23.2, Go 1.25.14. Median of
- five sequential benchmark samples. Performance varies with hardware
- and input images.
+ Historical U²-Net fallback timings on Apple M3 Pro, CPU-only ONNX
+ Runtime 1.23.2, Go 1.25.14. Median of five sequential benchmark
+ samples. Face-selected requests skip U²-Net. Performance varies with
+ hardware and input images.
| face | 0.40–0.60; 0.15–0.40 | Baseline centered portrait |
+|
| face | 0.16–0.38; 0.10–0.40 | Left placement and negative space |
+|
| face | 0.67–0.88; 0.10–0.42 | Right placement and negative space |
+|
| face | 0.24–0.54; 0.14–0.48 | Non-frontal head angle |
+|
| face | 0.22–0.74; 0.10–0.52 | Glasses, hat, and minor chin occlusion |
+|
| face | 0.43–0.76; 0.10–0.50 | Low light and uneven illumination |
+|
| face | 0.13–0.44; 0.10–0.48 | Primary-face selection by prominence |
+|
| saliency | — | No-person false-positive control |
+|
| saliency | — | Person present without a visible face |
+|
| saliency | — | Anonymized-face fallback matching the reported case |
+
+All depicted people are generated, fictional adults. These fixtures test face
+detection only; they are not used for identity recognition or biometric matching.
## Panda regression
diff --git a/internal/testimages/testdata/generated-face-center.jpg b/internal/testimages/testdata/generated-face-center.jpg
new file mode 100644
index 0000000..be4169b
Binary files /dev/null and b/internal/testimages/testdata/generated-face-center.jpg differ
diff --git a/internal/testimages/testdata/generated-face-left.jpg b/internal/testimages/testdata/generated-face-left.jpg
new file mode 100644
index 0000000..e9294b5
Binary files /dev/null and b/internal/testimages/testdata/generated-face-left.jpg differ
diff --git a/internal/testimages/testdata/generated-face-low-light.jpg b/internal/testimages/testdata/generated-face-low-light.jpg
new file mode 100644
index 0000000..7980fe5
Binary files /dev/null and b/internal/testimages/testdata/generated-face-low-light.jpg differ
diff --git a/internal/testimages/testdata/generated-face-occluded.jpg b/internal/testimages/testdata/generated-face-occluded.jpg
new file mode 100644
index 0000000..2a1539b
Binary files /dev/null and b/internal/testimages/testdata/generated-face-occluded.jpg differ
diff --git a/internal/testimages/testdata/generated-face-right.jpg b/internal/testimages/testdata/generated-face-right.jpg
new file mode 100644
index 0000000..f1bd1d1
Binary files /dev/null and b/internal/testimages/testdata/generated-face-right.jpg differ
diff --git a/internal/testimages/testdata/generated-face-three-quarter.jpg b/internal/testimages/testdata/generated-face-three-quarter.jpg
new file mode 100644
index 0000000..9035c91
Binary files /dev/null and b/internal/testimages/testdata/generated-face-three-quarter.jpg differ
diff --git a/internal/testimages/testdata/generated-faces-primary.jpg b/internal/testimages/testdata/generated-faces-primary.jpg
new file mode 100644
index 0000000..4e6a8f5
Binary files /dev/null and b/internal/testimages/testdata/generated-faces-primary.jpg differ
diff --git a/internal/testimages/testdata/generated-no-face-back-facing.jpg b/internal/testimages/testdata/generated-no-face-back-facing.jpg
new file mode 100644
index 0000000..10de7cc
Binary files /dev/null and b/internal/testimages/testdata/generated-no-face-back-facing.jpg differ
diff --git a/internal/testimages/testdata/generated-no-face-blurred.jpg b/internal/testimages/testdata/generated-no-face-blurred.jpg
new file mode 100644
index 0000000..799ead8
Binary files /dev/null and b/internal/testimages/testdata/generated-no-face-blurred.jpg differ
diff --git a/internal/testimages/testdata/generated-no-face-landscape.jpg b/internal/testimages/testdata/generated-no-face-landscape.jpg
new file mode 100644
index 0000000..e421974
Binary files /dev/null and b/internal/testimages/testdata/generated-no-face-landscape.jpg differ
diff --git a/models/README.md b/models/README.md
index 4c12aec..4aab5c8 100644
--- a/models/README.md
+++ b/models/README.md
@@ -27,3 +27,23 @@ Both architectures default to `MODEL_PRECISION=int8`. Set `MODEL_PRECISION=fp32`
to use the bundled FP32 model. An explicit `MODEL_PATH` overrides precision.
There is no automatic fallback on model errors or architecture-based selection.
The reported throughput gains were measured on x86-64, not ARM64.
+
+## Face detection
+
+`face_detection_yunet_2023mar.onnx` is the fixed 640x640
+[YuNet face detector](https://github.com/opencv/opencv_zoo/tree/f12e12798e8314f7c074a6656816c048dcc95b7a/models/face_detection_yunet)
+from OpenCV Zoo, pinned to upstream commit
+`f12e12798e8314f7c074a6656816c048dcc95b7a`. The checked-in artifact is 232,589
+bytes with SHA-256:
+
+```text
+8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4
+```
+
+The artifact is redistributed under the MIT license in `YUNET_LICENSE`. Images
+are aspect-fitted into its input with black padding and no stretching. YuNet
+runs before U²-Net; a face at or above `FACE_SCORE_THRESHOLD` supplies the
+focal point, while images without a reliable face retain the saliency result.
+The 0.85 default retains the licensed clear-face fixture while rejecting a
+0.81 false positive on the two-puppy regression image. Deliberately blurred or
+obscured faces may not reach the threshold.
diff --git a/models/YUNET_LICENSE b/models/YUNET_LICENSE
new file mode 100644
index 0000000..15a1958
--- /dev/null
+++ b/models/YUNET_LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 Shiqi Yu