diff --git a/README.md b/README.md index 12ec383..382c7e1 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ # autogravity `autogravity` is a small Go HTTP service that finds the main visual subject in -an image. It runs U²-Net with ONNX Runtime and returns the saliency-weighted -centroid as normalized X/Y coordinates. It never crops, stores, or modifies the -submitted image. +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. ## Requirements @@ -156,7 +156,9 @@ Example response: 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. Confidence is +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]`. Requests are limited to 10 MiB and decoded images to 20 megapixels. Separate diff --git a/docs/src/lib/site.ts b/docs/src/lib/site.ts index 6bad3c0..1a57e6d 100644 --- a/docs/src/lib/site.ts +++ b/docs/src/lib/site.ts @@ -3,7 +3,7 @@ export const SITE = { title: 'autogravity docs', url: 'https://6a9e5e660013e2481b3d.appwrite.network', description: - 'Image focal-point detection as a microservice. Post an image, get back one coordinate pair: the saliency-weighted centre of the main subject.', + "Image focal-point detection as a microservice. Post an image, get back one coordinate pair: the weighted centre of the strongest salient region.", tagline: 'Find the subject. Crop nothing.', github: 'https://github.com/appwrite/autogravity', appwrite: 'https://appwrite.io', diff --git a/docs/src/routes/index.tsx b/docs/src/routes/index.tsx index 5702620..8505294 100644 --- a/docs/src/routes/index.tsx +++ b/docs/src/routes/index.tsx @@ -20,7 +20,7 @@ function DocsPage() {

A small Go HTTP service that finds the main visual subject in an image. It runs U²-Net with ONNX Runtime and returns the - saliency-weighted centroid as normalized X/Y coordinates. It never + weighted centroid of the strongest salient region as normalized X/Y coordinates. It never crops, stores, or modifies the submitted image.

@@ -120,7 +120,7 @@ function DocsPage() {
diff --git a/internal/gravity/gravity.go b/internal/gravity/gravity.go index c445f9c..57456cc 100644 --- a/internal/gravity/gravity.go +++ b/internal/gravity/gravity.go @@ -13,15 +13,20 @@ type Point struct { Y float64 `json:"y"` } -// FromSaliency returns the saliency-weighted centroid and the peak saliency -// value as confidence. Values that are negative, NaN, or infinite contribute -// no weight. A map with no usable saliency falls back to the image center. +// FromSaliency returns the centroid of the strongest connected salient region +// and the peak saliency value as confidence. Values that are negative, NaN, or +// infinite contribute no weight. A map with no usable saliency falls back to +// the image center. func FromSaliency(saliency []float32, width, height int) (Point, float64, error) { return FromSaliencyRegion(saliency, width, height, image.Rect(0, 0, width, height)) } // FromSaliencyRegion calculates a focal point from a rectangular image region // within a larger saliency map, ignoring any surrounding letterbox padding. +// Pixels at least half as salient as the peak are grouped into 8-connected +// components. The component with the greatest total saliency is selected so +// separated subjects do not produce a focal point in the empty space between +// them. func FromSaliencyRegion(saliency []float32, width, height int, region image.Rectangle) (Point, float64, error) { if width <= 0 || height <= 0 || len(saliency) != width*height { return Point{}, 0, errors.New("invalid saliency map dimensions") @@ -31,16 +36,13 @@ func FromSaliencyRegion(saliency []float32, width, height int, region image.Rect return Point{}, 0, errors.New("invalid saliency map region") } - var total, weightedX, weightedY, peak float64 + var peak float64 for y := region.Min.Y; y < region.Max.Y; y++ { for x := region.Min.X; x < region.Max.X; x++ { weight := float64(saliency[y*width+x]) if weight <= 0 || math.IsNaN(weight) || math.IsInf(weight, 0) { continue } - total += weight - weightedX += float64(x-region.Min.X) * weight - weightedY += float64(y-region.Min.Y) * weight if weight > peak { peak = weight } @@ -48,21 +50,74 @@ func FromSaliencyRegion(saliency []float32, width, height int, region image.Rect } confidence := clamp01(peak) - if total == 0 { + if peak == 0 { return Point{X: 0.5, Y: 0.5}, confidence, nil } + type component struct { + total float64 + weightedX, weightedY float64 + } + threshold := peak * 0.5 + visited := make([]bool, len(saliency)) + var strongest component + directions := [...]image.Point{ + {X: -1, Y: -1}, {X: 0, Y: -1}, {X: 1, Y: -1}, + {X: -1, Y: 0}, {X: 1, Y: 0}, + {X: -1, Y: 1}, {X: 0, Y: 1}, {X: 1, Y: 1}, + } + for y := region.Min.Y; y < region.Max.Y; y++ { + for x := region.Min.X; x < region.Max.X; x++ { + index := y*width + x + if visited[index] || !usableAtThreshold(saliency[index], threshold) { + continue + } + visited[index] = true + queue := []image.Point{{X: x, Y: y}} + var current component + for len(queue) > 0 { + point := queue[len(queue)-1] + queue = queue[:len(queue)-1] + weight := float64(saliency[point.Y*width+point.X]) + current.total += weight + current.weightedX += float64(point.X-region.Min.X) * weight + current.weightedY += float64(point.Y-region.Min.Y) * weight + + for _, direction := range directions { + neighbor := point.Add(direction) + if !neighbor.In(region) { + continue + } + neighborIndex := neighbor.Y*width + neighbor.X + if visited[neighborIndex] || !usableAtThreshold(saliency[neighborIndex], threshold) { + continue + } + visited[neighborIndex] = true + queue = append(queue, neighbor) + } + } + if current.total > strongest.total { + strongest = current + } + } + } + x, y := 0.5, 0.5 if region.Dx() > 1 { - x = weightedX / total / float64(region.Dx()-1) + x = strongest.weightedX / strongest.total / float64(region.Dx()-1) } if region.Dy() > 1 { - y = weightedY / total / float64(region.Dy()-1) + y = strongest.weightedY / strongest.total / float64(region.Dy()-1) } return Point{X: clamp01(x), Y: clamp01(y)}, confidence, nil } +func usableAtThreshold(value float32, threshold float64) bool { + weight := float64(value) + return weight >= threshold && !math.IsNaN(weight) && !math.IsInf(weight, 0) +} + func clamp01(v float64) float64 { if v < 0 { return 0 diff --git a/internal/gravity/gravity_test.go b/internal/gravity/gravity_test.go index 932f418..97e6b25 100644 --- a/internal/gravity/gravity_test.go +++ b/internal/gravity/gravity_test.go @@ -24,11 +24,33 @@ func TestFromSaliency(t *testing.T) { confidence: 1, }, { - name: "weighted centroid", - mapData: []float32{1, 0, 0, 3}, - width: 2, - height: 2, - want: Point{X: 0.75, Y: 0.75}, + name: "strongest disconnected region", + mapData: []float32{1, 0, 3}, + width: 3, + height: 1, + want: Point{X: 1, Y: 0.5}, + confidence: 1, + }, + { + name: "weighted centroid within strongest region", + mapData: []float32{1, 1, 0, 0, 0, 0, 0.75, 0, 0}, + width: 3, + height: 3, + want: Point{X: 0.25, Y: 0}, + confidence: 1, + }, + { + name: "diagonally connected pixels form one region", + mapData: []float32{ + 1, 0, 0, 0, 0.75, + 0, 1, 0, 0, 0.75, + 0, 0, 1, 0, 0, + 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, + }, + width: 5, + height: 5, + want: Point{X: 0.25, Y: 0.25}, confidence: 1, }, { @@ -63,6 +85,35 @@ func TestFromSaliencyRejectsInvalidDimensions(t *testing.T) { } } +// Regression for panoramic images with two distant subjects. Computing one +// centroid across both regions places gravity in the empty middle; selecting +// the region with greater integrated saliency keeps it on a subject. +func TestFromSaliencySelectsStrongestSeparatedSubject(t *testing.T) { + const width, height = 11, 5 + mapData := make([]float32, width*height) + + // Both subjects reach the same peak confidence, but the left subject has + // greater saliency mass (6.0 versus 4.0). + for _, point := range []image.Point{{1, 1}, {2, 1}, {1, 2}, {2, 2}, {1, 3}, {2, 3}} { + mapData[point.Y*width+point.X] = 1 + } + for _, point := range []image.Point{{8, 1}, {9, 1}, {8, 2}, {9, 2}} { + mapData[point.Y*width+point.X] = 1 + } + + got, confidence, err := FromSaliency(mapData, width, height) + if err != nil { + t.Fatal(err) + } + want := Point{X: 0.15, Y: 0.5} + if !closeEnough(got.X, want.X) || !closeEnough(got.Y, want.Y) { + t.Fatalf("FromSaliency() = %+v, want strongest left subject at %+v", got, want) + } + if confidence != 1 { + t.Fatalf("confidence = %v, want 1", confidence) + } +} + func TestFromSaliencyRegionIgnoresPadding(t *testing.T) { mapData := make([]float32, 4*4) mapData[0] = 10 // Padding must not affect the result or confidence.