go-places/api/internal/analysis/badges.go

381 lines
11 KiB
Go
Raw Permalink Normal View History

2026-10-07 15:14:50 +02:00
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) }