380 lines
11 KiB
Go
380 lines
11 KiB
Go
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) }
|