initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:14:50 +02:00
commit 1253955070
75 changed files with 13972 additions and 0 deletions

View 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)
}

View 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() }

View 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) }

View 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
}

View 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 }

View 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...)
}

View 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
}

View 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 }