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.strategy} +
+
+ {frame.alt} + {frame.faces?.map((face, index) => ( + + ))} + +
+
+ + x {coordinate(frame.point.x)} + + + y {coordinate(frame.point.y)} + + + source {frame.source} + + Confidence {coordinate(frame.confidence)} +
+
+ ) +} + +export default function FacePriorityDemo() { + return ( +
+ {comparisons.map((comparison, index) => ( +
+
+ +
+ {comparison.eyebrow} +

{comparison.title}

+

{comparison.description}

+
+ {comparison.result} +
+
+ + +
+
+ ))} +
+ ) +} diff --git a/docs/src/lib/site.ts b/docs/src/lib/site.ts index 1a57e6d..0348bb7 100644 --- a/docs/src/lib/site.ts +++ b/docs/src/lib/site.ts @@ -14,6 +14,7 @@ export const SITE = { export const NAV = { start: [ { href: '#overview', label: 'Overview' }, + { href: '#face-priority', label: 'Face priority' }, { href: '#preview', label: 'Storage preview' }, { href: '#install', label: 'Install' }, { href: '#config', label: 'Configuration' }, diff --git a/docs/src/routes/index.tsx b/docs/src/routes/index.tsx index 8505294..c3f7fde 100644 --- a/docs/src/routes/index.tsx +++ b/docs/src/routes/index.tsx @@ -1,6 +1,7 @@ import { createFileRoute } from '@tanstack/react-router' import CodePanel from '../components/CodePanel' import DocsSidebar from '../components/DocsSidebar' +import FacePriorityDemo from '../components/FacePriorityDemo' import GravityDemo from '../components/GravityDemo' import HttpEndpoint from '../components/HttpEndpoint' import InlineCode from '../components/InlineCode' @@ -18,18 +19,32 @@ function DocsPage() { Overview

Focal points, as a service.

- 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.

- 290–390 ms per image + Face-first · saliency fallback JPEG · PNG · WebP CPU-only
+
+ + +

+ 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.

diff --git a/docs/src/styles.css b/docs/src/styles.css index 3080daf..a245513 100644 --- a/docs/src/styles.css +++ b/docs/src/styles.css @@ -398,6 +398,309 @@ a:hover { color: var(--muted); } +.face-priority-demo { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.face-priority-case { + overflow: hidden; + scroll-margin-top: 5rem; + border: 1px solid var(--line); + border-radius: 0.875rem; + background: var(--surface); + box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.08); +} + +.face-priority-case-header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.875rem; + align-items: start; + padding: 1rem 1.125rem; + border-bottom: 1px solid var(--line); + background: + radial-gradient(circle at 100% 0%, color-mix(in srgb, var(--aw-purple) 10%, transparent), transparent 42%), + var(--surface); +} + +.face-priority-case-index { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + margin-top: 0.125rem; + border: 1px solid var(--line-strong); + border-radius: 0.5rem; + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 600; + color: var(--muted); + background: color-mix(in srgb, var(--raised) 60%, transparent); +} + +.face-priority-case-copy { + min-width: 0; +} + +.face-priority-case-eyebrow { + display: block; + margin-bottom: 0.125rem; + font-family: var(--font-mono); + font-size: 0.625rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--pink-soft); +} + +.face-priority-case h3 { + margin: 0; + font-family: var(--font-display); + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1.4; +} + +.face-priority-case p { + margin: 0.25rem 0 0; + max-width: 31rem; + font-size: 0.8125rem; + line-height: 1.55; + color: var(--muted); +} + +.face-priority-result { + align-self: start; + margin-top: 0.125rem; + padding: 0.3125rem 0.5625rem; + border: 1px solid color-mix(in srgb, #34d399 35%, var(--line)); + border-radius: 999px; + background: color-mix(in srgb, #34d399 10%, transparent); + font-family: var(--font-mono); + font-size: 0.625rem; + font-weight: 600; + line-height: 1.4; + color: color-mix(in srgb, #34d399 86%, var(--fg)); + white-space: nowrap; +} + +.face-priority-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + background: var(--line); +} + +.face-priority-frame { + min-width: 0; + margin: 0; + background: var(--sunken); +} + +.face-priority-frame-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + min-height: 2.625rem; + padding: 0.625rem 0.75rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + background: #111827; + font-family: var(--font-mono); + font-size: 0.625rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.face-priority-phase { + display: inline-flex; + align-items: center; + gap: 0.4375rem; + color: #f8fafc; +} + +.face-priority-status-dot { + width: 0.4375rem; + height: 0.4375rem; + border-radius: 999px; + background: #fbbf24; + box-shadow: 0 0 0 0.1875rem rgba(251, 191, 36, 0.14); +} + +.face-priority-frame--face .face-priority-status-dot { + background: #34d399; + box-shadow: 0 0 0 0.1875rem rgba(52, 211, 153, 0.14); +} + +.face-priority-frame--fallback .face-priority-status-dot { + background: #38bdf8; + box-shadow: 0 0 0 0.1875rem rgba(56, 189, 248, 0.14); +} + +.face-priority-strategy { + overflow: hidden; + color: #94a3b8; + text-overflow: ellipsis; + white-space: nowrap; +} + +.face-priority-visual { + position: relative; + overflow: hidden; + aspect-ratio: 16 / 9; + background: #0f172a; +} + +.face-priority-visual::after { + position: absolute; + inset: 0; + z-index: 1; + border: 1px solid rgba(255, 255, 255, 0.1); + content: ''; + pointer-events: none; +} + +.face-priority-visual img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.face-priority-box { + position: absolute; + z-index: 2; + border: 2px solid #facc15; + box-shadow: + 0 0 0 1px rgba(15, 23, 42, 0.45), + 0 0.25rem 1.25rem rgba(0, 0, 0, 0.22); +} + +.face-priority-box.is-primary { + border-width: 3px; + border-color: #34d399; +} + +.face-priority-box-label { + position: absolute; + left: -2px; + bottom: 100%; + padding: 0.125rem 0.3125rem; + border-radius: 0.25rem 0.25rem 0 0; + background: #facc15; + font-family: var(--font-mono); + font-size: clamp(0.375rem, 1.4vw, 0.5rem); + font-weight: 700; + line-height: 1.25; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #111827; + white-space: nowrap; +} + +.face-priority-box.is-primary .face-priority-box-label { + left: -3px; + background: #34d399; +} + +.face-priority-marker { + position: absolute; + z-index: 3; + width: clamp(2rem, 6vw, 2.625rem); + height: clamp(2rem, 6vw, 2.625rem); + overflow: visible; + color: #fbbf24; + filter: drop-shadow(0 0.125rem 0.25rem rgba(0, 0, 0, 0.5)); + transform: translate(-50%, -50%); +} + +.face-priority-marker--face { + color: #10e99b; +} + +.face-priority-marker--fallback { + color: #38bdf8; +} + +.face-priority-frame-caption { + display: flex; + align-items: center; + gap: 0.625rem; + min-height: 2.75rem; + padding: 0.6875rem 0.75rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); + background: #111827; + font-family: var(--font-mono); + font-size: 0.625rem; + line-height: 1.35; + color: #cbd5e1; +} + +.face-priority-coordinate { + white-space: nowrap; +} + +.face-priority-coordinate span { + color: #64748b; +} + +.face-priority-source { + margin-left: auto; + color: #64748b; + white-space: nowrap; +} + +.face-priority-source strong { + color: #f8fafc; + font-weight: 600; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 640px) { + .face-priority-case-header { + grid-template-columns: auto minmax(0, 1fr); + } + + .face-priority-result { + grid-column: 2; + justify-self: start; + margin-top: -0.25rem; + } + + .face-priority-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 420px) { + .face-priority-case-index { + display: none; + } + + .face-priority-case-header { + grid-template-columns: 1fr; + } + + .face-priority-result { + grid-column: 1; + } +} + .endpoint-block { display: flex; flex-direction: column; diff --git a/internal/facedetection/integration_test.go b/internal/facedetection/integration_test.go new file mode 100644 index 0000000..8586e22 --- /dev/null +++ b/internal/facedetection/integration_test.go @@ -0,0 +1,87 @@ +//go:build integration + +package facedetection + +import ( + "context" + "math" + "os" + "path/filepath" + "testing" + + "autogravity/internal/imageutil" + "autogravity/internal/testimages" + "github.com/disintegration/imaging" +) + +func TestDetectRealModel(t *testing.T) { + library := os.Getenv("ONNXRUNTIME_LIB") + if library == "" { + t.Fatal("integration tests require ONNXRUNTIME_LIB") + } + modelPath := os.Getenv("FACE_MODEL_PATH") + if modelPath == "" { + modelPath = filepath.Join("..", "..", "models", "face_detection_yunet_2023mar.onnx") + } + model, err := NewWithOptions(library, modelPath, Options{IntraOpThreads: 1}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := model.Close(); err != nil { + t.Error(err) + } + }) + + img, err := imageutil.Decode(testimages.Read(t, "person-room.jpg")) + if err != nil { + t.Fatal(err) + } + detections, err := model.Detect(context.Background(), img) + if err != nil { + t.Fatal(err) + } + face, ok := Primary(detections) + if !ok { + t.Fatal("clear portrait produced no face detection") + } + x, y := face.Center() + if x < 0.43 || x > 0.58 || y < 0.40 || y > 0.62 { + t.Fatalf("face center = (%.4f, %.4f), outside annotated region", x, y) + } + if face.Confidence < DefaultScoreThreshold || face.Confidence > 1 { + t.Fatalf("face confidence = %.4f", face.Confidence) + } + + mirroredDetections, err := model.Detect(context.Background(), imaging.FlipH(img)) + if err != nil { + t.Fatal(err) + } + mirrored, ok := Primary(mirroredDetections) + if !ok { + t.Fatal("mirrored portrait produced no face detection") + } + mirroredX, mirroredY := mirrored.Center() + if math.Abs(mirroredX-(1-x)) > 0.03 || math.Abs(mirroredY-y) > 0.03 { + t.Fatalf("mirrored face center = (%.4f, %.4f), original = (%.4f, %.4f)", mirroredX, mirroredY, x, y) + } + + for _, name := range []string{ + "rose.png", "portrait.jpg", "dog-portrait.jpg", "puppies.jpg", + "panda-bamboo.jpg", "pedestrian-dog.jpg", "bird-branch.jpg", + } { + t.Run("no false face/"+name, func(t *testing.T) { + fixture, err := imageutil.Decode(testimages.Read(t, name)) + if err != nil { + t.Fatal(err) + } + detections, err := model.Detect(context.Background(), fixture) + if err != nil { + t.Fatal(err) + } + if len(detections) != 0 { + t.Fatalf("non-human fixture produced face detections: %+v", detections) + } + }) + } +} diff --git a/internal/facedetection/model.go b/internal/facedetection/model.go new file mode 100644 index 0000000..6777497 --- /dev/null +++ b/internal/facedetection/model.go @@ -0,0 +1,449 @@ +// Package facedetection finds faces with the YuNet ONNX model. +package facedetection + +import ( + "context" + "errors" + "fmt" + "image" + "math" + "sort" + "sync" + + "autogravity/internal/ortenv" + "github.com/disintegration/imaging" + ort "github.com/yalue/onnxruntime_go" +) + +const ( + InputWidth = 640 + InputHeight = 640 + DefaultScoreThreshold = 0.85 + DefaultNMSThreshold = 0.30 + DefaultTopK = 5000 +) + +var ( + outputNames = []string{ + "cls_8", "cls_16", "cls_32", + "obj_8", "obj_16", "obj_32", + "bbox_8", "bbox_16", "bbox_32", + } + outputShapes = []ort.Shape{ + ort.NewShape(1, 6400, 1), ort.NewShape(1, 1600, 1), ort.NewShape(1, 400, 1), + ort.NewShape(1, 6400, 1), ort.NewShape(1, 1600, 1), ort.NewShape(1, 400, 1), + ort.NewShape(1, 6400, 4), ort.NewShape(1, 1600, 4), ort.NewShape(1, 400, 4), + } +) + +// Box is a normalized face rectangle in oriented-image coordinates. +type Box struct { + MinX float64 + MinY float64 + MaxX float64 + MaxY float64 +} + +// Detection is one face candidate retained after score filtering and NMS. +type Detection struct { + Bounds Box + Confidence float64 +} + +// Center returns the normalized center of the detected face. +func (d Detection) Center() (float64, float64) { + return (d.Bounds.MinX + d.Bounds.MaxX) / 2, (d.Bounds.MinY + d.Bounds.MaxY) / 2 +} + +// Primary chooses the most prominent reliable face. Area favors foreground +// faces while confidence prevents a large, weak candidate from dominating. +func Primary(detections []Detection) (Detection, bool) { + if len(detections) == 0 { + return Detection{}, false + } + best := detections[0] + bestPriority := priority(best) + for _, detection := range detections[1:] { + candidatePriority := priority(detection) + if candidatePriority > bestPriority || + (candidatePriority == bestPriority && detection.Confidence > best.Confidence) { + best = detection + bestPriority = candidatePriority + } + } + return best, true +} + +func priority(detection Detection) float64 { + width := math.Max(0, detection.Bounds.MaxX-detection.Bounds.MinX) + height := math.Max(0, detection.Bounds.MaxY-detection.Bounds.MinY) + return detection.Confidence * math.Sqrt(width*height) +} + +// Options controls face filtering and the ONNX Runtime session. +type Options struct { + ScoreThreshold float64 + NMSThreshold float64 + TopK int + IntraOpThreads int +} + +// Model owns a reusable YuNet session and its preprocessing buffers. +type Model struct { + mu sync.RWMutex + session *ort.DynamicAdvancedSession + environmentHeld bool + closed bool + scoreThreshold float64 + nmsThreshold float64 + topK int + inputBuffers sync.Pool +} + +// New initializes a face detector with production defaults. +func New(runtimeLibraryPath, modelPath string) (*Model, error) { + return NewWithOptions(runtimeLibraryPath, modelPath, Options{}) +} + +// NewWithOptions initializes a face detector with explicit filtering and +// session settings. +func NewWithOptions(runtimeLibraryPath, modelPath string, options Options) (*Model, error) { + if runtimeLibraryPath == "" { + return nil, errors.New("ONNX Runtime library path is required") + } + if modelPath == "" { + return nil, errors.New("face model path is required") + } + if options.IntraOpThreads < 0 { + return nil, errors.New("ONNX intra-op threads must be at least zero") + } + if options.ScoreThreshold == 0 { + options.ScoreThreshold = DefaultScoreThreshold + } + if options.NMSThreshold == 0 { + options.NMSThreshold = DefaultNMSThreshold + } + if options.TopK == 0 { + options.TopK = DefaultTopK + } + if math.IsNaN(options.ScoreThreshold) || math.IsInf(options.ScoreThreshold, 0) || + options.ScoreThreshold <= 0 || options.ScoreThreshold > 1 { + return nil, errors.New("face score threshold must be greater than zero and at most one") + } + if math.IsNaN(options.NMSThreshold) || math.IsInf(options.NMSThreshold, 0) || + options.NMSThreshold <= 0 || options.NMSThreshold > 1 { + return nil, errors.New("face NMS threshold must be greater than zero and at most one") + } + if options.TopK < 1 { + return nil, errors.New("face top-k must be positive") + } + + if err := ortenv.Acquire(runtimeLibraryPath); err != nil { + return nil, fmt.Errorf("initialize ONNX Runtime: %w", err) + } + loaded := false + defer func() { + if !loaded { + _ = ortenv.Release() + } + }() + + sessionOptions, err := ort.NewSessionOptions() + if err != nil { + return nil, fmt.Errorf("create ONNX session options: %w", err) + } + defer sessionOptions.Destroy() + if err := sessionOptions.SetIntraOpNumThreads(options.IntraOpThreads); err != nil { + return nil, fmt.Errorf("configure ONNX intra-op threads: %w", err) + } + if err := sessionOptions.SetExecutionMode(ort.ExecutionModeSequential); err != nil { + return nil, fmt.Errorf("configure ONNX execution mode: %w", err) + } + + session, err := ort.NewDynamicAdvancedSession( + modelPath, + []string{"input"}, + outputNames, + sessionOptions, + ) + if err != nil { + return nil, fmt.Errorf("load face model: %w", err) + } + + loaded = true + model := &Model{ + session: session, + environmentHeld: true, + scoreThreshold: options.ScoreThreshold, + nmsThreshold: options.NMSThreshold, + topK: options.TopK, + } + model.inputBuffers.New = func() any { + return make([]float32, 3*InputWidth*InputHeight) + } + return model, nil +} + +// Detect returns normalized face boxes from an oriented image. +func (m *Model) Detect(ctx context.Context, img image.Image) ([]Detection, error) { + if ctx == nil { + return nil, errors.New("inference context is required") + } + if err := ctx.Err(); err != nil { + return nil, err + } + if img == nil || img.Bounds().Dx() <= 0 || img.Bounds().Dy() <= 0 { + return nil, errors.New("invalid image") + } + + input := m.inputBuffers.Get().([]float32) + defer m.inputBuffers.Put(input) + content := prepareInto(input, img) + outputs, err := m.infer(ctx, input) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + return postprocess(outputs, content, m.scoreThreshold, m.nmsThreshold, m.topK), nil +} + +func (m *Model) infer(ctx context.Context, input []float32) ([]float32, error) { + if len(input) != 3*InputWidth*InputHeight { + return nil, fmt.Errorf("invalid input tensor length: got %d", len(input)) + } + if err := ctx.Err(); err != nil { + return nil, err + } + m.mu.RLock() + defer m.mu.RUnlock() + if m.closed { + return nil, errors.New("face model is closed") + } + + inputTensor, err := ort.NewTensor( + ort.NewShape(1, 3, InputHeight, InputWidth), + input, + ) + if err != nil { + return nil, fmt.Errorf("create face input tensor: %w", err) + } + defer inputTensor.Destroy() + + outputData := make([][]float32, len(outputShapes)) + outputValues := make([]ort.Value, len(outputShapes)) + for i, shape := range outputShapes { + outputData[i] = make([]float32, int(shape.FlattenedSize())) + tensor, tensorErr := ort.NewTensor(shape, outputData[i]) + if tensorErr != nil { + for _, value := range outputValues[:i] { + _ = value.Destroy() + } + return nil, fmt.Errorf("create face output tensor: %w", tensorErr) + } + outputValues[i] = tensor + } + defer func() { + for _, value := range outputValues { + _ = value.Destroy() + } + }() + + runOptions, err := ort.NewRunOptions() + if err != nil { + return nil, fmt.Errorf("create face run options: %w", err) + } + defer runOptions.Destroy() + + watcherDone := make(chan struct{}) + stopWatcher := make(chan struct{}) + go func() { + defer close(watcherDone) + select { + case <-ctx.Done(): + _ = runOptions.Terminate() + case <-stopWatcher: + } + }() + runErr := m.session.RunWithOptions([]ort.Value{inputTensor}, outputValues, runOptions) + close(stopWatcher) + <-watcherDone + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + if runErr != nil { + return nil, fmt.Errorf("run face model: %w", runErr) + } + + flattened := make([]float32, 0, 50_400) + for _, values := range outputData { + flattened = append(flattened, values...) + } + return flattened, nil +} + +func prepareInto(tensor []float32, img image.Image) image.Rectangle { + clear(tensor) + sourceWidth, sourceHeight := img.Bounds().Dx(), img.Bounds().Dy() + scale := math.Min(float64(InputWidth)/float64(sourceWidth), float64(InputHeight)/float64(sourceHeight)) + resizedWidth := max(1, min(InputWidth, int(math.Round(float64(sourceWidth)*scale)))) + resizedHeight := max(1, min(InputHeight, int(math.Round(float64(sourceHeight)*scale)))) + offsetX := (InputWidth - resizedWidth) / 2 + offsetY := (InputHeight - resizedHeight) / 2 + content := image.Rect(offsetX, offsetY, offsetX+resizedWidth, offsetY+resizedHeight) + resized := imaging.Resize(img, resizedWidth, resizedHeight, imaging.Linear) + pixels := InputWidth * InputHeight + for y := 0; y < resizedHeight; y++ { + row := resized.Pix[y*resized.Stride : y*resized.Stride+4*resizedWidth] + for x := 0; x < resizedWidth; x++ { + pixel := row[4*x : 4*x+4] + alpha := uint32(pixel[3]) + index := (y+offsetY)*InputWidth + x + offsetX + // YuNet follows OpenCV's BGR channel order and consumes raw 0..255 values. + tensor[index] = float32(uint32(pixel[2]) * alpha / 255) + tensor[pixels+index] = float32(uint32(pixel[1]) * alpha / 255) + tensor[2*pixels+index] = float32(uint32(pixel[0]) * alpha / 255) + } + } + return content +} + +type candidate struct { + x1, y1, x2, y2 float64 + score float64 +} + +func postprocess(flattened []float32, content image.Rectangle, scoreThreshold, nmsThreshold float64, topK int) []Detection { + const scalarValues = 6400 + 1600 + 400 + const boxValues = scalarValues * 4 + if len(flattened) != 2*scalarValues+boxValues || content.Empty() { + return nil + } + classValues := flattened[:scalarValues] + objectValues := flattened[scalarValues : 2*scalarValues] + boxValuesSlice := flattened[2*scalarValues:] + + strides := [...]int{8, 16, 32} + counts := [...]int{6400, 1600, 400} + var candidates []candidate + offset := 0 + boxOffset := 0 + for level, stride := range strides { + columns := InputWidth / stride + for index := 0; index < counts[level]; index++ { + classScore := clamp01(float64(classValues[offset+index])) + objectScore := clamp01(float64(objectValues[offset+index])) + score := math.Sqrt(classScore * objectScore) + if score < scoreThreshold || math.IsNaN(score) { + continue + } + row, column := index/columns, index%columns + values := boxValuesSlice[boxOffset+4*index : boxOffset+4*index+4] + centerX := (float64(column) + float64(values[0])) * float64(stride) + centerY := (float64(row) + float64(values[1])) * float64(stride) + width := math.Exp(float64(values[2])) * float64(stride) + height := math.Exp(float64(values[3])) * float64(stride) + if width <= 0 || height <= 0 || math.IsNaN(width) || math.IsNaN(height) || + math.IsInf(width, 0) || math.IsInf(height, 0) || math.IsNaN(centerX) || + math.IsNaN(centerY) || math.IsInf(centerX, 0) || math.IsInf(centerY, 0) { + continue + } + candidates = append(candidates, candidate{ + x1: centerX - width/2, y1: centerY - height/2, + x2: centerX + width/2, y2: centerY + height/2, + score: score, + }) + } + offset += counts[level] + boxOffset += counts[level] * 4 + } + + sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score }) + if len(candidates) > topK { + candidates = candidates[:topK] + } + kept := make([]candidate, 0, len(candidates)) + for _, current := range candidates { + suppressed := false + for _, existing := range kept { + if intersectionOverUnion(current, existing) >= nmsThreshold { + suppressed = true + break + } + } + if !suppressed { + kept = append(kept, current) + } + } + + contentMinX, contentMinY := float64(content.Min.X), float64(content.Min.Y) + contentMaxX, contentMaxY := float64(content.Max.X), float64(content.Max.Y) + contentWidth, contentHeight := float64(content.Dx()), float64(content.Dy()) + detections := make([]Detection, 0, len(kept)) + for _, face := range kept { + x1 := math.Max(face.x1, contentMinX) + y1 := math.Max(face.y1, contentMinY) + x2 := math.Min(face.x2, contentMaxX) + y2 := math.Min(face.y2, contentMaxY) + if x2 <= x1 || y2 <= y1 { + continue + } + detections = append(detections, Detection{ + Bounds: Box{ + MinX: clamp01((x1 - contentMinX) / contentWidth), + MinY: clamp01((y1 - contentMinY) / contentHeight), + MaxX: clamp01((x2 - contentMinX) / contentWidth), + MaxY: clamp01((y2 - contentMinY) / contentHeight), + }, + Confidence: face.score, + }) + } + return detections +} + +func intersectionOverUnion(a, b candidate) float64 { + intersectionWidth := math.Max(0, math.Min(a.x2, b.x2)-math.Max(a.x1, b.x1)) + intersectionHeight := math.Max(0, math.Min(a.y2, b.y2)-math.Max(a.y1, b.y1)) + intersection := intersectionWidth * intersectionHeight + areaA := math.Max(0, a.x2-a.x1) * math.Max(0, a.y2-a.y1) + areaB := math.Max(0, b.x2-b.x1) * math.Max(0, b.y2-b.y1) + union := areaA + areaB - intersection + if union <= 0 { + return 0 + } + return intersection / union +} + +func clamp01(value float64) float64 { + if value < 0 { + return 0 + } + if value > 1 { + return 1 + } + return value +} + +// Close releases the face session and its shared runtime reference. +func (m *Model) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + if m.closed { + return nil + } + m.closed = true + var closeErrors []error + if m.session != nil { + if err := m.session.Destroy(); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("destroy face model session: %w", err)) + } + } + if m.environmentHeld { + m.environmentHeld = false + if err := ortenv.Release(); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("release ONNX Runtime: %w", err)) + } + } + return errors.Join(closeErrors...) +} diff --git a/internal/facedetection/model_test.go b/internal/facedetection/model_test.go new file mode 100644 index 0000000..47da476 --- /dev/null +++ b/internal/facedetection/model_test.go @@ -0,0 +1,120 @@ +package facedetection + +import ( + "context" + "image" + "image/color" + "math" + "testing" +) + +func TestNewValidatesOptionsBeforeRuntimeAccess(t *testing.T) { + tests := []struct { + name string + library string + model string + options Options + }{ + {name: "missing runtime", model: "face.onnx"}, + {name: "missing model", library: "runtime.so"}, + {name: "negative threads", library: "runtime.so", model: "face.onnx", options: Options{IntraOpThreads: -1}}, + {name: "score too high", library: "runtime.so", model: "face.onnx", options: Options{ScoreThreshold: 1.1}}, + {name: "score NaN", library: "runtime.so", model: "face.onnx", options: Options{ScoreThreshold: math.NaN()}}, + {name: "NMS too high", library: "runtime.so", model: "face.onnx", options: Options{NMSThreshold: 1.1}}, + {name: "negative top-k", library: "runtime.so", model: "face.onnx", options: Options{TopK: -1}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + model, err := NewWithOptions(test.library, test.model, test.options) + if model != nil || err == nil { + t.Fatalf("NewWithOptions() = %v, %v; want validation error", model, err) + } + }) + } +} + +func TestDetectRejectsCancelledContextBeforeRuntimeAccess(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := (&Model{}).Detect(ctx, image.NewNRGBA(image.Rect(0, 0, 1, 1))) + if err != context.Canceled { + t.Fatalf("Detect() error = %v, want context.Canceled", err) + } +} + +func TestPrepareIntoLetterboxesBGRValues(t *testing.T) { + img := image.NewNRGBA(image.Rect(0, 0, 2, 1)) + for x := range 2 { + img.SetNRGBA(x, 0, color.NRGBA{R: 10, G: 20, B: 30, A: 255}) + } + tensor := make([]float32, 3*InputWidth*InputHeight) + content := prepareInto(tensor, img) + wantContent := image.Rect(0, 160, 640, 480) + if content != wantContent { + t.Fatalf("content = %v, want %v", content, wantContent) + } + index := 320*InputWidth + 320 + pixels := InputWidth * InputHeight + if tensor[index] != 30 || tensor[pixels+index] != 20 || tensor[2*pixels+index] != 10 { + t.Fatalf("BGR values = %v, %v, %v", tensor[index], tensor[pixels+index], tensor[2*pixels+index]) + } + if tensor[0] != 0 || tensor[pixels] != 0 || tensor[2*pixels] != 0 { + t.Fatal("letterbox padding was not cleared") + } +} + +func TestPostprocessMapsAndFiltersFace(t *testing.T) { + const scalarValues = 8400 + values := make([]float32, 2*scalarValues+4*scalarValues) + row, column := 30, 40 + index := row*80 + column + values[index] = 0.81 + values[scalarValues+index] = 1 + boxOffset := 2*scalarValues + 4*index + values[boxOffset+2] = float32(math.Log(80.0 / 8.0)) + values[boxOffset+3] = float32(math.Log(80.0 / 8.0)) + + detections := postprocess(values, image.Rect(0, 140, 640, 500), 0.8, 0.3, 5000) + if len(detections) != 1 { + t.Fatalf("detections = %+v, want one", detections) + } + detection := detections[0] + x, y := detection.Center() + if math.Abs(x-0.5) > 1e-6 || math.Abs(y-(100.0/360.0)) > 1e-6 { + t.Fatalf("center = (%v, %v)", x, y) + } + if math.Abs(detection.Confidence-0.9) > 1e-6 { + t.Fatalf("confidence = %v, want 0.9", detection.Confidence) + } + if got := postprocess(values, image.Rect(0, 140, 640, 500), 0.91, 0.3, 5000); len(got) != 0 { + t.Fatalf("high-threshold detections = %+v, want none", got) + } +} + +func TestPostprocessSuppressesOverlappingFaces(t *testing.T) { + const scalarValues = 8400 + values := make([]float32, 2*scalarValues+4*scalarValues) + for column, score := range map[int]float32{40: 1, 41: 0.81} { + index := 30*80 + column + values[index] = score + values[scalarValues+index] = 1 + boxOffset := 2*scalarValues + 4*index + values[boxOffset+2] = float32(math.Log(80.0 / 8.0)) + values[boxOffset+3] = float32(math.Log(80.0 / 8.0)) + } + if got := postprocess(values, image.Rect(0, 0, 640, 640), 0.8, 0.3, 5000); len(got) != 1 { + t.Fatalf("detections = %+v, want one after NMS", got) + } +} + +func TestPrimaryFavorsProminentFace(t *testing.T) { + small := Detection{Bounds: Box{MinX: 0, MinY: 0, MaxX: 0.1, MaxY: 0.1}, Confidence: 0.99} + large := Detection{Bounds: Box{MinX: 0.3, MinY: 0.2, MaxX: 0.6, MaxY: 0.6}, Confidence: 0.85} + got, ok := Primary([]Detection{small, large}) + if !ok || got != large { + t.Fatalf("Primary() = %+v, %v; want prominent face", got, ok) + } + if _, ok := Primary(nil); ok { + t.Fatal("Primary(nil) reported a face") + } +} diff --git a/internal/ortenv/environment.go b/internal/ortenv/environment.go new file mode 100644 index 0000000..5a30f10 --- /dev/null +++ b/internal/ortenv/environment.go @@ -0,0 +1,63 @@ +// Package ortenv manages the process-wide ONNX Runtime environment. +package ortenv + +import ( + "errors" + "fmt" + "sync" + + ort "github.com/yalue/onnxruntime_go" +) + +var environment struct { + sync.Mutex + references int + libraryPath string +} + +// Acquire initializes ONNX Runtime on the first call and retains the shared +// environment for subsequent model sessions. Every successful call must be +// paired with Release. +func Acquire(libraryPath string) error { + if libraryPath == "" { + return errors.New("ONNX Runtime library path is required") + } + + environment.Lock() + defer environment.Unlock() + if environment.references > 0 { + if libraryPath != environment.libraryPath { + return fmt.Errorf("ONNX Runtime already initialized from %q", environment.libraryPath) + } + environment.references++ + return nil + } + if ort.IsInitialized() { + return errors.New("ONNX Runtime was initialized outside the shared environment manager") + } + + ort.SetSharedLibraryPath(libraryPath) + if err := ort.InitializeEnvironment(); err != nil { + return err + } + environment.references = 1 + environment.libraryPath = libraryPath + return nil +} + +// Release drops a reference and destroys ONNX Runtime after the final model +// session has been closed. +func Release() error { + environment.Lock() + defer environment.Unlock() + if environment.references == 0 { + return errors.New("ONNX Runtime environment is not acquired") + } + + environment.references-- + if environment.references > 0 { + return nil + } + environment.libraryPath = "" + return ort.DestroyEnvironment() +} diff --git a/internal/saliency/model.go b/internal/saliency/model.go index 4807e63..76d49b9 100644 --- a/internal/saliency/model.go +++ b/internal/saliency/model.go @@ -7,6 +7,7 @@ import ( "fmt" "sync" + "autogravity/internal/ortenv" ort "github.com/yalue/onnxruntime_go" ) @@ -17,9 +18,10 @@ const ( // Model owns a single reusable ONNX Runtime session. type Model struct { - mu sync.RWMutex - session *ort.DynamicAdvancedSession - closed bool + mu sync.RWMutex + session *ort.DynamicAdvancedSession + environmentHeld bool + closed bool } // Options controls the ONNX Runtime session used by the model. @@ -48,16 +50,15 @@ func NewWithOptions(runtimeLibraryPath, modelPath string, options Options) (*Mod return nil, errors.New("ONNX intra-op threads must be at least zero") } - ort.SetSharedLibraryPath(runtimeLibraryPath) - if err := ort.InitializeEnvironment(); err != nil { + if err := ortenv.Acquire(runtimeLibraryPath); err != nil { return nil, fmt.Errorf("initialize ONNX Runtime: %w", err) } loaded := false defer func() { if !loaded { // Registered before sessionOptions.Destroy: options must be freed - // before destroying the environment unloads the shared library. - _ = ort.DestroyEnvironment() + // before the final reference unloads the shared library. + _ = ortenv.Release() } }() @@ -88,7 +89,7 @@ func NewWithOptions(runtimeLibraryPath, modelPath string, options Options) (*Mod } loaded = true - return &Model{session: session}, nil + return &Model{session: session, environmentHeld: true}, nil } // Infer returns the model's 320x320 fused saliency map. @@ -168,7 +169,7 @@ func (m *Model) Infer(ctx context.Context, input []float32) ([]float32, error) { return result, nil } -// Close releases the model session and ONNX Runtime environment. +// Close releases the model session and its shared ONNX Runtime reference. func (m *Model) Close() error { m.mu.Lock() defer m.mu.Unlock() @@ -177,11 +178,16 @@ func (m *Model) Close() error { } m.closed = true var closeErrors []error - if err := m.session.Destroy(); err != nil { - closeErrors = append(closeErrors, fmt.Errorf("destroy model session: %w", err)) + if m.session != nil { + if err := m.session.Destroy(); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("destroy model session: %w", err)) + } } - if err := ort.DestroyEnvironment(); err != nil { - closeErrors = append(closeErrors, fmt.Errorf("destroy ONNX Runtime: %w", err)) + if m.environmentHeld { + m.environmentHeld = false + if err := ortenv.Release(); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("release ONNX Runtime: %w", err)) + } } return errors.Join(closeErrors...) } diff --git a/internal/testimages/testdata/GENERATED.md b/internal/testimages/testdata/GENERATED.md new file mode 100644 index 0000000..96b56fd --- /dev/null +++ b/internal/testimages/testdata/GENERATED.md @@ -0,0 +1,204 @@ +# Generated fixture provenance + +These source images were created in built-in image-generation mode on 2026-09-08 +using the `photorealistic-natural` taxonomy. Each generated PNG was preserved in +the local generation cache and converted with macOS `sips` to an 800-pixel +maximum dimension, JPEG quality 82, without cropping. The checked-in images are +the resulting JPEG files. + +All people requested in the prompts are fictional adults. The prompts prohibit +text, logos, watermarks, and unintended faces so the images can act as focused +software fixtures. + +## Prompts + +### `generated-face-center.jpg` + +> Create a photorealistic-natural software test fixture for face detection. +> +> Scene: one fictional adult standing in a quiet outdoor courtyard in natural +> daylight, photographed waist-up at eye level. The adult is centered and looking +> directly at the camera with a neutral expression. The face must be sharp, +> unobstructed, naturally proportioned, and about 18–24% of the full image height. +> +> Composition: wide 16:9 landscape frame; exactly one person; face center near +> normalized coordinate x=0.50, y=0.28; enough torso and simple softly blurred +> background to make subject placement unambiguous. +> +> Constraints: no other people, no portraits or face-like artwork in the +> background, no hat, no glasses, no face covering, no motion blur, no text, no +> logos, no watermark. Use a wholly fictional adult, not a recognizable public or +> private person. + +### `generated-face-left.jpg` + +> Create a photorealistic-natural software test fixture for face detection. +> +> Scene: one fictional Black adult man standing indoors beside a plain window in +> soft daylight, photographed from mid-torso upward at eye level. He looks directly +> at the camera with a neutral, relaxed expression. His face is sharp, fully +> visible, naturally proportioned, and about 16–22% of image height. +> +> Composition: wide 16:9 landscape frame; place the person clearly on the LEFT +> third with face center near normalized coordinate x=0.23, y=0.30; leave clean +> architectural negative space across the right two-thirds. Exactly one person. +> +> Constraints: no other people, reflections, portraits, face-like decorations, +> hat, glasses, covering, blur, text, logos, or watermark. Use a wholly fictional +> adult, not a recognizable public or private person. + +### `generated-face-right.jpg` + +> Create a photorealistic-natural software test fixture for face detection. +> +> Scene: one fictional East Asian adult woman in a modern public garden, +> photographed from mid-torso upward at eye level in bright overcast daylight. She +> looks directly at the camera with a neutral expression. Her face is sharp, fully +> visible, naturally proportioned, and about 16–22% of image height. +> +> Composition: wide 16:9 landscape frame; place the person clearly on the RIGHT +> third with face center near normalized coordinate x=0.77, y=0.30; leave +> uncluttered foliage and walkway negative space across the left two-thirds. +> Exactly one person. +> +> Constraints: no other people, statues, portraits, face-like decorations, hat, +> glasses, covering, blur, text, logos, or watermark. Use a wholly fictional adult, +> not a recognizable public or private person. + +### `generated-face-three-quarter.jpg` + +> Create a photorealistic-natural software test fixture for robust face detection. +> +> Scene: one fictional Middle Eastern adult man seated in a café terrace in soft +> daylight, photographed chest-up. His head is turned about 40 degrees away from +> the camera in a clear three-quarter profile, with both eyes still visible and +> facial structure sharp. Neutral expression, natural skin texture. +> +> Composition: 4:3 landscape frame; exactly one person; place his face around +> normalized coordinate x=0.38, y=0.32; face height about 22–28% of the image; +> background softly defocused and free of people. +> +> Constraints: preserve the intentional three-quarter angle; no front-facing pose, +> no other people, reflections, portraits, face-like artwork, hat, glasses, face +> covering, motion blur, text, logos, or watermark. Use a wholly fictional adult, +> not a recognizable public or private person. + +### `generated-face-occluded.jpg` + +> Create a photorealistic-natural software test fixture for partially occluded face +> detection. +> +> Scene: one fictional Latino adult woman at an outdoor transit shelter on a bright +> cloudy day, photographed chest-up. She looks toward the camera. She wears +> ordinary clear-lens eyeglasses and a knitted winter beanie; a loose scarf covers +> only the lowest edge of her chin, while eyes, nose, cheeks, and mouth remain +> visible. Face and glasses are sharply focused. +> +> Composition: portrait-oriented 4:5 frame; exactly one person; face center near +> normalized coordinate x=0.50, y=0.30; face height about 24–30% of image; simple +> defocused shelter background. +> +> Constraints: no sunglasses, no mask, no hands covering the face, no other people, +> reflections, advertisements with faces, portraits, text, logos, or watermark. +> Use a wholly fictional adult, not a recognizable public or private person. + +### `generated-face-low-light.jpg` + +> Create a photorealistic-natural software test fixture for face detection in +> difficult lighting. +> +> Scene: one fictional South Asian adult man standing in a dim room at dusk, +> photographed shoulders-up. A single warm table lamp softly lights one side of +> his face while cool window light faintly fills the other side. He looks directly +> at the camera with a neutral expression. Both eyes, nose, and mouth remain +> discernible; retain realistic low-light grain without losing facial structure. +> +> Composition: wide 16:9 landscape frame; exactly one person; face center near +> normalized coordinate x=0.60, y=0.36; face height about 22–28% of image; dark +> simple background. +> +> Constraints: genuinely low-key lighting but not a silhouette, no crushed-black +> face, no other people, reflections, portraits, face-like decorations, hat, +> glasses, covering, motion blur, text, logos, or watermark. Use a wholly fictional +> adult, not a recognizable public or private person. + +### `generated-faces-primary.jpg` + +> Create a photorealistic-natural software test fixture for selecting the primary +> face when multiple faces are present. +> +> Scene: exactly two fictional adults in a bright studio workspace. A Southeast +> Asian adult woman is in the foreground on the LEFT, photographed chest-up, +> facing the camera with a clear sharp face. A white adult man is farther back on +> the RIGHT, also facing the camera, with a clear but much smaller face. Both have +> neutral expressions. +> +> Composition: wide 16:9 landscape frame. The foreground woman's face center is +> near normalized x=0.30, y=0.34 and is about 24–28% of image height. The +> background man's face center is near x=0.78, y=0.31 and is about 9–12% of image +> height. Make the size difference unmistakable so the foreground woman is the +> intended primary face. +> +> Constraints: exactly two people and exactly two faces, no reflections, screens +> or artwork showing faces, no hats, glasses, face coverings, blur, text, logos, or +> watermark. Use wholly fictional adults, not recognizable public or private +> people. + +### `generated-no-face-landscape.jpg` + +> Create a photorealistic-natural software test fixture that must contain no +> detectable human face. +> +> Scene: a broad alpine lake at sunrise with layered mountains, evergreen trees, +> smooth rocks, and a small red canoe pulled onto the shore. Crisp natural detail +> and balanced photographic composition. +> +> Composition: wide 16:9 landscape frame; the canoe is the strongest foreground +> subject near normalized coordinate x=0.68, y=0.72; open water and mountains fill +> the rest. +> +> Strict constraints: no humans, no animals, no statues, no mannequins, no masks, +> no portraits, no faces, no face-like carvings or arrangements, no buildings with +> face-like windows, no text, no logos, no watermark. + +### `generated-no-face-back-facing.jpg` + +> Create a photorealistic-natural software test fixture containing a person but no +> visible face. +> +> Scene: one fictional adult hiker seen entirely from behind, standing at a coastal +> overlook in daylight and looking out to sea. Show the back of the head and body +> only. The hiker has a backpack and short hair. The pose should make it physically +> impossible to see eyes, nose, mouth, cheek, or facial profile. +> +> Composition: wide 16:9 landscape frame; one person centered slightly left near +> normalized x=0.42, occupying about half the image height; coastline and sea form +> a clear background. +> +> Strict constraints: exactly one person; camera directly behind them; no head +> turn, no facial profile, no reflections, no other people, no portraits or signs +> with faces, no statues, no masks, no text, no logos, no watermark. Use a wholly +> fictional adult. + +### `generated-no-face-blurred.jpg` + +> Create a photorealistic-natural software test fixture for anonymized-face +> fallback behavior. +> +> Scene: one fictional adult standing waist-up in an outdoor institutional +> courtyard in daylight, wearing a plain olive work shirt with no insignia. The +> person faces the camera and is centered. Apply a very strong, smooth Gaussian +> anonymization blur ONLY over the entire facial region, from forehead through chin +> and ear-to-ear. The blur must fully erase eyes, eyebrows, nose, nostrils, lips, +> skin detail, and every identifiable facial feature while leaving hair, neck, +> clothing, and background sharp. +> +> Composition: wide 16:9 landscape frame; exactly one person; blurred facial region +> center near normalized coordinate x=0.50, y=0.28; person occupies about 65% of +> image height; simple courtyard and trees behind. +> +> Critical constraints: the blurred face must contain no recoverable or faint +> facial features and should read as a uniform soft oval color field, not a clear +> face with shallow depth of field. No other people, reflections, portraits, +> statues, insignia, text, logos, or watermark. Use a wholly fictional adult, not a +> recognizable public or private person. diff --git a/internal/testimages/testdata/README.md b/internal/testimages/testdata/README.md index 6fb10f4..8e2637c 100644 --- a/internal/testimages/testdata/README.md +++ b/internal/testimages/testdata/README.md @@ -57,8 +57,45 @@ Observed original-image outputs on macOS arm64 with ONNX Runtime 1.23.2: | Pedestrian and dog | 0.2572, 0.4904 | 0.6766, 0.5457 | 0.9808 | Above expected region; reflection checks also fail | | Bird on branch | 0.5443, 0.6420 | 0.4112, 0.3939 | 0.9981 | Right of bird; reflected region also fails | -Peak activation is the API's current `confidence` value; these examples show it -is not a calibrated probability that the selected subject is correct. +Peak activation is the API's `confidence` value when `source` is `saliency`; +these examples show it is not a calibrated probability that the selected +subject is correct. + +The person-in-room image also supplies the real-model YuNet integration case. +At the default 0.85 threshold, its clear face is detected and moves the selected +gravity point from the body's saliency centroid to the face bounding-box center. +Blurred or obscured faces that do not meet the threshold continue through the +saliency path. + +## Generated face-priority matrix + +These ten fictional scenes were created with OpenAI's built-in image generation +tool on 2026-09-08 specifically for regression testing. They were exported as +JPEG at quality 82 with an 800-pixel maximum dimension; together they occupy +about 1 MB. The exact prompts and transformation details are recorded in +[`GENERATED.md`](GENERATED.md). + +The expected source and face regions were selected by visual inspection before +running either ONNX model. Regions are normalized to the original image. The +integration suite checks every image in its original and horizontally mirrored +orientation, verifies `source`, and checks the primary face location when a face +is expected. + +| Preview | Expected source | Primary face region (x; y) | Purpose | +| --- | --- | --- | --- | +| One centered adult facing the camera | face | 0.40–0.60; 0.15–0.40 | Baseline centered portrait | +| One adult on the left with negative space | face | 0.16–0.38; 0.10–0.40 | Left placement and negative space | +| One adult on the right with negative space | face | 0.67–0.88; 0.10–0.42 | Right placement and negative space | +| Adult in a three-quarter profile | face | 0.24–0.54; 0.14–0.48 | Non-frontal head angle | +| Adult wearing a beanie, glasses, and scarf | face | 0.22–0.74; 0.10–0.52 | Glasses, hat, and minor chin occlusion | +| Adult face in dim mixed lighting | face | 0.43–0.76; 0.10–0.50 | Low light and uneven illumination | +| Large foreground face and small background face | face | 0.13–0.44; 0.10–0.48 | Primary-face selection by prominence | +| Mountain lake and canoe without people | saliency | — | No-person false-positive control | +| Hiker facing away at a coastal overlook | saliency | — | Person present without a visible face | +| Adult whose entire face is strongly blurred | 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 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/models/face_detection_yunet_2023mar.onnx b/models/face_detection_yunet_2023mar.onnx new file mode 100644 index 0000000..f9beb30 Binary files /dev/null and b/models/face_detection_yunet_2023mar.onnx differ