Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/src/lib/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions docs/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function DocsPage() {
<p className="doc-lead">
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.
</p>
<div className="pill-row">
Expand Down Expand Up @@ -120,7 +120,7 @@ function DocsPage() {
<HttpEndpoint
method="POST"
path="/analyze"
description="Returns the saliency-weighted focal point as normalized coordinates."
description="Returns the strongest salient region's weighted focal point as normalized coordinates."
/>

<div className="code-grid">
Expand Down
75 changes: 65 additions & 10 deletions internal/gravity/gravity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -31,38 +36,88 @@ 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
}
}
}

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
Expand Down
61 changes: 56 additions & 5 deletions internal/gravity/gravity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
},
{
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,
},
{
Expand Down Expand Up @@ -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.
Expand Down