initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
122
api/internal/analysis/analysis.go
Normal file
122
api/internal/analysis/analysis.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// Package analysis inspects an uploaded picture and derives badges describing
|
||||
// what can be recognised in it.
|
||||
//
|
||||
// Two implementations are provided. LocalAnalyzer measures the picture's own
|
||||
// pixels and EXIF metadata and turns those measurements into scene and content
|
||||
// badges; it needs no external service and is fully deterministic. RemoteAnalyzer
|
||||
// delegates to an external inference service (a trained object-detection model)
|
||||
// and returns the labels that model predicts. The two can be combined, in which
|
||||
// case model labels take precedence over heuristics of the same name.
|
||||
package analysis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go-places-api/internal/domain"
|
||||
)
|
||||
|
||||
// Input is the picture to analyse.
|
||||
type Input struct {
|
||||
// ContentType is the MIME type as supplied by the client.
|
||||
ContentType string
|
||||
// Bytes is the complete encoded picture.
|
||||
Bytes []byte
|
||||
// MaxDimension bounds the longest edge used for pixel measurements. The
|
||||
// picture is downscaled before sampling so that cost does not grow with
|
||||
// megapixels. Zero means use the package default.
|
||||
MaxDimension int
|
||||
// Hints carries scene labels already known about the picture, which lets a
|
||||
// remote analyzer disambiguate using earlier results.
|
||||
Hints []string
|
||||
}
|
||||
|
||||
// Metadata is the capture information recovered from EXIF, if any.
|
||||
type Metadata struct {
|
||||
// Coordinate is the GPS position from the EXIF GPS IFD.
|
||||
Coordinate *domain.Coordinate
|
||||
// TakenAt is the original capture timestamp.
|
||||
TakenAt *time.Time
|
||||
// Make and Model identify the camera or phone.
|
||||
Make string
|
||||
Model string
|
||||
}
|
||||
|
||||
// Signals are the measurements taken from the pixels. Every field is
|
||||
// normalised to 0..1 so the badge rules can use a single comparable scale.
|
||||
type Signals struct {
|
||||
// Width and Height are the dimensions of the sampled image.
|
||||
Width int
|
||||
Height int
|
||||
|
||||
// Brightness is the mean perceptual luminance.
|
||||
Brightness float64
|
||||
// Saturation is the mean colour saturation.
|
||||
Saturation float64
|
||||
// Colorfulness is the spread of hue across the image, a stand-in for how
|
||||
// varied the scene is.
|
||||
Colorfulness float64
|
||||
// EdgeDensity is the fraction of pixels sitting on a strong gradient, which
|
||||
// indicates how much fine detail the picture holds.
|
||||
EdgeDensity float64
|
||||
|
||||
// HueWeights holds the fraction of pixels falling into each colour class:
|
||||
// blue, green, warm (red/orange/yellow), purple and achromatic.
|
||||
HueWeights HueWeights
|
||||
|
||||
// Top, Middle and Bottom hold the same measurements restricted to the
|
||||
// upper, central and lower horizontal bands of the picture. Sky, ground
|
||||
// and water all separate cleanly this way.
|
||||
Top Band
|
||||
Middle Band
|
||||
Bottom Band
|
||||
}
|
||||
|
||||
// Band is the measurement set for one horizontal third of a picture.
|
||||
type Band struct {
|
||||
Brightness float64
|
||||
Saturation float64
|
||||
EdgeDensity float64
|
||||
HueWeights HueWeights
|
||||
PixelShare float64
|
||||
PixelCount int
|
||||
}
|
||||
|
||||
// HueWeights is the distribution of pixels across colour classes. The values
|
||||
// sum to approximately 1 for a fully populated band.
|
||||
type HueWeights struct {
|
||||
Blue float64
|
||||
Green float64
|
||||
Warm float64
|
||||
// Purple covers magenta and violet, which read as neither blue nor warm.
|
||||
Purple float64
|
||||
// Achromatic is the share of pixels with little or no hue: greys, whites,
|
||||
// blacks and near-neutrals.
|
||||
Achromatic float64
|
||||
}
|
||||
|
||||
// Result is everything the pipeline learned about a picture.
|
||||
type Result struct {
|
||||
// Width and Height are the true dimensions of the encoded picture.
|
||||
Width int
|
||||
Height int
|
||||
// Signals are the pixel measurements, retained for debugging and for the
|
||||
// remote analyzer's fallback.
|
||||
Signals Signals
|
||||
// Metadata is what EXIF reported.
|
||||
Metadata Metadata
|
||||
// Badges are the conclusions drawn from the above.
|
||||
Badges []domain.Badge
|
||||
// Note carries a non-fatal warning, such as a degraded analyzer, so the
|
||||
// caller can record why the badge set may be incomplete.
|
||||
Note string
|
||||
}
|
||||
|
||||
// Analyzer inspects a picture.
|
||||
type Analyzer interface {
|
||||
// Name identifies the analyzer in badge sources.
|
||||
Name() string
|
||||
// Analyze inspects the picture. It returns an error only when the picture
|
||||
// cannot be inspected at all; a picture that yields no badges is a success.
|
||||
Analyze(ctx context.Context, in Input) (*Result, error)
|
||||
}
|
||||
410
api/internal/analysis/analysis_test.go
Normal file
410
api/internal/analysis/analysis_test.go
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
package analysis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-places-api/internal/domain"
|
||||
)
|
||||
|
||||
// pngOf encodes an RGBA image as PNG bytes.
|
||||
func pngOf(t *testing.T, img image.Image) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// solid returns a uniformly coloured picture.
|
||||
func solid(w, h int, c color.RGBA) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.SetRGBA(x, y, c)
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// banded returns a picture whose horizontal thirds are the given colours, which
|
||||
// is how the tests construct skies, ground and horizons.
|
||||
func banded(top, middle, bottom color.RGBA) *image.RGBA {
|
||||
const w, h = 60, 60
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
c := top
|
||||
switch {
|
||||
case y >= h*2/3:
|
||||
c = bottom
|
||||
case y >= h/3:
|
||||
c = middle
|
||||
}
|
||||
for x := 0; x < w; x++ {
|
||||
img.SetRGBA(x, y, c)
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func analyzePNG(t *testing.T, img image.Image) *Result {
|
||||
t.Helper()
|
||||
a := NewLocal(DefaultMaxDimension)
|
||||
res, err := a.Analyze(context.Background(), Input{
|
||||
ContentType: "image/png",
|
||||
Bytes: pngOf(t, img),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Analyze: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// labels collects the badge labels from a result.
|
||||
func labels(res *Result) []string {
|
||||
out := make([]string, 0, len(res.Badges))
|
||||
for _, b := range res.Badges {
|
||||
out = append(out, b.Label)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasBadge(res *Result, label string) bool {
|
||||
for _, b := range res.Badges {
|
||||
if b.Label == label {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func confidenceOf(res *Result, label string) (float64, bool) {
|
||||
for _, b := range res.Badges {
|
||||
if b.Label == label {
|
||||
return b.Confidence, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var (
|
||||
skyBlue = color.RGBA{R: 60, G: 130, B: 220, A: 255}
|
||||
grassGreen = color.RGBA{R: 60, G: 160, B: 70, A: 255}
|
||||
sandColor = color.RGBA{R: 220, G: 195, B: 140, A: 255}
|
||||
sunsetRed = color.RGBA{R: 230, G: 110, B: 60, A: 255}
|
||||
midGrey = color.RGBA{R: 128, G: 128, B: 128, A: 255}
|
||||
darkGrey = color.RGBA{R: 30, G: 30, B: 30, A: 255}
|
||||
white = color.RGBA{R: 250, G: 250, B: 250, A: 255}
|
||||
)
|
||||
|
||||
func TestAnalyzeReportsTrueDimensions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A small picture is sampled at its own size; a large one is downscaled,
|
||||
// but the reported dimensions must remain the encoded ones.
|
||||
for _, dims := range [][2]int{{40, 30}, {800, 600}} {
|
||||
w, h := dims[0], dims[1]
|
||||
res := analyzePNG(t, solid(w, h, skyBlue))
|
||||
if res.Width != w || res.Height != h {
|
||||
t.Errorf("dimensions = %dx%d, want %dx%d", res.Width, res.Height, w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDetectsSky(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Blue upper band over green ground: the classic outdoor landscape.
|
||||
res := analyzePNG(t, banded(skyBlue, grassGreen, grassGreen))
|
||||
|
||||
if !hasBadge(res, labelSky) {
|
||||
t.Errorf("expected a sky badge, got %v", labels(res))
|
||||
}
|
||||
if !hasBadge(res, labelVegetation) {
|
||||
t.Errorf("expected a vegetation badge, got %v", labels(res))
|
||||
}
|
||||
conf, ok := confidenceOf(res, labelSky)
|
||||
if !ok {
|
||||
t.Fatal("sky badge has no confidence")
|
||||
}
|
||||
if conf <= 0 || conf > 1 {
|
||||
t.Errorf("sky confidence = %v, want a value in (0,1]", conf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDetectsBeach(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Sky above warm sand below.
|
||||
res := analyzePNG(t, banded(skyBlue, sandColor, sandColor))
|
||||
if !hasBadge(res, labelBeach) {
|
||||
t.Errorf("expected a beach badge, got %v", labels(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDetectsWater(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Blue concentrated in the lower band is water rather than sky.
|
||||
res := analyzePNG(t, banded(midGrey, skyBlue, skyBlue))
|
||||
if !hasBadge(res, labelWater) {
|
||||
t.Errorf("expected a water badge, got %v", labels(res))
|
||||
}
|
||||
if hasBadge(res, labelSky) {
|
||||
t.Errorf("did not expect a sky badge when the blue is in the lower band, got %v",
|
||||
labels(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDetectsMonochrome(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res := analyzePNG(t, solid(50, 50, midGrey))
|
||||
if !hasBadge(res, labelMonochrome) {
|
||||
t.Errorf("expected a monochrome badge, got %v", labels(res))
|
||||
}
|
||||
if hasBadge(res, labelVivid) {
|
||||
t.Errorf("did not expect a vivid badge from a grey picture, got %v", labels(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDetectsLowLightAndNight(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dark := analyzePNG(t, solid(50, 50, darkGrey))
|
||||
if !hasBadge(dark, labelLowLight) {
|
||||
t.Errorf("expected a low-light badge, got %v", labels(dark))
|
||||
}
|
||||
if !hasBadge(dark, labelNight) {
|
||||
t.Errorf("expected a night badge from brightness alone, got %v", labels(dark))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDetectsOverexposed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bright := analyzePNG(t, solid(50, 50, white))
|
||||
if !hasBadge(bright, labelOverexposed) {
|
||||
t.Errorf("expected an overexposed badge, got %v", labels(bright))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDetectsOrientation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
w, h int
|
||||
want, notWant string
|
||||
}{
|
||||
// Ratios sit comfortably inside each band; a picture whose ratio lands
|
||||
// exactly on a band edge scores zero confidence, which is by design.
|
||||
{"tall", 45, 60, labelPortrait, labelLandscape},
|
||||
{"wide", 60, 45, labelLandscape, labelPortrait},
|
||||
{"square", 40, 40, "", labelPanorama},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
res := analyzePNG(t, solid(tc.w, tc.h, midGrey))
|
||||
if tc.want != "" && !hasBadge(res, tc.want) {
|
||||
t.Errorf("expected %q badge, got %v", tc.want, labels(res))
|
||||
}
|
||||
if tc.notWant != "" && hasBadge(res, tc.notWant) {
|
||||
t.Errorf("did not expect %q badge, got %v", tc.notWant, labels(res))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDetectsPanorama(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res := analyzePNG(t, solid(120, 40, midGrey))
|
||||
if !hasBadge(res, labelPanorama) {
|
||||
t.Errorf("expected a panorama badge for a 3:1 picture, got %v", labels(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeIsDeterministic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
img := banded(skyBlue, grassGreen, sandColor)
|
||||
first := labels(analyzePNG(t, img))
|
||||
for i := 0; i < 3; i++ {
|
||||
got := labels(analyzePNG(t, img))
|
||||
if len(got) != len(first) {
|
||||
t.Fatalf("run %d produced %v, want %v", i, got, first)
|
||||
}
|
||||
for j := range got {
|
||||
if got[j] != first[j] {
|
||||
t.Errorf("run %d produced %v, want %v", i, got, first)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeRejectsBadInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := NewLocal(DefaultMaxDimension)
|
||||
|
||||
if _, err := a.Analyze(context.Background(), Input{Bytes: nil}); err == nil {
|
||||
t.Error("expected an error for an empty payload")
|
||||
}
|
||||
if _, err := a.Analyze(context.Background(), Input{Bytes: []byte("not an image")}); err == nil {
|
||||
t.Error("expected an error for a payload that is not an image")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeRespectsContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
a := NewLocal(DefaultMaxDimension)
|
||||
_, err := a.Analyze(ctx, Input{ContentType: "image/png", Bytes: pngOf(t, solid(20, 20, midGrey))})
|
||||
if err == nil {
|
||||
t.Error("expected a cancelled context to fail the analysis")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCombinePrefersModelLabels checks the merge rule: a model label wins the
|
||||
// display name, but a confident heuristic is not thrown away.
|
||||
func TestCombinePrefersModelLabels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
local := []domain.Badge{
|
||||
{Label: labelSky, Display: "Sky", Confidence: 0.9, Source: SourceLocal},
|
||||
{Label: "beach", Display: "Beach", Confidence: 0.8, Source: SourceLocal},
|
||||
}
|
||||
model := []domain.Badge{
|
||||
{Label: "dog", Display: "Dog", Confidence: 0.95, Source: SourceRemote},
|
||||
// The model also claims sky, less confidently than the heuristic.
|
||||
{Label: labelSky, Display: "Clear sky", Confidence: 0.4, Source: SourceRemote},
|
||||
}
|
||||
|
||||
got := Combine(local, model)
|
||||
|
||||
byLabel := map[string]domain.Badge{}
|
||||
for _, b := range got {
|
||||
byLabel[b.Label] = b
|
||||
}
|
||||
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 distinct badges, got %d: %v", len(got), got)
|
||||
}
|
||||
if _, ok := byLabel["dog"]; !ok {
|
||||
t.Error("the model label was dropped")
|
||||
}
|
||||
sky := byLabel[labelSky]
|
||||
if sky.Source != SourceRemote {
|
||||
t.Errorf("sky source = %q, want %q", sky.Source, SourceRemote)
|
||||
}
|
||||
if sky.Display != "Clear sky" {
|
||||
t.Errorf("sky display = %q, want the model's %q", sky.Display, "Clear sky")
|
||||
}
|
||||
if sky.Confidence != 0.9 {
|
||||
t.Errorf("sky confidence = %v, want the heuristic's higher 0.9", sky.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineIsSortedByConfidence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
badges := Combine(
|
||||
[]domain.Badge{{Label: "a", Confidence: 0.2}, {Label: "b", Confidence: 0.9}},
|
||||
[]domain.Badge{{Label: "c", Confidence: 0.5, Source: SourceRemote}},
|
||||
)
|
||||
for i := 1; i < len(badges); i++ {
|
||||
if badges[i-1].Confidence < badges[i].Confidence {
|
||||
t.Errorf("badges are not sorted by confidence: %v", badges)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombineFillsMissingDisplayName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Combine(nil, []domain.Badge{{Label: "low-light", Confidence: 0.7, Source: SourceRemote}})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 badge, got %d", len(got))
|
||||
}
|
||||
if got[0].Display != "Low light" {
|
||||
t.Errorf("display = %q, want %q", got[0].Display, "Low light")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
"dog": "Dog",
|
||||
"low-light": "Low light",
|
||||
"close_up": "Close up",
|
||||
"already Done": "Already done",
|
||||
"": "",
|
||||
"sandwich-board": "Sandwich board",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := Humanize(in); got != want {
|
||||
t.Errorf("Humanize(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuleNightPrefersClockTime confirms the EXIF timestamp takes precedence
|
||||
// over brightness, so a dark frame shot at noon is not called a night scene.
|
||||
func TestRuleNightPrefersClockTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dark := Signals{Brightness: 0.01, Width: 100, Height: 100}
|
||||
|
||||
noon := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
if got := ruleNight(dark, Metadata{TakenAt: &noon}); got != 0 {
|
||||
t.Errorf("a dark midday frame scored %v for night, want 0", got)
|
||||
}
|
||||
|
||||
midnight := time.Date(2026, 6, 1, 23, 30, 0, 0, time.UTC)
|
||||
if got := ruleNight(dark, Metadata{TakenAt: &midnight}); got <= 0 {
|
||||
t.Errorf("a bright frame shot at 23:30 scored %v for night, want > 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllBadgesCarrySourceAndBounds checks the invariants every badge must
|
||||
// satisfy regardless of which rule produced it.
|
||||
func TestAllBadgesCarrySourceAndBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res := analyzePNG(t, banded(skyBlue, grassGreen, sandColor))
|
||||
if len(res.Badges) == 0 {
|
||||
t.Fatal("expected the sample picture to produce badges")
|
||||
}
|
||||
for _, b := range res.Badges {
|
||||
if b.Label == "" {
|
||||
t.Error("a badge has an empty label")
|
||||
}
|
||||
if b.Source != SourceLocal {
|
||||
t.Errorf("badge %q has source %q, want %q", b.Label, b.Source, SourceLocal)
|
||||
}
|
||||
if b.Confidence <= 0 || b.Confidence > 1 {
|
||||
t.Errorf("badge %q confidence = %v, want a value in (0,1]", b.Label, b.Confidence)
|
||||
}
|
||||
if b.Display == "" {
|
||||
t.Errorf("badge %q has no display name", b.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newTestContext returns a background context, kept as a helper so tests that
|
||||
// build payloads read consistently.
|
||||
func newTestContext() context.Context { return context.Background() }
|
||||
380
api/internal/analysis/badges.go
Normal file
380
api/internal/analysis/badges.go
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
package analysis
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"go-places-api/internal/domain"
|
||||
)
|
||||
|
||||
// SourceLocal identifies badges derived from the picture's own pixels and
|
||||
// metadata rather than from a trained model.
|
||||
const SourceLocal = "local"
|
||||
|
||||
// badge is a candidate rule. A rule contributes a badge when its condition
|
||||
// holds, with a confidence derived from how far past the threshold the signal
|
||||
// sits.
|
||||
type badge struct {
|
||||
Label string
|
||||
Display string
|
||||
// Condition returns a confidence in 0..1, or a negative value to reject.
|
||||
Condition func(s Signals, md Metadata) float64
|
||||
}
|
||||
|
||||
// DeriveBadges turns measurements into badges.
|
||||
//
|
||||
// These are honest heuristics over colour, brightness, detail and EXIF. They
|
||||
// recognise scene and subject *categories* such as sky, vegetation or a sunset,
|
||||
// not arbitrary objects; object-level recognition belongs behind the remote
|
||||
// analyzer, whose labels are merged in by Combine.
|
||||
func DeriveBadges(res *Result) []domain.Badge {
|
||||
if res == nil {
|
||||
return nil
|
||||
}
|
||||
rules := []badge{
|
||||
{labelSky, "Sky", ruleSky},
|
||||
{labelVegetation, "Vegetation", ruleVegetation},
|
||||
{labelNature, "Nature", ruleNature},
|
||||
{labelWater, "Water", ruleWater},
|
||||
{labelBeach, "Beach", ruleBeach},
|
||||
{labelSunset, "Sunset", ruleSunset},
|
||||
{labelSnow, "Snow", ruleSnow},
|
||||
{labelIndoor, "Indoor", ruleIndoor},
|
||||
{labelPortrait, "Portrait", rulePortrait},
|
||||
{labelLandscape, "Landscape", ruleLandscape},
|
||||
{labelPanorama, "Panorama", rulePanorama},
|
||||
{labelLowLight, "Low light", ruleLowLight},
|
||||
{labelNight, "Night", ruleNight},
|
||||
{labelOverexposed, "Overexposed", ruleOverexposed},
|
||||
{labelMonochrome, "Monochrome", ruleMonochrome},
|
||||
{labelVivid, "Vivid", ruleVivid},
|
||||
{labelCloseUp, "Close-up", ruleCloseUp},
|
||||
{labelGraphic, "Graphic", ruleGraphic},
|
||||
{labelScreenshot, "Screenshot or document", ruleScreenshot},
|
||||
}
|
||||
|
||||
var out []domain.Badge
|
||||
for _, r := range rules {
|
||||
conf := r.Condition(res.Signals, res.Metadata)
|
||||
if conf <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, domain.Badge{
|
||||
Label: r.Label,
|
||||
Display: r.Display,
|
||||
Confidence: round2(clamp01(conf)),
|
||||
Source: SourceLocal,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const (
|
||||
labelSky = "sky"
|
||||
labelVegetation = "vegetation"
|
||||
labelNature = "nature"
|
||||
labelWater = "water"
|
||||
labelBeach = "beach"
|
||||
labelSunset = "sunset"
|
||||
labelSnow = "snow"
|
||||
labelIndoor = "indoor"
|
||||
labelPortrait = "portrait"
|
||||
labelLandscape = "landscape"
|
||||
labelPanorama = "panorama"
|
||||
labelLowLight = "low-light"
|
||||
labelNight = "night"
|
||||
labelOverexposed = "overexposed"
|
||||
labelMonochrome = "monochrome"
|
||||
labelVivid = "vivid"
|
||||
labelCloseUp = "close-up"
|
||||
labelGraphic = "graphic"
|
||||
labelScreenshot = "screenshot"
|
||||
)
|
||||
|
||||
// Thresholds are collected here so they can be read, and tuned, in one place.
|
||||
const (
|
||||
skyBlueShare = 0.40
|
||||
greenShare = 0.25
|
||||
waterBlueShare = 0.22
|
||||
waterBottomSmooth = 0.06
|
||||
beachSandShare = 0.28
|
||||
sunsetWarmShare = 0.18
|
||||
snowBrightness = 0.82
|
||||
snowLowSaturation = 0.12
|
||||
lowLightBrightness = 0.20
|
||||
nightBrightness = 0.16
|
||||
overexposed = 0.88
|
||||
monochromeSat = 0.10
|
||||
vividSat = 0.42
|
||||
closeUpEdges = 0.30
|
||||
graphicEdges = 0.38
|
||||
graphicSaturation = 0.28
|
||||
screenshotEdges = 0.22
|
||||
screenshotBright = 0.62
|
||||
)
|
||||
|
||||
// scale maps a signal against a threshold into a confidence that grows with
|
||||
// the margin, saturating at full confidence once the signal is twice the
|
||||
// threshold. A signal at exactly the threshold yields 0 and is therefore not
|
||||
// emitted, which keeps thresholds meaning "at least this much".
|
||||
func scale(value, threshold float64) float64 {
|
||||
if threshold <= 0 {
|
||||
return 0
|
||||
}
|
||||
ratio := value / threshold
|
||||
if ratio <= 1 {
|
||||
return 0
|
||||
}
|
||||
// Ease out so confidence approaches 1 asymptotically.
|
||||
t := (ratio - 1) / (ratio + 1)
|
||||
return 0.5 + 0.5*t*2
|
||||
}
|
||||
|
||||
// scaleBelow is the inverse of scale, for signals where a *low* value is the
|
||||
// positive signal, such as saturation for a monochrome picture. It returns zero
|
||||
// at or above the threshold and rises as the value falls towards zero.
|
||||
//
|
||||
// Thresholding an inverted signal by subtracting it and feeding the result to
|
||||
// scale would compare a margin against a threshold and silently emit nothing,
|
||||
// so the two directions are deliberately given separate helpers.
|
||||
func scaleBelow(value, threshold float64) float64 {
|
||||
if threshold <= 0 || value >= threshold {
|
||||
return 0
|
||||
}
|
||||
// ratio runs 0 at the threshold to 1 at zero.
|
||||
ratio := (threshold - value) / threshold
|
||||
if ratio <= 0 {
|
||||
return 0
|
||||
}
|
||||
return 0.4 + 0.5*ratio
|
||||
}
|
||||
|
||||
func ruleSky(s Signals, _ Metadata) float64 {
|
||||
// Sky sits in the upper band and is blue with little detail.
|
||||
blue := scale(s.Top.HueWeights.Blue, skyBlueShare)
|
||||
smooth := 1 - scale(s.Top.EdgeDensity, 0.10)
|
||||
return 0.75*blue + 0.25*smooth*blue
|
||||
}
|
||||
|
||||
func ruleVegetation(s Signals, _ Metadata) float64 {
|
||||
// Vegetation is green and can appear in any band, weighted towards the
|
||||
// lower half where ground and trees are.
|
||||
overall := scale(s.HueWeights.Green, greenShare)
|
||||
lower := scale(s.Bottom.HueWeights.Green, greenShare)
|
||||
return 0.5*overall + 0.5*lower
|
||||
}
|
||||
|
||||
func ruleNature(s Signals, _ Metadata) float64 {
|
||||
green := s.HueWeights.Green
|
||||
blue := s.HueWeights.Blue
|
||||
// Nature means the frame is dominated by vegetation or sky and is not a
|
||||
// document-like composition.
|
||||
if s.EdgeDensity > screenshotEdges {
|
||||
return 0
|
||||
}
|
||||
return 0.6*scale(green, greenShare) + 0.4*scale(blue, skyBlueShare)
|
||||
}
|
||||
|
||||
func ruleWater(s Signals, _ Metadata) float64 {
|
||||
// Water is blue in the lower band and has little structure, unlike a sky
|
||||
// which occupies the upper band.
|
||||
if s.Top.HueWeights.Blue > s.Bottom.HueWeights.Blue {
|
||||
return 0
|
||||
}
|
||||
blue := scale(s.Bottom.HueWeights.Blue, waterBlueShare)
|
||||
smooth := 1 - scale(s.Bottom.EdgeDensity, waterBottomSmooth)
|
||||
return 0.8*blue + 0.2*smooth*blue
|
||||
}
|
||||
|
||||
func ruleBeach(s Signals, _ Metadata) float64 {
|
||||
// A beach is sky above and warm sand below.
|
||||
sky := scale(s.Top.HueWeights.Blue, skyBlueShare)
|
||||
sand := scale(s.Bottom.HueWeights.Warm, beachSandShare)
|
||||
return 0.5 * sky * sand
|
||||
}
|
||||
|
||||
func ruleSunset(s Signals, _ Metadata) float64 {
|
||||
warm := scale(s.Middle.HueWeights.Warm, sunsetWarmShare)
|
||||
// Sunset keeps some light: a pitch-dark frame is night, not sunset.
|
||||
bright := 1 - scale(1-s.Brightness, 0.25)
|
||||
return warm * bright
|
||||
}
|
||||
|
||||
func ruleSnow(s Signals, _ Metadata) float64 {
|
||||
// Snow is very bright and almost colourless.
|
||||
bright := scale(s.Brightness, snowBrightness)
|
||||
flat := 1 - scale(s.Saturation, snowLowSaturation)
|
||||
return 0.7*bright + 0.3*flat*bright
|
||||
}
|
||||
|
||||
func ruleIndoor(s Signals, _ Metadata) float64 {
|
||||
// Indoor shots are shot close with plenty of detail and no sky.
|
||||
if s.Top.HueWeights.Blue > skyBlueShare/2 {
|
||||
return 0
|
||||
}
|
||||
detail := scale(s.EdgeDensity, closeUpEdges)
|
||||
return detail
|
||||
}
|
||||
|
||||
func rulePortrait(s Signals, _ Metadata) float64 {
|
||||
return orientationConfidence(s, 0.5, 0.95)
|
||||
}
|
||||
|
||||
func ruleLandscape(s Signals, _ Metadata) float64 {
|
||||
return orientationConfidence(s, 1.06, 2.2)
|
||||
}
|
||||
|
||||
func rulePanorama(s Signals, _ Metadata) float64 {
|
||||
return orientationConfidence(s, 2.5, 6.0)
|
||||
}
|
||||
|
||||
// orientationConfidence scores how strongly the aspect ratio sits inside the
|
||||
// band [min,max]. It returns zero outside the band, one at its centre, and
|
||||
// falls off towards the edges.
|
||||
func orientationConfidence(s Signals, min, max float64) float64 {
|
||||
if s.Width <= 0 || s.Height <= 0 {
|
||||
return 0
|
||||
}
|
||||
ratio := float64(s.Width) / float64(s.Height)
|
||||
if ratio < min || ratio > max {
|
||||
return 0
|
||||
}
|
||||
// The comparison happens in log space so a wide band and a tall band are
|
||||
// treated symmetrically.
|
||||
halfSpan := math.Log(max/min) / 2
|
||||
if halfSpan <= 0 {
|
||||
return 0
|
||||
}
|
||||
drift := abs(math.Log(ratio/math.Sqrt(min*max))) / halfSpan
|
||||
return 1 - clamp01(drift)
|
||||
}
|
||||
|
||||
// ruleNight prefers the clock time from EXIF and falls back to brightness alone
|
||||
// when the picture carries no timestamp.
|
||||
func ruleNight(s Signals, md Metadata) float64 {
|
||||
if md.TakenAt != nil {
|
||||
if h := md.TakenAt.UTC().Hour(); h >= 21 || h < 5 {
|
||||
return 0.9
|
||||
}
|
||||
// Daytime capture means the frame is not a night scene even if it is
|
||||
// dark, which usually means a window or an interior.
|
||||
return 0
|
||||
}
|
||||
return scaleBelow(s.Brightness, nightBrightness)
|
||||
}
|
||||
|
||||
func ruleLowLight(s Signals, _ Metadata) float64 {
|
||||
return scaleBelow(s.Brightness, lowLightBrightness)
|
||||
}
|
||||
|
||||
func ruleOverexposed(s Signals, _ Metadata) float64 {
|
||||
return scale(s.Brightness, overexposed)
|
||||
}
|
||||
|
||||
func ruleMonochrome(s Signals, _ Metadata) float64 {
|
||||
return scaleBelow(s.Saturation, monochromeSat)
|
||||
}
|
||||
|
||||
func ruleVivid(s Signals, _ Metadata) float64 {
|
||||
return scale(s.Saturation, vividSat)
|
||||
}
|
||||
|
||||
func ruleCloseUp(s Signals, _ Metadata) float64 {
|
||||
return scale(s.EdgeDensity, closeUpEdges)
|
||||
}
|
||||
|
||||
func ruleGraphic(s Signals, _ Metadata) float64 {
|
||||
// Graphics and illustrations have heavy outlines but muted colour.
|
||||
edges := scale(s.EdgeDensity, graphicEdges)
|
||||
muted := 1 - scale(s.Saturation, graphicSaturation)
|
||||
return 0.6*edges + 0.4*muted*edges
|
||||
}
|
||||
|
||||
func ruleScreenshot(s Signals, _ Metadata) float64 {
|
||||
// Screenshots and scans are flat, bright and edge-dense because text
|
||||
// produces many small high-contrast strokes.
|
||||
if s.Brightness < screenshotBright {
|
||||
return 0
|
||||
}
|
||||
edges := scale(s.EdgeDensity, screenshotEdges)
|
||||
return edges
|
||||
}
|
||||
|
||||
// Combine merges heuristic badges with model badges. Where both sources report
|
||||
// the same label the model wins, because a trained detector is more reliable
|
||||
// than a colour heuristic, but the heuristic confidence is retained as a floor
|
||||
// so a confident measurement is not discarded.
|
||||
func Combine(local, model []domain.Badge) []domain.Badge {
|
||||
byLabel := map[string]domain.Badge{}
|
||||
order := []string{}
|
||||
|
||||
add := func(b domain.Badge) {
|
||||
if b.Label == "" {
|
||||
return
|
||||
}
|
||||
if existing, ok := byLabel[b.Label]; ok {
|
||||
existing.Confidence = maxf(existing.Confidence, b.Confidence)
|
||||
// A model label is what actually identified the object, so it wins
|
||||
// the display name and keeps its own source.
|
||||
if b.Source != SourceLocal {
|
||||
existing.Display = b.Display
|
||||
existing.Source = b.Source
|
||||
}
|
||||
byLabel[b.Label] = existing
|
||||
return
|
||||
}
|
||||
byLabel[b.Label] = b
|
||||
order = append(order, b.Label)
|
||||
}
|
||||
|
||||
for _, b := range model {
|
||||
add(b)
|
||||
}
|
||||
for _, b := range local {
|
||||
add(b)
|
||||
}
|
||||
|
||||
out := make([]domain.Badge, 0, len(order))
|
||||
for _, label := range order {
|
||||
b := byLabel[label]
|
||||
if b.Display == "" {
|
||||
b.Display = Humanize(label)
|
||||
}
|
||||
b.Confidence = round2(clamp01(b.Confidence))
|
||||
out = append(out, b)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Confidence != out[j].Confidence {
|
||||
return out[i].Confidence > out[j].Confidence
|
||||
}
|
||||
return out[i].Label < out[j].Label
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Humanize turns a kebab-case label such as "low-light" into "Low light".
|
||||
func Humanize(label string) string {
|
||||
parts := strings.FieldsFunc(label, func(r rune) bool {
|
||||
return r == '-' || r == '_' || r == ' '
|
||||
})
|
||||
if len(parts) == 0 {
|
||||
return label
|
||||
}
|
||||
for i, p := range parts {
|
||||
p = strings.ToLower(p)
|
||||
// Only the first word is capitalised, so "low-light" reads "Low light"
|
||||
// rather than "Low Light".
|
||||
if i == 0 && p != "" {
|
||||
p = strings.ToUpper(p[:1]) + p[1:]
|
||||
}
|
||||
parts[i] = p
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func round2(v float64) float64 { return math.Round(v*100) / 100 }
|
||||
|
||||
func abs(v float64) float64 { return math.Abs(v) }
|
||||
|
||||
func maxf(a, b float64) float64 { return math.Max(a, b) }
|
||||
99
api/internal/analysis/composite.go
Normal file
99
api/internal/analysis/composite.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package analysis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go-places-api/internal/domain"
|
||||
)
|
||||
|
||||
// Composite runs the local analyzer first and then, when a remote service is
|
||||
// configured, feeds the locally derived scene labels to it as hints. Running
|
||||
// the local pass first is what makes the hints available, and it also means the
|
||||
// picture still gets badges if the remote service is unreachable.
|
||||
type Composite struct {
|
||||
local Analyzer
|
||||
remote Analyzer
|
||||
}
|
||||
|
||||
var _ Analyzer = (*Composite)(nil)
|
||||
|
||||
// NewComposite chains two analyzers. Either may be nil, in which case it is
|
||||
// skipped.
|
||||
func NewComposite(local, remote Analyzer) *Composite {
|
||||
return &Composite{local: local, remote: remote}
|
||||
}
|
||||
|
||||
// Name implements Analyzer.
|
||||
func (c *Composite) Name() string {
|
||||
if c.remote == nil {
|
||||
return SourceLocal
|
||||
}
|
||||
return SourceLocal + "+" + SourceRemote
|
||||
}
|
||||
|
||||
// Analyze measures locally, then augments with model labels when available.
|
||||
//
|
||||
// A failure in the remote service is reported as a non-fatal note rather than
|
||||
// an error: the picture was still understood, just less precisely. Callers can
|
||||
// inspect Note to surface the degradation.
|
||||
func (c *Composite) Analyze(ctx context.Context, in Input) (*Result, error) {
|
||||
var (
|
||||
base *Result
|
||||
local []domain.Badge
|
||||
note string
|
||||
)
|
||||
|
||||
if c.local != nil {
|
||||
res, err := c.local.Analyze(ctx, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base = res
|
||||
local = res.Badges
|
||||
} else {
|
||||
base = &Result{}
|
||||
}
|
||||
|
||||
if c.remote != nil {
|
||||
hinted := in
|
||||
hinted.Hints = labelsOf(local)
|
||||
res, err := c.remote.Analyze(ctx, hinted)
|
||||
switch {
|
||||
case err != nil:
|
||||
note = fmt.Sprintf("%s analyzer unavailable: %v", SourceRemote, err)
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
default:
|
||||
base.Width, base.Height = pickDimension(base.Width, res.Width), pickDimension(base.Height, res.Height)
|
||||
base.Badges = Combine(local, res.Badges)
|
||||
}
|
||||
} else {
|
||||
base.Badges = local
|
||||
}
|
||||
|
||||
base.Note = note
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// labelsOf extracts badge labels to use as upstream hints.
|
||||
func labelsOf(badges []domain.Badge) []string {
|
||||
if len(badges) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(badges))
|
||||
for _, b := range badges {
|
||||
if b.Confidence >= 0.5 {
|
||||
out = append(out, b.Label)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pickDimension(current, candidate int) int {
|
||||
if candidate > 0 {
|
||||
return candidate
|
||||
}
|
||||
return current
|
||||
}
|
||||
384
api/internal/analysis/exif.go
Normal file
384
api/internal/analysis/exif.go
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
package analysis
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file implements a deliberately small EXIF reader. Only the fields the
|
||||
// application actually uses are decoded:
|
||||
//
|
||||
// Make, Model IFD0, tags 0x010F / 0x0110
|
||||
// DateTimeOriginal Exif SubIFD, tag 0x9003
|
||||
// GPSLatitude / GPSLongitude GPS SubIFD, tags 0x0002 / 0x0004
|
||||
//
|
||||
// EXIF is a TIFF structure embedded in a file, so parsing it means walking an
|
||||
// image file directory. Every offset read from the data is treated as untrusted
|
||||
// and bounds-checked, because the input is a user upload.
|
||||
|
||||
// errNoEXIF is returned when the payload carries no EXIF block at all. That is
|
||||
// the normal case for pictures exported from messaging apps and screenshots.
|
||||
var errNoEXIF = errors.New("no EXIF block found")
|
||||
|
||||
// exifHeaderLen is the length of the "Exif\x00\x00" identifier that introduces
|
||||
// the APP1 payload.
|
||||
const exifHeaderLen = 6
|
||||
|
||||
// searchWindow bounds how far into the file the EXIF identifier is searched.
|
||||
// APP1 comes near the front of a JPEG; a small window avoids scanning a
|
||||
// multi-megabyte payload.
|
||||
const searchWindow = 128 << 10
|
||||
|
||||
// TIFF field types from the TIFF 6.0 specification.
|
||||
const (
|
||||
tiffByte = 1
|
||||
tiffASCII = 2
|
||||
tiffShort = 3
|
||||
tiffLong = 4
|
||||
tiffRational = 5
|
||||
tiffUndefined = 7
|
||||
)
|
||||
|
||||
type tiffTag uint16
|
||||
|
||||
const (
|
||||
tagMake tiffTag = 0x010F
|
||||
tagModel tiffTag = 0x0110
|
||||
tagExifIFDPointer tiffTag = 0x8769
|
||||
tagGPSIFDPointer tiffTag = 0x8825
|
||||
tagDateTimeOriginal tiffTag = 0x9003
|
||||
tagGPSLatitudeRef tiffTag = 0x0001
|
||||
tagGPSLatitude tiffTag = 0x0002
|
||||
tagGPSLongitudeRef tiffTag = 0x0003
|
||||
tagGPSLongitude tiffTag = 0x0004
|
||||
)
|
||||
|
||||
// exifData holds the decoded fields. The zero value means "not present".
|
||||
type exifData struct {
|
||||
Make string
|
||||
Model string
|
||||
// TakenAt is the capture time. EXIF records no time zone, so it is
|
||||
// interpreted as UTC.
|
||||
TakenAt *time.Time
|
||||
// Latitude and Longitude are signed decimal degrees.
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
HasGPS bool
|
||||
}
|
||||
|
||||
// readEXIF extracts EXIF from a JPEG or PNG payload. A payload without EXIF
|
||||
// yields errNoEXIF, which callers treat as a normal outcome.
|
||||
func readEXIF(data []byte) (exifData, error) {
|
||||
var out exifData
|
||||
|
||||
block, err := findExifBlock(data)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := parseTIFF(block, &out); err != nil {
|
||||
return exifData{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// findExifBlock locates the TIFF structure inside the payload. JPEG stores it
|
||||
// in an APP1 segment and PNG in an "eXIf" chunk; both are preceded by the
|
||||
// "Exif\x00\x00" identifier in practice, so scanning for that identifier keeps
|
||||
// the reader format-agnostic.
|
||||
func findExifBlock(data []byte) ([]byte, error) {
|
||||
window := data
|
||||
if len(window) > searchWindow {
|
||||
window = window[:searchWindow]
|
||||
}
|
||||
|
||||
const marker = "Exif\x00\x00"
|
||||
for i := 0; i+exifHeaderLen <= len(window); i++ {
|
||||
if string(window[i:i+exifHeaderLen]) != marker {
|
||||
continue
|
||||
}
|
||||
start := i + exifHeaderLen
|
||||
if start >= len(data) {
|
||||
return nil, errNoEXIF
|
||||
}
|
||||
return data[start:], nil
|
||||
}
|
||||
return nil, errNoEXIF
|
||||
}
|
||||
|
||||
// parseTIFF walks the directory structure starting at the beginning of block.
|
||||
func parseTIFF(block []byte, out *exifData) error {
|
||||
if len(block) < 8 {
|
||||
return errors.New("EXIF: TIFF header is truncated")
|
||||
}
|
||||
|
||||
var order binary.ByteOrder
|
||||
switch {
|
||||
case block[0] == 'I' && block[1] == 'I':
|
||||
order = binary.LittleEndian
|
||||
case block[0] == 'M' && block[1] == 'M':
|
||||
order = binary.BigEndian
|
||||
default:
|
||||
return errors.New("EXIF: unknown byte order marker")
|
||||
}
|
||||
|
||||
if magic := order.Uint16(block[2:4]); magic != 42 {
|
||||
return errors.New("EXIF: bad TIFF magic number")
|
||||
}
|
||||
ifd0 := order.Uint32(block[4:8])
|
||||
|
||||
entries, next, err := readIFD(block, order, ifd0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
switch e.tag {
|
||||
case tagMake:
|
||||
if s, ok := e.asASCII(block, order); ok {
|
||||
out.Make = s
|
||||
}
|
||||
case tagModel:
|
||||
if s, ok := e.asASCII(block, order); ok {
|
||||
out.Model = s
|
||||
}
|
||||
case tagExifIFDPointer:
|
||||
if sub, _, err := readIFD(block, order, uint32(e.firstValue(order))); err == nil {
|
||||
readExifSubIFD(block, order, sub, out)
|
||||
}
|
||||
case tagGPSIFDPointer:
|
||||
if sub, _, err := readIFD(block, order, uint32(e.firstValue(order))); err == nil {
|
||||
readGPSIFD(block, order, sub, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = next
|
||||
return nil
|
||||
}
|
||||
|
||||
// entry is one directory entry.
|
||||
type entry struct {
|
||||
tag tiffTag
|
||||
typ uint16
|
||||
count uint32
|
||||
// inline is the 4-byte value field as read, used when the payload fits.
|
||||
inline [4]byte
|
||||
}
|
||||
|
||||
func readExifSubIFD(block []byte, order binary.ByteOrder, entries []entry, out *exifData) {
|
||||
for _, e := range entries {
|
||||
if e.tag != tagDateTimeOriginal {
|
||||
continue
|
||||
}
|
||||
if s, ok := e.asASCII(block, order); ok {
|
||||
if t, err := time.Parse(exifTimeLayout, s); err == nil {
|
||||
utc := t.UTC()
|
||||
out.TakenAt = &utc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readGPSIFD(block []byte, order binary.ByteOrder, entries []entry, out *exifData) {
|
||||
var (
|
||||
lat, lon float64
|
||||
latOK, lonOK bool
|
||||
latRef, lonRef string
|
||||
)
|
||||
|
||||
for _, e := range entries {
|
||||
switch e.tag {
|
||||
case tagGPSLatitudeRef:
|
||||
if s, ok := e.asASCII(block, order); ok {
|
||||
latRef = s
|
||||
}
|
||||
case tagGPSLongitudeRef:
|
||||
if s, ok := e.asASCII(block, order); ok {
|
||||
lonRef = s
|
||||
}
|
||||
case tagGPSLatitude:
|
||||
if v, ok := e.asRationals(block, order); ok && len(v) >= 3 {
|
||||
lat, latOK = dmsToDecimal(v[0], v[1], v[2])
|
||||
}
|
||||
case tagGPSLongitude:
|
||||
if v, ok := e.asRationals(block, order); ok && len(v) >= 3 {
|
||||
lon, lonOK = dmsToDecimal(v[0], v[1], v[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if latOK && lonOK {
|
||||
// Southern latitudes and western longitudes are stored as magnitudes
|
||||
// with a separate hemisphere reference.
|
||||
if strings.EqualFold(latRef, "S") {
|
||||
lat = -lat
|
||||
}
|
||||
if strings.EqualFold(lonRef, "W") {
|
||||
lon = -lon
|
||||
}
|
||||
if lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 {
|
||||
out.Latitude = lat
|
||||
out.Longitude = lon
|
||||
out.HasGPS = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readIFD reads the directory at the given offset and returns its entries plus
|
||||
// the offset of the next directory, which is unused in practice.
|
||||
func readIFD(block []byte, order binary.ByteOrder, offset uint32) ([]entry, uint32, error) {
|
||||
// Each entry is 12 bytes and a directory of more than a few hundred
|
||||
// entries indicates a malformed or hostile file.
|
||||
const maxEntries = 512
|
||||
|
||||
if !inRange(block, offset, 2) {
|
||||
return nil, 0, errors.New("EXIF: directory offset is out of range")
|
||||
}
|
||||
count := order.Uint16(block[offset : offset+2])
|
||||
if int(count) > maxEntries {
|
||||
return nil, 0, errors.New("EXIF: implausible directory entry count")
|
||||
}
|
||||
|
||||
entries := make([]entry, 0, count)
|
||||
base := int(offset) + 2
|
||||
for i := 0; i < int(count); i++ {
|
||||
at := base + i*12
|
||||
if at < 0 || !inRange(block, uint32(at), 12) {
|
||||
return nil, 0, errors.New("EXIF: directory entry is out of range")
|
||||
}
|
||||
rec := block[at : at+12]
|
||||
e := entry{
|
||||
tag: tiffTag(order.Uint16(rec[0:2])),
|
||||
typ: order.Uint16(rec[2:4]),
|
||||
count: order.Uint32(rec[4:8]),
|
||||
}
|
||||
copy(e.inline[:], rec[8:12])
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
nextAt := base + int(count)*12
|
||||
var next uint32
|
||||
if nextAt >= 0 && inRange(block, uint32(nextAt), 4) {
|
||||
next = order.Uint32(block[nextAt : nextAt+4])
|
||||
}
|
||||
return entries, next, nil
|
||||
}
|
||||
|
||||
// exifTimeLayout is the non-conforming timestamp format used by EXIF.
|
||||
const exifTimeLayout = "2006:01:02 15:04:05"
|
||||
|
||||
// typeSize returns the byte width of one element of a TIFF type.
|
||||
//
|
||||
// RATIONAL and SRATIONAL are two LONGs, so they are eight bytes rather than
|
||||
// four. Getting this wrong makes every subsequent offset in the directory wrong,
|
||||
// which is why it is worth stating explicitly.
|
||||
func typeSize(typ uint16) (int, bool) {
|
||||
switch typ {
|
||||
case tiffByte, tiffASCII, tiffUndefined:
|
||||
return 1, true
|
||||
case tiffShort:
|
||||
return 2, true
|
||||
case tiffLong:
|
||||
return 4, true
|
||||
case tiffRational:
|
||||
return 8, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// payload locates the entry's value, which is either inline in the 4-byte value
|
||||
// field or at a pointer stored in that field.
|
||||
func (e entry) payload(block []byte, order binary.ByteOrder) ([]byte, bool) {
|
||||
size, ok := typeSize(e.typ)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
total := uint64(e.count) * uint64(size)
|
||||
if total == 0 {
|
||||
return nil, false
|
||||
}
|
||||
// More than a few kilobytes of "value" means a malformed file; the fields
|
||||
// this application reads are all short.
|
||||
if total > 4096 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if total <= 4 {
|
||||
return e.inline[:total], true
|
||||
}
|
||||
offset := e.firstValue(order)
|
||||
if !inRange(block, offset, uint32(total)) {
|
||||
return nil, false
|
||||
}
|
||||
return block[int(offset) : int(offset)+int(total)], true
|
||||
}
|
||||
|
||||
func (e entry) firstValue(order binary.ByteOrder) uint32 {
|
||||
return order.Uint32(e.inline[:4])
|
||||
}
|
||||
|
||||
func (e entry) asASCII(block []byte, order binary.ByteOrder) (string, bool) {
|
||||
if e.typ != tiffASCII {
|
||||
return "", false
|
||||
}
|
||||
raw, ok := e.payload(block, order)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
s := string(raw)
|
||||
if i := strings.IndexByte(s, 0); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
return strings.TrimSpace(s), true
|
||||
}
|
||||
|
||||
// asRationals decodes an array of unsigned rationals. Each is a numerator and
|
||||
// denominator pair, and a zero denominator makes the entry unusable.
|
||||
func (e entry) asRationals(block []byte, order binary.ByteOrder) ([]float64, bool) {
|
||||
if e.typ != tiffRational {
|
||||
return nil, false
|
||||
}
|
||||
raw, ok := e.payload(block, order)
|
||||
if !ok || len(raw)%8 != 0 {
|
||||
return nil, false
|
||||
}
|
||||
n := len(raw) / 8
|
||||
out := make([]float64, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
num := order.Uint32(raw[i*8 : i*8+4])
|
||||
den := order.Uint32(raw[i*8+4 : i*8+8])
|
||||
if den == 0 {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, float64(num)/float64(den))
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// dmsToDecimal converts EXIF's degrees/minutes/seconds triple to decimal
|
||||
// degrees. It reports false for out-of-range or non-finite values.
|
||||
func dmsToDecimal(degrees, minutes, seconds float64) (float64, bool) {
|
||||
if math.IsNaN(degrees) || math.IsNaN(minutes) || math.IsNaN(seconds) {
|
||||
return 0, false
|
||||
}
|
||||
if degrees < 0 || degrees > 180 {
|
||||
return 0, false
|
||||
}
|
||||
if minutes < 0 || minutes >= 60 {
|
||||
return 0, false
|
||||
}
|
||||
if seconds < 0 || seconds >= 60 {
|
||||
return 0, false
|
||||
}
|
||||
v := degrees + minutes/60 + seconds/3600
|
||||
return v, true
|
||||
}
|
||||
|
||||
func inRange(block []byte, offset, length uint32) bool {
|
||||
end := uint64(offset) + uint64(length)
|
||||
return end <= uint64(len(block))
|
||||
}
|
||||
|
||||
func math_isNaN(v float64) bool { return v != v }
|
||||
423
api/internal/analysis/exif_test.go
Normal file
423
api/internal/analysis/exif_test.go
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
package analysis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The tests build real TIFF/EXIF structures byte by byte and check the reader
|
||||
// recovers the values, including for inputs a hostile client could supply.
|
||||
|
||||
// buildTIFF assembles a TIFF block from the given directories. The layout is
|
||||
// header, IFD0, Exif SubIFD, GPS SubIFD, then a value area holding any entry
|
||||
// payload larger than the four inline bytes.
|
||||
//
|
||||
// Pointer entries for the two sub-directories are filled in automatically, so a
|
||||
// caller only has to list the tags it cares about.
|
||||
func buildTIFF(t *testing.T, order binary.ByteOrder, ifd0, exifSub, gpsSub []tiffEntry) []byte {
|
||||
t.Helper()
|
||||
|
||||
const headerLen = 8
|
||||
// IFD0 is always emitted, even when empty, because readIFD reads the
|
||||
// next-directory pointer that follows the entries.
|
||||
ifdLen := func(n int) int { return 2 + n*12 + 4 }
|
||||
areaLen := func(entries []tiffEntry) int {
|
||||
total := 0
|
||||
for _, e := range entries {
|
||||
if len(e.payload) > 4 {
|
||||
total += len(e.payload)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
ifd0Offset := headerLen
|
||||
// Sub-directories are only emitted when the caller supplies entries, but
|
||||
// IFD0 always is, so it always occupies space.
|
||||
exifOffset := ifd0Offset + ifdLen(len(ifd0))
|
||||
gpsOffset := exifOffset
|
||||
if len(exifSub) > 0 {
|
||||
gpsOffset += ifdLen(len(exifSub))
|
||||
}
|
||||
dataOffset := gpsOffset
|
||||
if len(gpsSub) > 0 {
|
||||
dataOffset += ifdLen(len(gpsSub))
|
||||
}
|
||||
|
||||
out := make([]byte, dataOffset+areaLen(ifd0)+areaLen(exifSub)+areaLen(gpsSub))
|
||||
|
||||
if order == binary.LittleEndian {
|
||||
copy(out[0:2], "II")
|
||||
} else {
|
||||
copy(out[0:2], "MM")
|
||||
}
|
||||
order.PutUint16(out[2:4], 42)
|
||||
order.PutUint32(out[4:8], uint32(ifd0Offset))
|
||||
|
||||
// TIFF records the element count, not the byte count, so a 24-byte
|
||||
// rational triple is count 3.
|
||||
elementCount := func(typ uint16, payload []byte) uint32 {
|
||||
var size uint32 = 1
|
||||
switch typ {
|
||||
case tiffShort:
|
||||
size = 2
|
||||
case tiffLong:
|
||||
size = 4
|
||||
case tiffRational:
|
||||
size = 8 // two LONGs
|
||||
}
|
||||
return uint32(len(payload)) / size
|
||||
}
|
||||
|
||||
areaCursor := dataOffset
|
||||
writeIFD := func(entries []tiffEntry, offset uint32) {
|
||||
order.PutUint16(out[offset:offset+2], uint16(len(entries)))
|
||||
at := offset + 2
|
||||
for _, e := range entries {
|
||||
order.PutUint16(out[at:at+2], uint16(e.tag))
|
||||
order.PutUint16(out[at+2:at+4], e.typ)
|
||||
order.PutUint32(out[at+4:at+8], elementCount(e.typ, e.payload))
|
||||
|
||||
// Sub-IFD pointers are always four inline bytes and are resolved
|
||||
// from the layout rather than supplied by the caller.
|
||||
switch e.tag {
|
||||
case tagExifIFDPointer:
|
||||
order.PutUint32(out[at+8:at+12], uint32(exifOffset))
|
||||
case tagGPSIFDPointer:
|
||||
order.PutUint32(out[at+8:at+12], uint32(gpsOffset))
|
||||
default:
|
||||
if len(e.payload) <= 4 {
|
||||
copy(out[at+8:at+12], e.payload)
|
||||
} else {
|
||||
order.PutUint32(out[at+8:at+12], uint32(areaCursor))
|
||||
copy(out[areaCursor:areaCursor+len(e.payload)], e.payload)
|
||||
areaCursor += len(e.payload)
|
||||
}
|
||||
}
|
||||
at += 12
|
||||
}
|
||||
// No second directory follows.
|
||||
}
|
||||
|
||||
writeIFD(ifd0, uint32(ifd0Offset))
|
||||
if len(exifSub) > 0 {
|
||||
writeIFD(exifSub, uint32(exifOffset))
|
||||
}
|
||||
if len(gpsSub) > 0 {
|
||||
writeIFD(gpsSub, uint32(gpsOffset))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type tiffEntry struct {
|
||||
tag tiffTag
|
||||
typ uint16
|
||||
payload []byte
|
||||
}
|
||||
|
||||
func asciiEntry(tag tiffTag, value string) tiffEntry {
|
||||
// EXIF ASCII values are NUL-terminated.
|
||||
return tiffEntry{tag: tag, typ: tiffASCII, payload: append([]byte(value), 0)}
|
||||
}
|
||||
|
||||
func rationalEntry(order binary.ByteOrder, tag tiffTag, values ...[2]uint32) tiffEntry {
|
||||
payload := make([]byte, 0, len(values)*8)
|
||||
for _, v := range values {
|
||||
var b [8]byte
|
||||
order.PutUint32(b[0:4], v[0])
|
||||
order.PutUint32(b[4:8], v[1])
|
||||
payload = append(payload, b[:]...)
|
||||
}
|
||||
return tiffEntry{tag: tag, typ: tiffRational, payload: payload}
|
||||
}
|
||||
|
||||
// jpegWithExif splices an APP1 segment carrying the TIFF block into a valid
|
||||
// JPEG, which is how EXIF reaches a real JPEG file. The segment has to go
|
||||
// immediately after the start-of-image marker, so the image is encoded first and
|
||||
// the segment inserted into it.
|
||||
func jpegWithExif(t *testing.T, tiffBlock []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
||||
for y := 0; y < 4; y++ {
|
||||
for x := 0; x < 4; x++ {
|
||||
img.SetRGBA(x, y, color.RGBA{R: 10, G: 120, B: 200, A: 255})
|
||||
}
|
||||
}
|
||||
var enc bytes.Buffer
|
||||
if err := jpeg.Encode(&enc, img, nil); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
encoded := enc.Bytes()
|
||||
|
||||
// APP1: marker, length, "Exif\0\0", then the TIFF block.
|
||||
app1Payload := append([]byte("Exif\x00\x00"), tiffBlock...)
|
||||
segment := make([]byte, 0, 4+len(app1Payload))
|
||||
segment = append(segment, 0xFF, 0xE1)
|
||||
lenBuf := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(lenBuf, uint16(len(app1Payload)+2))
|
||||
segment = append(segment, lenBuf...)
|
||||
segment = append(segment, app1Payload...)
|
||||
|
||||
out := make([]byte, 0, len(encoded)+len(segment))
|
||||
out = append(out, encoded[:2]...) // SOI
|
||||
out = append(out, segment...)
|
||||
out = append(out, encoded[2:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// pngWithExifChunk writes a PNG carrying an eXIf chunk, the other common
|
||||
// container for EXIF.
|
||||
func pngWithExifChunk(t *testing.T, tiffBlock []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
||||
for y := 0; y < 4; y++ {
|
||||
for x := 0; x < 4; x++ {
|
||||
img.SetRGBA(x, y, color.RGBA{R: 10, G: 120, B: 200, A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
out := buf.Bytes()
|
||||
|
||||
// Splice an eXIf chunk in right after the 8-byte PNG signature.
|
||||
const sigLen = 8
|
||||
var chunk bytes.Buffer
|
||||
chunk.WriteString("eXIf")
|
||||
b4 := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(b4, uint32(len(tiffBlock)))
|
||||
chunk.Write(b4)
|
||||
chunk.Write(tiffBlock)
|
||||
binary.BigEndian.PutUint32(b4, crc32IEEE(chunk.Bytes()))
|
||||
chunk.Write(b4)
|
||||
|
||||
return append(append(append([]byte{}, out[:sigLen]...), chunk.Bytes()...), out[sigLen:]...)
|
||||
}
|
||||
|
||||
// crc32IEEE computes the PNG chunk CRC.
|
||||
func crc32IEEE(data []byte) uint32 {
|
||||
var table [256]uint32
|
||||
for i := range table {
|
||||
c := uint32(i)
|
||||
for k := 0; k < 8; k++ {
|
||||
if c&1 != 0 {
|
||||
c = 0xedb88320 ^ (c >> 1)
|
||||
} else {
|
||||
c >>= 1
|
||||
}
|
||||
}
|
||||
table[i] = c
|
||||
}
|
||||
crc := uint32(0xffffffff)
|
||||
for _, b := range data {
|
||||
crc = table[(crc^uint32(b))&0xff] ^ (crc >> 8)
|
||||
}
|
||||
return crc ^ 0xffffffff
|
||||
}
|
||||
|
||||
func TestReadEXIFExtractsMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, order := range []binary.ByteOrder{binary.LittleEndian, binary.BigEndian} {
|
||||
name := "big-endian"
|
||||
if order == binary.LittleEndian {
|
||||
name = "little-endian"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
block := buildTIFF(t, order,
|
||||
[]tiffEntry{
|
||||
asciiEntry(tagMake, "Canon"),
|
||||
asciiEntry(tagModel, "EOS R6"),
|
||||
// Empty payloads: buildTIFF fills the offsets in.
|
||||
{tag: tagExifIFDPointer, typ: tiffLong},
|
||||
{tag: tagGPSIFDPointer, typ: tiffLong},
|
||||
},
|
||||
[]tiffEntry{
|
||||
asciiEntry(tagDateTimeOriginal, "2026:06:01 14:23:45"),
|
||||
},
|
||||
[]tiffEntry{
|
||||
asciiEntry(tagGPSLatitudeRef, "N"),
|
||||
// 48 deg 51' 23.76" N
|
||||
rationalEntry(order, tagGPSLatitude, [2]uint32{48, 1}, [2]uint32{51, 1}, [2]uint32{2376, 100}),
|
||||
asciiEntry(tagGPSLongitudeRef, "E"),
|
||||
// 2 deg 17' 39.6" E
|
||||
rationalEntry(order, tagGPSLongitude, [2]uint32{2, 1}, [2]uint32{17, 1}, [2]uint32{396, 10}),
|
||||
},
|
||||
)
|
||||
got, err := readEXIF(withExifHeader(block))
|
||||
if err != nil {
|
||||
t.Fatalf("readEXIF: %v", err)
|
||||
}
|
||||
|
||||
if got.Make != "Canon" {
|
||||
t.Errorf("Make = %q, want %q", got.Make, "Canon")
|
||||
}
|
||||
if got.Model != "EOS R6" {
|
||||
t.Errorf("Model = %q, want %q", got.Model, "EOS R6")
|
||||
}
|
||||
if got.TakenAt == nil {
|
||||
t.Fatal("TakenAt was not decoded")
|
||||
}
|
||||
if got.TakenAt.Format("2006:01:02 15:04:05") != "2026:06:01 14:23:45" {
|
||||
t.Errorf("TakenAt = %v, want 2026:06:01 14:23:45", got.TakenAt)
|
||||
}
|
||||
if !got.HasGPS {
|
||||
t.Fatal("HasGPS = false, want true")
|
||||
}
|
||||
// 48 + 51/60 + 23.76/3600 = 48.8566
|
||||
if diff := got.Latitude - 48.8566; diff > 1e-3 || diff < -1e-3 {
|
||||
t.Errorf("Latitude = %v, want ~48.8566", got.Latitude)
|
||||
}
|
||||
// 2 + 17/60 + 39.6/3600 = 2.29433
|
||||
if diff := got.Longitude - 2.29433; diff > 1e-3 || diff < -1e-3 {
|
||||
t.Errorf("Longitude = %v, want ~2.29433", got.Longitude)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadEXIFHandlesSouthAndWest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
block := buildTIFF(t, binary.LittleEndian,
|
||||
[]tiffEntry{
|
||||
{tag: tagExifIFDPointer, typ: tiffLong},
|
||||
{tag: tagGPSIFDPointer, typ: tiffLong},
|
||||
},
|
||||
nil,
|
||||
[]tiffEntry{
|
||||
asciiEntry(tagGPSLatitudeRef, "S"),
|
||||
rationalEntry(binary.LittleEndian, tagGPSLatitude, [2]uint32{33, 1}, [2]uint32{52, 1}, [2]uint32{0, 1}),
|
||||
asciiEntry(tagGPSLongitudeRef, "W"),
|
||||
rationalEntry(binary.LittleEndian, tagGPSLongitude, [2]uint32{151, 1}, [2]uint32{12, 1}, [2]uint32{0, 1}),
|
||||
},
|
||||
)
|
||||
got, err := readEXIF(withExifHeader(block))
|
||||
if err != nil {
|
||||
t.Fatalf("readEXIF: %v", err)
|
||||
}
|
||||
if got.Latitude >= 0 {
|
||||
t.Errorf("Latitude = %v, want a negative value for a south ref", got.Latitude)
|
||||
}
|
||||
if got.Longitude >= 0 {
|
||||
t.Errorf("Longitude = %v, want a negative value for a west ref", got.Longitude)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadEXIFWithoutMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
block := buildTIFF(t, binary.LittleEndian, nil, nil, nil)
|
||||
got, err := readEXIF(withExifHeader(block))
|
||||
if err != nil {
|
||||
t.Fatalf("readEXIF: %v", err)
|
||||
}
|
||||
if got.Make != "" || got.Model != "" || got.TakenAt != nil || got.HasGPS {
|
||||
t.Errorf("expected an empty result, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadEXIFRejectsMalformed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{"not exif at all", []byte("this is a text file, not a picture")},
|
||||
{"truncated header", []byte("Exif\x00\x00II")},
|
||||
{"unknown byte order", append([]byte("Exif\x00\x00"), []byte("XY\x00\x2a\x08\x00\x00\x00")...)},
|
||||
{"bad magic", append([]byte("Exif\x00\x00"), []byte("II\x00\x00\x08\x00\x00\x00")...)},
|
||||
// A directory offset pointing past the end of the block.
|
||||
{"offset out of range", append([]byte("Exif\x00\x00"), []byte("II\x2a\x00\x08\x00\x00\xff\xff\xff\xff")...)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := readEXIF(tc.data); err == nil {
|
||||
t.Error("expected an error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindExifBlockIgnoresAbsentMetadata confirms a payload with no EXIF is
|
||||
// reported as such rather than as a parse failure.
|
||||
func TestFindExifBlockIgnoresAbsentMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
if _, err := readEXIF(buf.Bytes()); err == nil {
|
||||
t.Error("expected an error for a PNG with no EXIF")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadEXIFRejectsAbsurdEntryCount guards the bound on directory size: a
|
||||
// hostile file claiming a huge entry count must not cause a huge allocation.
|
||||
func TestReadEXIFRejectsAbsurdEntryCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
block := append([]byte("Exif\x00\x00"),
|
||||
0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00,
|
||||
0xff, 0xff, // 65535 entries
|
||||
)
|
||||
if _, err := readEXIF(block); err == nil {
|
||||
t.Error("expected an error for an implausible entry count")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnalyzeUsesEXIFGPS verifies the metadata reaches the analysis result,
|
||||
// which is what lets an upload be located without the client sending a
|
||||
// coordinate.
|
||||
func TestAnalyzeUsesEXIFGPS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
block := buildTIFF(t, binary.LittleEndian,
|
||||
[]tiffEntry{
|
||||
{tag: tagExifIFDPointer, typ: tiffLong},
|
||||
{tag: tagGPSIFDPointer, typ: tiffLong},
|
||||
},
|
||||
[]tiffEntry{asciiEntry(tagDateTimeOriginal, "2026:06:01 14:23:45")},
|
||||
[]tiffEntry{
|
||||
asciiEntry(tagGPSLatitudeRef, "N"),
|
||||
rationalEntry(binary.LittleEndian, tagGPSLatitude, [2]uint32{48, 1}, [2]uint32{51, 1}, [2]uint32{2376, 100}),
|
||||
asciiEntry(tagGPSLongitudeRef, "E"),
|
||||
rationalEntry(binary.LittleEndian, tagGPSLongitude, [2]uint32{2, 1}, [2]uint32{17, 1}, [2]uint32{396, 10}),
|
||||
},
|
||||
)
|
||||
data := jpegWithExif(t, block)
|
||||
a := NewLocal(DefaultMaxDimension)
|
||||
res, err := a.Analyze(newTestContext(), Input{ContentType: "image/jpeg", Bytes: data})
|
||||
if err != nil {
|
||||
t.Fatalf("Analyze: %v", err)
|
||||
}
|
||||
if res.Metadata.Coordinate == nil {
|
||||
t.Fatal("expected the GPS coordinate to be recovered from EXIF")
|
||||
}
|
||||
if diff := res.Metadata.Coordinate.Latitude - 48.8566; diff > 1e-3 || diff < -1e-3 {
|
||||
t.Errorf("latitude = %v, want ~48.8566", res.Metadata.Coordinate.Latitude)
|
||||
}
|
||||
if res.Metadata.TakenAt == nil {
|
||||
t.Error("expected the capture timestamp to be recovered from EXIF")
|
||||
}
|
||||
}
|
||||
|
||||
// withExifHeader prefixes a raw TIFF block with the identifier that introduces
|
||||
// an APP1 payload, which is what readEXIF scans for.
|
||||
func withExifHeader(tiffBlock []byte) []byte {
|
||||
return append([]byte("Exif\x00\x00"), tiffBlock...)
|
||||
}
|
||||
427
api/internal/analysis/local.go
Normal file
427
api/internal/analysis/local.go
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
package analysis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
|
||||
// Registering the decoders is what makes image.Decode understand the
|
||||
// formats named in the configured allow-list.
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
|
||||
"go-places-api/internal/domain"
|
||||
)
|
||||
|
||||
// DefaultMaxDimension is the longest edge sampled when Input does not set one.
|
||||
// 256px keeps the work per picture small while preserving the broad colour and
|
||||
// edge statistics the badge rules rely on.
|
||||
const DefaultMaxDimension = 256
|
||||
|
||||
// LocalAnalyzer measures a picture's own pixels and metadata. It requires no
|
||||
// network access and always produces the same badges for the same bytes.
|
||||
type LocalAnalyzer struct {
|
||||
maxDimension int
|
||||
}
|
||||
|
||||
var _ Analyzer = (*LocalAnalyzer)(nil)
|
||||
|
||||
// NewLocal returns a LocalAnalyzer sampling down to maxDimension on the longest
|
||||
// edge. Values below 32 are raised to 32 so the statistics stay meaningful.
|
||||
func NewLocal(maxDimension int) *LocalAnalyzer {
|
||||
if maxDimension < 32 {
|
||||
maxDimension = 32
|
||||
}
|
||||
return &LocalAnalyzer{maxDimension: maxDimension}
|
||||
}
|
||||
|
||||
// Name implements Analyzer.
|
||||
func (l *LocalAnalyzer) Name() string { return "local" }
|
||||
|
||||
// Analyze measures the picture and derives badges from the measurements.
|
||||
func (l *LocalAnalyzer) Analyze(ctx context.Context, in Input) (*Result, error) {
|
||||
if len(in.Bytes) == 0 {
|
||||
return nil, errors.New("analysis: empty image payload")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// DecodeConfig reads only the header, giving true dimensions cheaply and
|
||||
// letting an unsupported format be rejected before the pixel work starts.
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(in.Bytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analysis: read image header: %w", err)
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return nil, fmt.Errorf("analysis: invalid image dimensions %dx%d", cfg.Width, cfg.Height)
|
||||
}
|
||||
|
||||
sample, err := sample(in.Bytes, l.maxDimension)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &Result{
|
||||
Width: cfg.Width,
|
||||
Height: cfg.Height,
|
||||
Signals: measure(sample),
|
||||
Metadata: readMetadata(in.Bytes),
|
||||
}
|
||||
res.Badges = DeriveBadges(res)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// sample decodes the picture and downscales it so the longest edge is at most
|
||||
// maxDimension. Decoders in the standard library always materialise the full
|
||||
// image, so the payload size limit enforced by the upload handler is what keeps
|
||||
// this bounded.
|
||||
func sample(data []byte, maxDimension int) (image.Image, error) {
|
||||
src, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analysis: decode image: %w", err)
|
||||
}
|
||||
|
||||
b := src.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= maxDimension && h <= maxDimension {
|
||||
return src, nil
|
||||
}
|
||||
|
||||
// Preserve aspect ratio and never upscale.
|
||||
scale := float64(maxDimension) / float64(max(w, h))
|
||||
dstW := int(math.Round(float64(w) * scale))
|
||||
dstH := int(math.Round(float64(h) * scale))
|
||||
if dstW < 1 {
|
||||
dstW = 1
|
||||
}
|
||||
if dstH < 1 {
|
||||
dstH = 1
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), src, b, draw.Over, nil)
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// measure converts the sampled pixels into the normalised signals used by the
|
||||
// badge rules.
|
||||
func measure(img image.Image) Signals {
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w == 0 || h == 0 {
|
||||
return Signals{}
|
||||
}
|
||||
|
||||
s := Signals{Width: w, Height: h}
|
||||
|
||||
// Accumulate per-band so sky and ground can be told apart.
|
||||
bandAcc := [3]*bandAccumulator{newBandAccumulator(), newBandAccumulator(), newBandAccumulator()}
|
||||
hueAcc := newHueAccumulator()
|
||||
rgbAcc := newRGBAccumulator()
|
||||
|
||||
// The sampled picture is at most maxDimension on its longest edge, so a
|
||||
// single byte per pixel is a small amount of memory and lets the edge pass
|
||||
// below see a proper 3x3 neighbourhood.
|
||||
lumas := make([]luma, w*h)
|
||||
brightnessSum := 0.0
|
||||
saturationSum := 0.0
|
||||
|
||||
for y := 0; y < h; y++ {
|
||||
rowBase := y * w
|
||||
for x := 0; x < w; x++ {
|
||||
c := colorAt(img, b.Min.X+x, b.Min.Y+y)
|
||||
lu := lumaOf(c)
|
||||
|
||||
lumas[rowBase+x] = lu
|
||||
brightnessSum += float64(lu) / 255
|
||||
saturationSum += saturationOf(c)
|
||||
|
||||
rgbAcc.add(c)
|
||||
hueAcc.add(c)
|
||||
|
||||
// Band index 0 top, 1 middle, 2 bottom.
|
||||
bi := (y * 3) / h
|
||||
bandAcc[bi].add(c, lu)
|
||||
}
|
||||
}
|
||||
|
||||
totalPixels := w * h
|
||||
inv := 1 / float64(totalPixels)
|
||||
s.Brightness = brightnessSum * inv
|
||||
s.Saturation = saturationSum * inv
|
||||
s.EdgeDensity = float64(countEdges(lumas, w, h)) * inv
|
||||
s.HueWeights = hueAcc.weights(totalPixels)
|
||||
s.Colorfulness = rgbAcc.colorfulness()
|
||||
|
||||
for i, acc := range bandAcc {
|
||||
switch i {
|
||||
case 0:
|
||||
s.Top = acc.finish(totalPixels)
|
||||
case 1:
|
||||
s.Middle = acc.finish(totalPixels)
|
||||
case 2:
|
||||
s.Bottom = acc.finish(totalPixels)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// countEdges applies a Sobel operator at every pixel with a full 3x3
|
||||
// neighbourhood, returning how many exceed edgeThreshold. Border pixels are
|
||||
// skipped because their neighbourhood would fall outside the image.
|
||||
func countEdges(lumas []luma, w, h int) int {
|
||||
if w < 3 || h < 3 {
|
||||
return 0
|
||||
}
|
||||
edges := 0
|
||||
for y := 1; y < h-1; y++ {
|
||||
top := (y - 1) * w
|
||||
mid := y * w
|
||||
bot := (y + 1) * w
|
||||
for x := 1; x < w-1; x++ {
|
||||
if isEdge(
|
||||
lumas[top+x-1], lumas[top+x], lumas[top+x+1],
|
||||
lumas[mid+x-1], lumas[mid+x], lumas[mid+x+1],
|
||||
lumas[bot+x-1], lumas[bot+x], lumas[bot+x+1],
|
||||
) {
|
||||
edges++
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges
|
||||
}
|
||||
|
||||
// edgeThreshold is the normalised gradient magnitude a pixel must exceed to
|
||||
// count as an edge.
|
||||
const edgeThreshold = 0.12
|
||||
|
||||
// isEdge applies a Sobel operator to the 3x3 neighbourhood around the centre
|
||||
// pixel and reports whether the gradient magnitude exceeds edgeThreshold.
|
||||
func isEdge(tl, tc, tr, ml, mc, mr, bl, bc, br luma) bool {
|
||||
gx := -float64(tl) + float64(tr)
|
||||
gx += -2*float64(ml) + 2*float64(mr)
|
||||
gx += -float64(bl) + float64(br)
|
||||
|
||||
gy := -float64(tl) - 2*float64(tc) - float64(tr)
|
||||
gy += float64(bl) + 2*float64(bc) + float64(br)
|
||||
|
||||
gx /= 4 * 255
|
||||
gy /= 4 * 255
|
||||
return gx*gx+gy*gy > edgeThreshold*edgeThreshold
|
||||
}
|
||||
|
||||
type luma uint8
|
||||
|
||||
func lumaOf(c rgb) luma {
|
||||
// Rec. 601 luma, which tracks perceived brightness closely enough here.
|
||||
r, g, b := c.r, c.g, c.b
|
||||
return luma((299*int(r) + 587*int(g) + 114*int(b)) / 1000)
|
||||
}
|
||||
|
||||
// rgb is a plain 8-bit RGB triple, kept private so the sampling loop avoids
|
||||
// repeated interface method calls.
|
||||
type rgb struct{ r, g, b uint8 }
|
||||
|
||||
func colorAt(img image.Image, x, y int) rgb {
|
||||
r, g, b, _ := img.At(x, y).RGBA()
|
||||
// RGBA returns alpha-premultiplied 16-bit values.
|
||||
return rgb{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)}
|
||||
}
|
||||
|
||||
// saturationOf returns the HSV saturation of a pixel in 0..1.
|
||||
func saturationOf(c rgb) float64 {
|
||||
maxV := max(c.r, max(c.g, c.b))
|
||||
minV := min(c.r, min(c.g, c.b))
|
||||
if maxV == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(maxV-minV) / float64(maxV)
|
||||
}
|
||||
|
||||
// hueClass buckets a pixel by hue, treating low-saturation pixels as
|
||||
// achromatic since their hue is not meaningful.
|
||||
func hueClass(c rgb) (blue, green, warm, purple, achromatic bool) {
|
||||
const achromaticSat = 0.20
|
||||
|
||||
hsvSat := saturationOf(c)
|
||||
if hsvSat < achromaticSat {
|
||||
return false, false, false, false, true
|
||||
}
|
||||
|
||||
maxV := float64(max(c.r, max(c.g, c.b)))
|
||||
minV := float64(min(c.r, min(c.g, c.b)))
|
||||
delta := maxV - minV
|
||||
if delta == 0 {
|
||||
return false, false, false, false, true
|
||||
}
|
||||
|
||||
var hue float64
|
||||
switch maxV {
|
||||
case float64(c.r):
|
||||
hue = math.Mod(60*(float64(c.g)-float64(c.b))/delta+360, 360)
|
||||
case float64(c.g):
|
||||
hue = 60*(float64(c.b)-float64(c.r))/delta + 120
|
||||
default:
|
||||
hue = 60*(float64(c.r)-float64(c.g))/delta + 240
|
||||
}
|
||||
|
||||
// Boundaries are the conventional hue ranges: blue 180-260, green 60-170,
|
||||
// purple 260-320, warm everything else including red and yellow.
|
||||
switch {
|
||||
case hue >= 180 && hue < 260:
|
||||
blue = true
|
||||
case hue >= 60 && hue < 170:
|
||||
green = true
|
||||
case hue >= 260 && hue < 320:
|
||||
purple = true
|
||||
default:
|
||||
warm = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type hueAccumulator struct {
|
||||
n int
|
||||
w HueWeights
|
||||
}
|
||||
|
||||
func newHueAccumulator() *hueAccumulator { return &hueAccumulator{} }
|
||||
|
||||
func (a *hueAccumulator) add(c rgb) {
|
||||
a.n++
|
||||
blue, green, warm, purple, achromatic := hueClass(c)
|
||||
if blue {
|
||||
a.w.Blue++
|
||||
}
|
||||
if green {
|
||||
a.w.Green++
|
||||
}
|
||||
if warm {
|
||||
a.w.Warm++
|
||||
}
|
||||
if purple {
|
||||
a.w.Purple++
|
||||
}
|
||||
if achromatic {
|
||||
a.w.Achromatic++
|
||||
}
|
||||
}
|
||||
|
||||
func (a *hueAccumulator) weights(total int) HueWeights {
|
||||
if total == 0 {
|
||||
return HueWeights{}
|
||||
}
|
||||
inv := 1 / float64(total)
|
||||
return HueWeights{
|
||||
Blue: float64(a.w.Blue) * inv,
|
||||
Green: float64(a.w.Green) * inv,
|
||||
Warm: float64(a.w.Warm) * inv,
|
||||
Purple: float64(a.w.Purple) * inv,
|
||||
Achromatic: float64(a.w.Achromatic) * inv,
|
||||
}
|
||||
}
|
||||
|
||||
type bandAccumulator struct {
|
||||
pixels int
|
||||
lumaSum uint64
|
||||
satSum float64
|
||||
hue hueAccumulator
|
||||
}
|
||||
|
||||
func newBandAccumulator() *bandAccumulator {
|
||||
return &bandAccumulator{hue: *newHueAccumulator()}
|
||||
}
|
||||
|
||||
func (a *bandAccumulator) add(c rgb, lu luma) {
|
||||
a.pixels++
|
||||
a.lumaSum += uint64(lu)
|
||||
a.satSum += saturationOf(c)
|
||||
a.hue.add(c)
|
||||
}
|
||||
|
||||
func (a *bandAccumulator) finish(totalPixels int) Band {
|
||||
if a.pixels == 0 {
|
||||
return Band{}
|
||||
}
|
||||
return Band{
|
||||
Brightness: float64(a.lumaSum) / float64(a.pixels) / 255,
|
||||
Saturation: a.satSum / float64(a.pixels),
|
||||
HueWeights: a.hue.weights(a.pixels),
|
||||
PixelShare: float64(a.pixels) / float64(totalPixels),
|
||||
PixelCount: a.pixels,
|
||||
}
|
||||
}
|
||||
|
||||
// rgbAccumulator tracks per-channel means and spreads for the colorfulness
|
||||
// measure of Hasler and Suesstrunk, a standard perceptual metric.
|
||||
type rgbAccumulator struct {
|
||||
rgSum, rgSumSq float64
|
||||
ybSum, ybSumSq float64
|
||||
n int
|
||||
}
|
||||
|
||||
func newRGBAccumulator() *rgbAccumulator { return &rgbAccumulator{} }
|
||||
|
||||
func (a *rgbAccumulator) add(c rgb) {
|
||||
rg := float64(c.r) - float64(c.g)
|
||||
yb := 0.5*(float64(c.r)+float64(c.g)) - float64(c.b)
|
||||
|
||||
a.n++
|
||||
a.rgSum += rg
|
||||
a.rgSumSq += rg * rg
|
||||
a.ybSum += yb
|
||||
a.ybSumSq += yb * yb
|
||||
}
|
||||
|
||||
func (a *rgbAccumulator) colorfulness() float64 {
|
||||
if a.n == 0 {
|
||||
return 0
|
||||
}
|
||||
n := float64(a.n)
|
||||
rgMean, rgStd := a.rgSum/n, stddev(a.rgSumSq/n, (a.rgSum/n)*(a.rgSum/n))
|
||||
ybMean, ybStd := a.ybSum/n, stddev(a.ybSumSq/n, (a.ybSum/n)*(a.ybSum/n))
|
||||
|
||||
magnitude := math.Hypot(rgMean, ybMean)
|
||||
spread := math.Hypot(rgStd, ybStd)
|
||||
// Normalised against a saturation and contrast of 0.3, which is the point
|
||||
// where the metric is conventionally considered "highly colourful".
|
||||
return clamp01((magnitude + spread) / 105)
|
||||
}
|
||||
|
||||
func stddev(meanOfSquares, squareOfMean float64) float64 {
|
||||
v := meanOfSquares - squareOfMean
|
||||
if v <= 0 {
|
||||
return 0
|
||||
}
|
||||
return math.Sqrt(v)
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 { return math.Max(0, math.Min(1, v)) }
|
||||
|
||||
// readMetadata extracts what EXIF knows about the capture. A picture without
|
||||
// usable EXIF is normal, not an error.
|
||||
func readMetadata(data []byte) Metadata {
|
||||
var md Metadata
|
||||
|
||||
parsed, err := readEXIF(data)
|
||||
if err != nil {
|
||||
return md
|
||||
}
|
||||
md.Make = parsed.Make
|
||||
md.Model = parsed.Model
|
||||
md.TakenAt = parsed.TakenAt
|
||||
if parsed.HasGPS {
|
||||
c := domain.Coordinate{Latitude: parsed.Latitude, Longitude: parsed.Longitude}
|
||||
if c.Valid() {
|
||||
md.Coordinate = &c
|
||||
}
|
||||
}
|
||||
return md
|
||||
}
|
||||
139
api/internal/analysis/remote.go
Normal file
139
api/internal/analysis/remote.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package analysis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-places-api/internal/domain"
|
||||
)
|
||||
|
||||
// SourceRemote identifies badges produced by an external inference service.
|
||||
const SourceRemote = "remote"
|
||||
|
||||
// RemoteAnalyzer forwards a picture to an external inference service that runs
|
||||
// a trained object or scene classifier, and turns the labels it returns into
|
||||
// badges. This is the path that recognises specific objects; the local analyzer
|
||||
// only produces scene-level categories.
|
||||
type RemoteAnalyzer struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
client *http.Client
|
||||
// maxBytes bounds the payload sent upstream so a large upload cannot be
|
||||
// forwarded without limit.
|
||||
maxBytes int64
|
||||
}
|
||||
|
||||
var _ Analyzer = (*RemoteAnalyzer)(nil)
|
||||
|
||||
// NewRemote returns a RemoteAnalyzer calling baseURL. The timeout bounds the
|
||||
// whole request including reading the response.
|
||||
func NewRemote(baseURL, apiKey string, timeout time.Duration) *RemoteAnalyzer {
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return &RemoteAnalyzer{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
apiKey: apiKey,
|
||||
client: &http.Client{Timeout: timeout},
|
||||
maxBytes: 12 << 20,
|
||||
}
|
||||
}
|
||||
|
||||
// remoteRequest is the payload sent to the inference service.
|
||||
type remoteRequest struct {
|
||||
ContentType string `json:"contentType"`
|
||||
ImageBase64 string `json:"imageBase64"`
|
||||
// Hint carries locally derived scene badges so the service can disambiguate
|
||||
// using the signals we already computed.
|
||||
Hint []string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// remoteResponse is the expected reply. Width and height are optional because
|
||||
// the service may not report them.
|
||||
type remoteResponse struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Labels []struct {
|
||||
Label string `json:"label"`
|
||||
Display string `json:"display"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
} `json:"labels"`
|
||||
}
|
||||
|
||||
// Analyze sends the picture upstream and converts the reply into badges.
|
||||
func (r *RemoteAnalyzer) Analyze(ctx context.Context, in Input) (*Result, error) {
|
||||
if len(in.Bytes) == 0 {
|
||||
return nil, fmt.Errorf("analysis: empty image payload")
|
||||
}
|
||||
if int64(len(in.Bytes)) > r.maxBytes {
|
||||
return nil, fmt.Errorf("analysis: payload of %d bytes exceeds remote limit of %d",
|
||||
len(in.Bytes), r.maxBytes)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(remoteRequest{
|
||||
ContentType: in.ContentType,
|
||||
ImageBase64: base64.StdEncoding.EncodeToString(in.Bytes),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analysis: encode request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.baseURL+"/v1/analyze", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analysis: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if r.apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+r.apiKey)
|
||||
}
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analysis: call inference service: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Read a bounded amount of the error body to aid debugging without
|
||||
// letting an error response stream unbounded data.
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("analysis: inference service returned %s: %s",
|
||||
resp.Status, strings.TrimSpace(string(snippet)))
|
||||
}
|
||||
|
||||
// Bound the response so a misbehaving service cannot exhaust memory.
|
||||
var decoded remoteResponse
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&decoded); err != nil {
|
||||
return nil, fmt.Errorf("analysis: decode inference response: %w", err)
|
||||
}
|
||||
|
||||
res := &Result{Width: decoded.Width, Height: decoded.Height}
|
||||
for _, l := range decoded.Labels {
|
||||
label := strings.TrimSpace(strings.ToLower(l.Label))
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
display := strings.TrimSpace(l.Display)
|
||||
if display == "" {
|
||||
display = Humanize(label)
|
||||
}
|
||||
res.Badges = append(res.Badges, domain.Badge{
|
||||
Label: label,
|
||||
Display: display,
|
||||
Confidence: round2(clamp01(l.Confidence)),
|
||||
Source: SourceRemote,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Name implements Analyzer.
|
||||
func (r *RemoteAnalyzer) Name() string { return SourceRemote }
|
||||
Loading…
Add table
Add a link
Reference in a new issue