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