diff --git a/.dockerignore b/.dockerignore index c8eaa81..dc3dbed 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ .git autogravity models/*.onnx +!models/u2net-int8.onnx diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9e20d7d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Keep reproducibility data available without overwhelming code review. +/tools/quantization/results-*.json linguist-generated=true +/tools/quantization/dataset-*.json linguist-generated=true +/tools/quantization/timings-*.json linguist-generated=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e833d3c..cd24ab9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,9 @@ jobs: - name: Test real image inference run: mise run test-integration + - name: Test FP32 precision override + run: make test-integration-fp32 + docker: name: Docker build (${{ matrix.arch }}) strategy: diff --git a/.gitignore b/.gitignore index e425eaa..6452e0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ /autogravity /models/*.onnx - +!/models/u2net-int8.onnx diff --git a/.mise.toml b/.mise.toml index 328e5ce..60cfb2f 100644 --- a/.mise.toml +++ b/.mise.toml @@ -3,7 +3,7 @@ go = "1.25.14" [tasks.fmt] description = "Check Go formatting" -run = "test -z \"$(gofmt -l cmd internal)\"" +run = "test -z \"$(gofmt -l cmd internal tools)\"" [tasks.vet] description = "Run static analysis" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ef7a49..dff9085 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,12 @@ 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 +switch; CI runs both modes. The INT8 binary is intentionally versioned in +`models/` and checksum-verified, so release builds do not need the experimental +SSH host or the calibration dataset. See `models/README.md` for provenance. + The gallery also includes three difficult natural scenes with manually annotated subject regions. Run `make evaluate` to check a person in a room, a pedestrian with a dog, and a bird above tree foliage. Full U²-Net passes the person-in-room @@ -57,6 +63,17 @@ model predictions. ## Benchmarks +To measure preprocessing time and Go allocations without loading ONNX Runtime: + +```sh +go test ./internal/imageutil -run '^$' -bench BenchmarkPrepare -benchmem -count=3 +``` + +`BenchmarkPrepareInto` measures the reusable input-buffer path used by the HTTP +handler. `BenchmarkPrepare` includes input-buffer allocation. The already-sized +case isolates normalization from resizing. These are preprocessing measurements, +not end-to-end inference throughput. + The benchmark uses the included landscape JPEG and portrait PNG. It measures image decoding, orientation handling, resize and normalization, ONNX inference, and focal-point calculation. Model startup is excluded. @@ -83,6 +100,12 @@ ONNX Runtime version. The fixtures are synthetic, and the benchmark runs analyses sequentially. File reads, HTTP handling, uploads, and model startup are excluded. +To tune production throughput, benchmark `MAX_CONCURRENT_ANALYSES` values such +as 1, 2, 4, and 8 under an HTTP workload while recording requests/second, +latency percentiles, and peak memory. Test `ONNX_INTRA_OP_THREADS` alongside it: +the default of 1 is intended for concurrent requests, while a higher value may +reduce single-request latency when more CPU cores are available. + ## Layout ```text diff --git a/Dockerfile b/Dockerfile index 405ba6c..0f0a6b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,9 @@ RUN mkdir -p /opt/onnxruntime \ && tar -xzf /tmp/onnxruntime.tgz --strip-components=1 -C /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/ +RUN echo "b340186f56660b6665e494aab912e5f8e9adbc2317181c77fd01aa226f06553b /opt/models/u2net-int8.onnx" | sha256sum -c - WORKDIR /src COPY go.mod go.sum ./ @@ -31,8 +34,9 @@ COPY --from=builder /out/autogravity /usr/local/bin/autogravity COPY --chmod=0555 healthcheck.sh /usr/local/bin/healthcheck COPY --from=builder /opt/onnxruntime/lib /opt/onnxruntime/lib COPY --from=builder /opt/models /opt/models +WORKDIR /opt ENV ADDR=:8080 \ - MODEL_PATH=/opt/models/u2net.onnx \ + MODEL_PRECISION=int8 \ ONNXRUNTIME_LIB=/opt/onnxruntime/lib/libonnxruntime.so.1.23.2 USER 65532:65532 EXPOSE 8080 diff --git a/Makefile b/Makefile index accf9cc..6583bc9 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ -MODEL_PATH := models/u2net.onnx -MODEL_URL := https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx -MODEL_SHA256 := 8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491 +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 -.PHONY: build run test test-integration evaluate model +.PHONY: build run test test-integration test-integration-fp32 evaluate model model-fp32 model-int8 build: go build -o autogravity ./cmd/autogravity @@ -16,11 +16,19 @@ test: test-integration: model go test -race -tags=integration ./... +test-integration-fp32: model-fp32 + MODEL_PATH= MODEL_PRECISION=fp32 go test -race -tags=integration ./... + evaluate: model go test -tags=integration,evaluation -run TestEvaluateDifficultScenes -v ./cmd/autogravity -model: - @if [ ! -f "$(MODEL_PATH)" ]; then \ - curl -fL --retry 3 -o "$(MODEL_PATH)" "$(MODEL_URL)"; \ +model: model-fp32 model-int8 + +model-int8: + @echo "b340186f56660b6665e494aab912e5f8e9adbc2317181c77fd01aa226f06553b models/u2net-int8.onnx" | shasum -a 256 -c + +model-fp32: + @if [ ! -f "$(FP32_MODEL_PATH)" ]; then \ + curl -fL --retry 3 -o "$(FP32_MODEL_PATH)" "$(FP32_MODEL_URL)"; \ fi - @echo "$(MODEL_SHA256) $(MODEL_PATH)" | shasum -a 256 -c + @echo "$(FP32_MODEL_SHA256) $(FP32_MODEL_PATH)" | shasum -a 256 -c diff --git a/README.md b/README.md index dcb9243..c1160dc 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ submitted image. ## Requirements - Go 1.25 or newer -- The full U²-Net ONNX model, approximately 168 MiB (`make model` downloads and verifies it) +- The included INT8 U²-Net model (approximately 42 MiB). `make model` verifies it + and downloads/verifies the FP32 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. @@ -34,12 +35,28 @@ The server listens on `:8080`. These environment variables are available: | Variable | Default | Purpose | | --- | --- | --- | | `ADDR` | `:8080` | HTTP listen address | -| `MODEL_PATH` | `models/u2net.onnx` | U²-Net model path | +| `MODEL_PRECISION` | `int8` | `int8` or `fp32`, on every architecture | +| `MODEL_PATH` | unset | Explicit model path; overrides `MODEL_PRECISION` | | `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 | ## Performance -On an Apple M3 Pro, image analysis takes about **290–390 ms per image** with full U²-Net +INT8 is the default on both amd64 and arm64. Set `MODEL_PRECISION=fp32` to use +FP32, or `MODEL_PATH` for a custom artifact. Unknown precision values fail +startup unless an explicit model path is supplied; there is no silent fallback. +Native runs select `models/u2net-int8.onnx` or `models/u2net.onnx` relative to +the working directory. Containers use the same selection under `/opt`. + +On the tested x86 Xeon, INT8 delivered **38–45% more HTTP throughput** at four +CPUs and approximately **64–65% lower peak container memory**. Across 200 +held-out public images, median focal-point shift was 0.10%, p95 0.91%, and the +worst shift 7.4% of an image dimension. These throughput gains are not established +for ARM64. The calibration and evaluation provenance is documented with the +model artifact in [models/README.md](models/README.md). + +On an Apple M3 Pro, historical **FP32** image analysis takes about **290–390 ms per image** with full U²-Net and CPU-only ONNX Runtime: | Input | Dimensions | Time per image | @@ -57,15 +74,25 @@ 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 +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 +predictable resource use, and override `MAX_CONCURRENT_ANALYSES` when memory is +the tighter constraint. + ## Docker -The image downloads the verified U²-Net model and the CPU-only ONNX Runtime -library during the build. Docker BuildKit supports both `linux/amd64` and -`linux/arm64`. +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. ```sh docker build -t autogravity . docker run --rm -p 8080:8080 autogravity +# Select FP32 without rebuilding: +docker run --rm -p 8080:8080 -e MODEL_PRECISION=fp32 autogravity ``` ### Container releases diff --git a/cmd/autogravity/fixtures_test.go b/cmd/autogravity/fixtures_test.go index 7cc3bb9..ba323b9 100644 --- a/cmd/autogravity/fixtures_test.go +++ b/cmd/autogravity/fixtures_test.go @@ -68,7 +68,7 @@ func TestHandleAnalyzeFixtures(t *testing.T) { result[y*saliency.InputWidth+x] = 0.75 return result, nil }) - app := newApplication(model) + app := newApplication(model, 2) response := httptest.NewRecorder() app.handleAnalyze(response, fixtureRequest(t, fixture, multipartBody, testimages.Read(t, fixture.Name))) if response.Code != http.StatusOK { @@ -104,7 +104,7 @@ func TestHandleAnalyzeTruncatedFixtures(t *testing.T) { } t.Run(name, func(t *testing.T) { model := analyzerFunc(func([]float32) ([]float32, error) { t.Error("inference called for corrupt image"); return nil, nil }) - app := newApplication(model) + app := newApplication(model, 2) data := testimages.Read(t, fixture.Name) response := httptest.NewRecorder() app.handleAnalyze(response, fixtureRequest(t, fixture, multipartBody, data[:len(data)/2])) @@ -143,7 +143,7 @@ func TestHandleAnalyzeInferenceFailureRecovery(t *testing.T) { return nil, errors.New("private runtime error") } return make([]float32, 320*320), nil - })) + }), 2) fixture := testimages.All[0] for _, status := range []int{http.StatusInternalServerError, http.StatusOK} { response := httptest.NewRecorder() diff --git a/cmd/autogravity/integration_test.go b/cmd/autogravity/integration_test.go index 202629c..b97c0db 100644 --- a/cmd/autogravity/integration_test.go +++ b/cmd/autogravity/integration_test.go @@ -5,12 +5,14 @@ package main import ( "bytes" "encoding/json" + "fmt" "image/png" "math" "net/http" "net/http/httptest" "os" "path/filepath" + "sync" "testing" "autogravity/internal/imageutil" @@ -38,6 +40,84 @@ func TestAnalyzeRealModel(t *testing.T) { }) } +func TestConcurrentInferenceIsConsistent(t *testing.T) { + library := os.Getenv("ONNXRUNTIME_LIB") + if library == "" { + t.Fatal("integration tests require ONNXRUNTIME_LIB") + } + modelPath := os.Getenv("MODEL_PATH") + if modelPath == "" { + selected, err := configuredModelPath() + if err != nil { + t.Fatal(err) + } + modelPath = filepath.Join("..", "..", selected) + } + model, err := saliency.NewWithOptions(library, modelPath, saliency.Options{IntraOpThreads: 1}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := model.Close(); err != nil { + t.Error(err) + } + }) + + fixture := testimages.All[0] + img, err := imageutil.Decode(testimages.Read(t, fixture.Name)) + if err != nil { + t.Fatal(err) + } + input, _, err := imageutil.Prepare(img, saliency.InputWidth, saliency.InputHeight) + if err != nil { + t.Fatal(err) + } + want, err := model.Infer(input) + if err != nil { + t.Fatal(err) + } + + const workers = 4 + var wait sync.WaitGroup + errors := make(chan error, workers) + for range workers { + wait.Add(1) + go func() { + defer wait.Done() + got, err := model.Infer(input) + if err != nil { + errors <- err + return + } + for i := range want { + if got[i] != want[i] { + errors <- fmt.Errorf("output[%d] = %v, want %v", i, got[i], want[i]) + return + } + } + }() + } + wait.Wait() + close(errors) + for err := range errors { + t.Error(err) + } + // Infer returns independently owned Go memory, even after another call + // uses different input and the native runtime has been destroyed. + snapshot := append([]float32(nil), want...) + if _, err := model.Infer(make([]float32, len(input))); err != nil { + t.Fatal(err) + } + if err := model.Close(); err != nil { + t.Fatal(err) + } + for i := range want { + if want[i] != snapshot[i] { + t.Fatalf("returned output changed at %d after inference/Close", i) + } + } +} + // Missing prerequisites fail explicitly so CI cannot silently skip real inference. func runSubjectCases(t *testing.T, cases []subjectCase) { t.Helper() @@ -47,7 +127,11 @@ func runSubjectCases(t *testing.T, cases []subjectCase) { } modelPath := os.Getenv("MODEL_PATH") if modelPath == "" { - modelPath = filepath.Join("..", "..", "models", "u2net.onnx") + selected, err := configuredModelPath() + if err != nil { + t.Fatal(err) + } + modelPath = filepath.Join("..", "..", selected) } model, err := saliency.New(library, modelPath) if err != nil { @@ -58,7 +142,7 @@ func runSubjectCases(t *testing.T, cases []subjectCase) { t.Error(err) } }) - app := newApplication(model) + app := newApplication(model, 2) analyze := func(t *testing.T, fixture testimages.Fixture, multi bool, data []byte) analyzeResponse { t.Helper() response := httptest.NewRecorder() diff --git a/cmd/autogravity/main.go b/cmd/autogravity/main.go index 61c1d59..985ab9f 100644 --- a/cmd/autogravity/main.go +++ b/cmd/autogravity/main.go @@ -11,6 +11,8 @@ import ( "net/http" "os" "os/signal" + "runtime" + "strconv" "strings" "syscall" "time" @@ -23,10 +25,12 @@ import ( const ( maxRequestBytes = 10 << 20 // 10 MiB, including multipart overhead. maxConcurrentUploads = 4 // Bounds buffered bodies without reserving inference. - maxConcurrentAnalyses = 1 // Inference is serialized; bound decoded-image memory too. + defaultIntraOpThreads = 1 // Avoid N requests multiplying ONNX worker threads. ) type analyzer interface { + // Infer borrows input only for the duration of the call. Returned data + // must remain valid independently of input and subsequent inference calls. Infer([]float32) ([]float32, error) } @@ -34,6 +38,7 @@ type application struct { model analyzer uploadSlots chan struct{} analysisSlots chan struct{} + inputBuffers chan []float32 } type analyzeResponse struct { @@ -58,20 +63,35 @@ func main() { func run() error { addr := envOrDefault("ADDR", ":8080") - modelPath := envOrDefault("MODEL_PATH", "models/u2net.onnx") + modelPath, err := configuredModelPath() + if err != nil { + return fmt.Errorf("startup: %w", err) + } runtimePath := os.Getenv("ONNXRUNTIME_LIB") + maxConcurrentAnalyses, err := positiveEnvInt("MAX_CONCURRENT_ANALYSES", runtime.GOMAXPROCS(0)) + if err != nil { + return fmt.Errorf("startup: %w", err) + } + intraOpThreads, err := positiveEnvInt("ONNX_INTRA_OP_THREADS", defaultIntraOpThreads) + if err != nil { + return fmt.Errorf("startup: %w", err) + } - model, err := saliency.New(runtimePath, modelPath) + model, err := saliency.NewWithOptions(runtimePath, modelPath, saliency.Options{ + IntraOpThreads: intraOpThreads, + }) if err != nil { return fmt.Errorf("startup: %w", err) } + slog.Info("model loaded", "path", modelPath, "architecture", runtime.GOARCH, + "analysis_slots", maxConcurrentAnalyses, "intra_op_threads", intraOpThreads) defer func() { if err := model.Close(); err != nil { slog.Error("failed to close model", "error", err) } }() - app := newApplication(model) + app := newApplication(model, maxConcurrentAnalyses) mux := http.NewServeMux() mux.HandleFunc("/analyze", app.handleAnalyze) mux.HandleFunc("/healthz", app.handleHealthz) @@ -111,6 +131,22 @@ func run() error { return nil } +// An explicit path takes precedence. Otherwise precision is identical across +// architectures; invalid settings fail rather than silently changing quality. +func configuredModelPath() (string, error) { + if path := os.Getenv("MODEL_PATH"); path != "" { + return path, nil + } + switch precision := envOrDefault("MODEL_PRECISION", "int8"); precision { + case "int8": + return "models/u2net-int8.onnx", nil + case "fp32": + return "models/u2net.onnx", nil + default: + return "", fmt.Errorf("MODEL_PRECISION must be int8 or fp32, got %q", precision) + } +} + func (app *application) handleHealthz(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.Header().Set("Allow", http.MethodGet) @@ -178,7 +214,19 @@ func (app *application) handleAnalyze(w http.ResponseWriter, r *http.Request) { return } - input, content, err := imageutil.Prepare(img, saliency.InputWidth, saliency.InputHeight) + var input []float32 + select { + case input = <-app.inputBuffers: + default: + input = make([]float32, 3*saliency.InputWidth*saliency.InputHeight) + } + defer func() { + select { + case app.inputBuffers <- input: + default: + } + }() + content, err := imageutil.PrepareInto(input, img, saliency.InputWidth, saliency.InputHeight) if err != nil { writeError(w, http.StatusInternalServerError, "failed to preprocess image") return @@ -204,11 +252,12 @@ func (app *application) handleAnalyze(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, analyzeResponse{Gravity: point, Confidence: confidence}) } -func newApplication(model analyzer) *application { +func newApplication(model analyzer, maxConcurrentAnalyses int) *application { return &application{ model: model, uploadSlots: make(chan struct{}, maxConcurrentUploads), analysisSlots: make(chan struct{}, maxConcurrentAnalyses), + inputBuffers: make(chan []float32, maxConcurrentAnalyses), } } @@ -279,3 +328,15 @@ func envOrDefault(key, fallback string) string { } return fallback } + +func positiveEnvInt(key string, fallback int) (int, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed < 1 { + return 0, fmt.Errorf("%s must be a positive integer, got %q", key, value) + } + return parsed, nil +} diff --git a/cmd/autogravity/main_test.go b/cmd/autogravity/main_test.go index 300bab3..e5b384a 100644 --- a/cmd/autogravity/main_test.go +++ b/cmd/autogravity/main_test.go @@ -24,6 +24,32 @@ type fakeAnalyzer struct { maxInFlight atomic.Int32 } +func TestConfiguredModelPath(t *testing.T) { + for _, tc := range []struct { + name, precision, path, want string + invalid bool + }{ + {"default", "", "", "models/u2net-int8.onnx", false}, + {"int8", "int8", "", "models/u2net-int8.onnx", false}, + {"fp32", "fp32", "", "models/u2net.onnx", false}, + {"invalid", "fp16", "", "", true}, + {"override", "int8", "/custom/fp32.onnx", "/custom/fp32.onnx", false}, + {"override invalid precision", "fp16", "/custom/model.onnx", "/custom/model.onnx", false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("MODEL_PATH", tc.path) + t.Setenv("MODEL_PRECISION", tc.precision) + got, err := configuredModelPath() + if (err != nil) != tc.invalid { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + type blockingReader struct { started chan struct{} release chan struct{} @@ -75,7 +101,7 @@ func (f *fakeAnalyzer) Infer(input []float32) ([]float32, error) { } func TestHandleAnalyzeRawImage(t *testing.T) { - app := newApplication(&fakeAnalyzer{}) + app := newApplication(&fakeAnalyzer{}, 2) request := httptest.NewRequest(http.MethodPost, "/analyze", bytes.NewReader(testPNG(t))) request.Header.Set("Content-Type", "image/png") response := httptest.NewRecorder() @@ -114,7 +140,7 @@ func TestHandleAnalyzeMultipartImage(t *testing.T) { request := httptest.NewRequest(http.MethodPost, "/analyze", &body) request.Header.Set("Content-Type", writer.FormDataContentType()) response := httptest.NewRecorder() - newApplication(&fakeAnalyzer{}).handleAnalyze(response, request) + newApplication(&fakeAnalyzer{}, 2).handleAnalyze(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", response.Code, response.Body.String()) @@ -142,7 +168,7 @@ func TestHandleAnalyzeErrors(t *testing.T) { request.Header.Set("Content-Type", tt.contentType) } response := httptest.NewRecorder() - newApplication(&fakeAnalyzer{}).handleAnalyze(response, request) + newApplication(&fakeAnalyzer{}, 2).handleAnalyze(response, request) if response.Code != tt.wantStatus { t.Fatalf("status = %d, want %d; body = %s", response.Code, tt.wantStatus, response.Body.String()) } @@ -152,7 +178,8 @@ func TestHandleAnalyzeErrors(t *testing.T) { func TestHandleAnalyzeBoundsConcurrentWork(t *testing.T) { analyzer := &fakeAnalyzer{delay: 10 * time.Millisecond} - app := newApplication(analyzer) + const concurrency = 2 + app := newApplication(analyzer, concurrency) data := testPNG(t) var wait sync.WaitGroup @@ -171,13 +198,38 @@ func TestHandleAnalyzeBoundsConcurrentWork(t *testing.T) { } wait.Wait() - if got := analyzer.maxInFlight.Load(); got != maxConcurrentAnalyses { - t.Fatalf("maximum concurrent analyses = %d, want %d", got, maxConcurrentAnalyses) + if got := analyzer.maxInFlight.Load(); got != concurrency { + t.Fatalf("maximum concurrent analyses = %d, want %d", got, concurrency) + } +} + +func TestPositiveEnvInt(t *testing.T) { + const key = "TEST_CONCURRENCY" + for _, tt := range []struct { + name string + value string + want int + bad bool + }{ + {name: "unset", want: 2}, + {name: "valid", value: "4", want: 4}, + {name: "trimmed", value: " 3 ", want: 3}, + {name: "zero", value: "0", bad: true}, + {name: "negative", value: "-1", bad: true}, + {name: "not a number", value: "many", bad: true}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(key, tt.value) + got, err := positiveEnvInt(key, 2) + if (err != nil) != tt.bad || got != tt.want { + t.Fatalf("positiveEnvInt() = %d, %v; want %d, bad=%v", got, err, tt.want, tt.bad) + } + }) } } func TestHandleAnalyzeSlowUploadDoesNotTakeAnalysisSlot(t *testing.T) { - app := newApplication(&fakeAnalyzer{}) + app := newApplication(&fakeAnalyzer{}, 2) slowBody := &blockingReader{started: make(chan struct{}), release: make(chan struct{})} slowRequest := httptest.NewRequest(http.MethodPost, "/analyze", slowBody) slowRequest.Header.Set("Content-Type", "image/png") @@ -203,7 +255,9 @@ func TestHandleAnalyzeSlowUploadDoesNotTakeAnalysisSlot(t *testing.T) { func TestHandleAnalyzeBoundsBufferedUploads(t *testing.T) { analyzer := &gatedAnalyzer{started: make(chan struct{}), release: make(chan struct{})} - app := newApplication(analyzer) + // This test isolates upload admission by deliberately occupying the only + // analysis slot. Concurrent analysis is covered separately above. + app := newApplication(analyzer, 1) data := testPNG(t) var wait sync.WaitGroup @@ -247,7 +301,7 @@ func TestHandleAnalyzeBoundsBufferedUploads(t *testing.T) { } func TestHandleHealthz(t *testing.T) { - app := newApplication(&fakeAnalyzer{}) + app := newApplication(&fakeAnalyzer{}, 2) request := httptest.NewRequest(http.MethodGet, "/healthz", nil) response := httptest.NewRecorder() @@ -272,7 +326,7 @@ func TestHandleHealthzErrors(t *testing.T) { t.Run("method not allowed", func(t *testing.T) { request := httptest.NewRequest(http.MethodPost, "/healthz", nil) response := httptest.NewRecorder() - newApplication(&fakeAnalyzer{}).handleHealthz(response, request) + newApplication(&fakeAnalyzer{}, 2).handleHealthz(response, request) if response.Code != http.StatusMethodNotAllowed { t.Fatalf("status = %d, want 405; body = %s", response.Code, response.Body.String()) } @@ -281,7 +335,7 @@ func TestHandleHealthzErrors(t *testing.T) { t.Run("model not ready", func(t *testing.T) { request := httptest.NewRequest(http.MethodGet, "/healthz", nil) response := httptest.NewRecorder() - newApplication(nil).handleHealthz(response, request) + newApplication(nil, 2).handleHealthz(response, request) if response.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503; body = %s", response.Code, response.Body.String()) } diff --git a/internal/benchmark/pipeline_test.go b/internal/benchmark/pipeline_test.go index 3b26915..c16b05e 100644 --- a/internal/benchmark/pipeline_test.go +++ b/internal/benchmark/pipeline_test.go @@ -28,7 +28,15 @@ func BenchmarkAnalyze(b *testing.B) { projectRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..")) modelPath := os.Getenv("MODEL_PATH") if modelPath == "" { - modelPath = filepath.Join(projectRoot, "models", "u2net.onnx") + name := "u2net-int8.onnx" + switch precision := os.Getenv("MODEL_PRECISION"); precision { + case "", "int8": + case "fp32": + name = "u2net.onnx" + default: + b.Fatalf("MODEL_PRECISION must be int8 or fp32, got %q", precision) + } + modelPath = filepath.Join(projectRoot, "models", name) } model, err := saliency.New(runtimeLibrary, modelPath) diff --git a/internal/imageutil/benchmark_test.go b/internal/imageutil/benchmark_test.go new file mode 100644 index 0000000..f5f3e16 --- /dev/null +++ b/internal/imageutil/benchmark_test.go @@ -0,0 +1,65 @@ +package imageutil + +import ( + "image" + "testing" + + "autogravity/internal/testimages" +) + +var preparedTensor []float32 + +func BenchmarkPrepareInto(b *testing.B) { + for _, name := range []string{"portrait.jpg", "rose-alpha.webp"} { + b.Run(name, func(b *testing.B) { + img, err := Decode(testimages.Read(b, name)) + if err != nil { + b.Fatal(err) + } + dst := make([]float32, 3*320*320) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if _, err := PrepareInto(dst, img, 320, 320); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func BenchmarkPrepare(b *testing.B) { + for _, name := range []string{"portrait.jpg", "rose-alpha.webp"} { + b.Run(name, func(b *testing.B) { + img, err := Decode(testimages.Read(b, name)) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + preparedTensor, _, err = Prepare(img, 320, 320) + if err != nil { + b.Fatal(err) + } + } + }) + } +} + +// This isolates normalization from resizing and includes every alpha value. +func BenchmarkPrepareAlreadySized(b *testing.B) { + img := image.NewNRGBA(image.Rect(0, 0, 320, 320)) + for i := range img.Pix { + img.Pix[i] = byte(i / 7) + } + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + var err error + preparedTensor, _, err = Prepare(img, 320, 320) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/imageutil/image.go b/internal/imageutil/image.go index 9444f81..1e8b6d9 100644 --- a/internal/imageutil/image.go +++ b/internal/imageutil/image.go @@ -49,6 +49,21 @@ func Decode(data []byte) (image.Image, error) { // aspect ratio. It returns a normalized RGB NCHW tensor and the rectangle // occupied by the image; unused tensor pixels are neutral zero padding. func Prepare(img image.Image, width, height int) ([]float32, image.Rectangle, error) { + return prepare(nil, img, width, height) +} + +// PrepareInto reuses dst for the normalized tensor. Its length must be exactly +// 3*width*height. Padding is cleared on every call, including when dst is reused +// for images with different aspect ratios. +func PrepareInto(dst []float32, img image.Image, width, height int) (image.Rectangle, error) { + if width <= 0 || height <= 0 || len(dst) != 3*width*height { + return image.Rectangle{}, errors.New("invalid preprocessing buffer dimensions") + } + _, content, err := prepare(dst, img, width, height) + return content, err +} + +func prepare(tensor []float32, img image.Image, width, height int) ([]float32, image.Rectangle, error) { if img == nil || width <= 0 || height <= 0 { return nil, image.Rectangle{}, errors.New("invalid preprocessing dimensions") } @@ -67,13 +82,24 @@ func Prepare(img image.Image, width, height int) ([]float32, image.Rectangle, er resized := imaging.Resize(img, resizedWidth, resizedHeight, imaging.Linear) pixels := width * height - tensor := make([]float32, 3*pixels) + if tensor == nil { + tensor = make([]float32, 3*pixels) + } else { + clear(tensor) + } mean := [3]float32{0.485, 0.456, 0.406} stddev := [3]float32{0.229, 0.224, 0.225} for y := 0; y < resizedHeight; y++ { + row := resized.Pix[y*resized.Stride : y*resized.Stride+4*resizedWidth] for x := 0; x < resizedWidth; x++ { - r, g, b, _ := resized.At(x, y).RGBA() + p := row[4*x : 4*x+4] + // Match color.NRGBA.RGBA's 16-bit premultiplication, including + // its integer rounding for partially transparent pixels. + a := uint32(p[3]) + r := uint32(p[0]) * 257 * a / 255 + g := uint32(p[1]) * 257 * a / 255 + b := uint32(p[2]) * 257 * a / 255 values := [3]float32{ float32(r) / 65535, float32(g) / 65535, diff --git a/internal/imageutil/image_test.go b/internal/imageutil/image_test.go index 7eec209..555beed 100644 --- a/internal/imageutil/image_test.go +++ b/internal/imageutil/image_test.go @@ -12,6 +12,58 @@ import ( "testing" ) +func TestPreparePreservesPremultiplication(t *testing.T) { + img := image.NewNRGBA(image.Rect(0, 0, 256, 256)) + for a := range 256 { + for c := range 256 { + img.SetNRGBA(c, a, color.NRGBA{R: byte(c), G: byte(c), B: byte(c), A: byte(a)}) + } + } + got, _, err := Prepare(img, 256, 256) + if err != nil { + t.Fatal(err) + } + mean := [3]float32{0.485, 0.456, 0.406} + stddev := [3]float32{0.229, 0.224, 0.225} + for a := range 256 { + for c := range 256 { + r, _, _, _ := img.At(c, a).RGBA() + for channel := range 3 { + want := (float32(r)/65535 - mean[channel]) / stddev[channel] + if value := got[channel*256*256+a*256+c]; value != want { + t.Fatalf("alpha=%d color=%d channel=%d: got %v, want %v", a, c, channel, value, want) + } + } + } + } +} + +func TestPrepareIntoClearsReusedPadding(t *testing.T) { + dst := make([]float32, 3*32*32) + for _, size := range []image.Point{{32, 32}, {32, 8}, {8, 32}, {1, 1}} { + img := image.NewNRGBA(image.Rectangle{Max: size}) + for i := range img.Pix { + img.Pix[i] = 255 + } + want, wantContent, err := Prepare(img, 32, 32) + if err != nil { + t.Fatal(err) + } + content, err := PrepareInto(dst, img, 32, 32) + if err != nil || content != wantContent { + t.Fatalf("PrepareInto(%v) = %v, %v", size, content, err) + } + for i := range dst { + if dst[i] != want[i] { + t.Fatalf("size=%v index=%d: got %v, want %v", size, i, dst[i], want[i]) + } + } + } + if _, err := PrepareInto(dst[:len(dst)-1], image.NewGray(image.Rect(0, 0, 1, 1)), 32, 32); err == nil { + t.Fatal("accepted wrong-sized buffer") + } +} + func TestDecodeWebP(t *testing.T) { data, err := base64.StdEncoding.DecodeString( "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA", diff --git a/internal/saliency/model.go b/internal/saliency/model.go index a403b3a..6deac15 100644 --- a/internal/saliency/model.go +++ b/internal/saliency/model.go @@ -16,24 +16,63 @@ const ( // Model owns a single reusable ONNX Runtime session. type Model struct { - mu sync.Mutex + mu sync.RWMutex session *ort.DynamicAdvancedSession closed bool } +// Options controls the ONNX Runtime session used by the model. +type Options struct { + // IntraOpThreads is the number of threads used within each operator. Zero + // keeps ONNX Runtime's default. Use one when running multiple requests in + // parallel on a CPU-constrained container to avoid oversubscription. + IntraOpThreads int +} + // New initializes ONNX Runtime and loads the model once. func New(runtimeLibraryPath, modelPath string) (*Model, error) { + return NewWithOptions(runtimeLibraryPath, modelPath, Options{}) +} + +// NewWithOptions initializes ONNX Runtime and loads the model once with the +// supplied 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("model path is required") } + if options.IntraOpThreads < 0 { + return nil, errors.New("ONNX intra-op threads must be at least zero") + } ort.SetSharedLibraryPath(runtimeLibraryPath) if err := ort.InitializeEnvironment(); 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() + } + }() + + sessionOptions, err := ort.NewSessionOptions() + if err != nil { + return nil, fmt.Errorf("create ONNX Runtime session options: %w", err) + } + defer sessionOptions.Destroy() + if err := sessionOptions.SetIntraOpNumThreads(options.IntraOpThreads); err != nil { + return nil, fmt.Errorf("configure ONNX Runtime intra-op threads: %w", err) + } + // Sequential mode avoids creating an inter-op thread pool. Independent + // requests still run concurrently by calling Run on the shared session. + if err := sessionOptions.SetExecutionMode(ort.ExecutionModeSequential); err != nil { + return nil, fmt.Errorf("configure ONNX Runtime execution mode: %w", err) + } // U2-Net exposes seven side outputs. The first (1959) is the fused, // highest-resolution saliency map and is the only output needed here. @@ -41,13 +80,13 @@ func New(runtimeLibraryPath, modelPath string) (*Model, error) { modelPath, []string{"input.1"}, []string{"1959"}, - nil, + sessionOptions, ) if err != nil { - _ = ort.DestroyEnvironment() return nil, fmt.Errorf("load saliency model: %w", err) } + loaded = true return &Model{session: session}, nil } @@ -56,6 +95,14 @@ func (m *Model) Infer(input []float32) ([]float32, error) { if len(input) != 3*InputWidth*InputHeight { return nil, fmt.Errorf("invalid input tensor length: got %d", len(input)) } + // ONNX Runtime supports concurrent Run calls on one CPU session. Hold the + // lifecycle read lock across all runtime calls so Close cannot destroy the + // environment while tensors or inference are active. + m.mu.RLock() + defer m.mu.RUnlock() + if m.closed { + return nil, errors.New("saliency model is closed") + } inputTensor, err := ort.NewTensor( ort.NewShape(1, 3, InputHeight, InputWidth), @@ -66,19 +113,18 @@ func (m *Model) Infer(input []float32) ([]float32, error) { } defer inputTensor.Destroy() - outputTensor, err := ort.NewEmptyTensor[float32]( + // Keep ownership of the Go output buffer: destroying the ONNX tensor + // releases the runtime wrapper, not this backing slice. + result := make([]float32, InputWidth*InputHeight) + outputTensor, err := ort.NewTensor( ort.NewShape(1, 1, InputHeight, InputWidth), + result, ) if err != nil { return nil, fmt.Errorf("create output tensor: %w", err) } defer outputTensor.Destroy() - m.mu.Lock() - defer m.mu.Unlock() - if m.closed { - return nil, errors.New("saliency model is closed") - } if err := m.session.Run( []ort.Value{inputTensor}, []ort.Value{outputTensor}, @@ -86,8 +132,6 @@ func (m *Model) Infer(input []float32) ([]float32, error) { return nil, fmt.Errorf("run saliency model: %w", err) } - result := make([]float32, InputWidth*InputHeight) - copy(result, outputTensor.GetData()) return result, nil } diff --git a/internal/saliency/model_test.go b/internal/saliency/model_test.go index e9039a3..aa1f39a 100644 --- a/internal/saliency/model_test.go +++ b/internal/saliency/model_test.go @@ -19,6 +19,13 @@ func TestNewRequiresPaths(t *testing.T) { } } +func TestNewRejectsNegativeIntraOpThreadsBeforeRuntimeAccess(t *testing.T) { + model, err := NewWithOptions("runtime.so", "model.onnx", Options{IntraOpThreads: -1}) + if model != nil || err == nil || err.Error() != "ONNX intra-op threads must be at least zero" { + t.Fatalf("NewWithOptions() = %v, %v", model, err) + } +} + func TestInferRejectsInvalidTensorBeforeRuntimeAccess(t *testing.T) { model := &Model{} for _, length := range []int{0, 1, InputWidth * InputHeight, 3*InputWidth*InputHeight - 1, 3*InputWidth*InputHeight + 1} { diff --git a/models/README.md b/models/README.md new file mode 100644 index 0000000..4c12aec --- /dev/null +++ b/models/README.md @@ -0,0 +1,29 @@ +# Model artifacts + +The default, `u2net-int8.onnx`, is included in this repository (44,211,876 +bytes) so clean builds need no private host or mutable experimental download. +Its SHA-256 is verified by `make model` and Docker builds: + +```text +b340186f56660b6665e494aab912e5f8e9adbc2317181c77fd01aa226f06553b +``` + +This is a modified [U²-Net](https://github.com/xuebinqin/U-2-Net) model, whose +upstream Apache-2.0 license is included in `U2NET_LICENSE`. The original FP32 ONNX +artifact is obtained from the rembg release URL pinned in the Dockerfile and +Makefile (SHA-256 `8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491`). + +Modifications made on 2026-09-07: migrate the graph to opset 13, preprocess it, +and apply static MinMax, per-channel, reduced-range U8/S8 QOperator quantization +with ONNX Runtime 1.23.2. Calibration uses 128 ECSSD images with native x86 Go +preprocessing. The user-provided panda and all evaluation images were excluded +from calibration. Evaluation used 200 separate ECSSD images plus nine extra +fixtures. Median focal-point shift versus FP32 was 0.10% of an image dimension, +p95 was 0.91%, and the maximum was 7.4%. At four CPUs on the tested x86 Xeon, +two HTTP runs measured 38–45% more throughput and 64–65% less peak cgroup memory. +Raw calibration and evaluation images are not bundled with the model. + +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. diff --git a/models/U2NET_LICENSE b/models/U2NET_LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/models/U2NET_LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/models/u2net-int8.onnx b/models/u2net-int8.onnx new file mode 100644 index 0000000..f6c7c28 Binary files /dev/null and b/models/u2net-int8.onnx differ