initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
380
api/internal/analysis/badges.go
Normal file
380
api/internal/analysis/badges.go
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
package analysis
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"go-places-api/internal/domain"
|
||||
)
|
||||
|
||||
// SourceLocal identifies badges derived from the picture's own pixels and
|
||||
// metadata rather than from a trained model.
|
||||
const SourceLocal = "local"
|
||||
|
||||
// badge is a candidate rule. A rule contributes a badge when its condition
|
||||
// holds, with a confidence derived from how far past the threshold the signal
|
||||
// sits.
|
||||
type badge struct {
|
||||
Label string
|
||||
Display string
|
||||
// Condition returns a confidence in 0..1, or a negative value to reject.
|
||||
Condition func(s Signals, md Metadata) float64
|
||||
}
|
||||
|
||||
// DeriveBadges turns measurements into badges.
|
||||
//
|
||||
// These are honest heuristics over colour, brightness, detail and EXIF. They
|
||||
// recognise scene and subject *categories* such as sky, vegetation or a sunset,
|
||||
// not arbitrary objects; object-level recognition belongs behind the remote
|
||||
// analyzer, whose labels are merged in by Combine.
|
||||
func DeriveBadges(res *Result) []domain.Badge {
|
||||
if res == nil {
|
||||
return nil
|
||||
}
|
||||
rules := []badge{
|
||||
{labelSky, "Sky", ruleSky},
|
||||
{labelVegetation, "Vegetation", ruleVegetation},
|
||||
{labelNature, "Nature", ruleNature},
|
||||
{labelWater, "Water", ruleWater},
|
||||
{labelBeach, "Beach", ruleBeach},
|
||||
{labelSunset, "Sunset", ruleSunset},
|
||||
{labelSnow, "Snow", ruleSnow},
|
||||
{labelIndoor, "Indoor", ruleIndoor},
|
||||
{labelPortrait, "Portrait", rulePortrait},
|
||||
{labelLandscape, "Landscape", ruleLandscape},
|
||||
{labelPanorama, "Panorama", rulePanorama},
|
||||
{labelLowLight, "Low light", ruleLowLight},
|
||||
{labelNight, "Night", ruleNight},
|
||||
{labelOverexposed, "Overexposed", ruleOverexposed},
|
||||
{labelMonochrome, "Monochrome", ruleMonochrome},
|
||||
{labelVivid, "Vivid", ruleVivid},
|
||||
{labelCloseUp, "Close-up", ruleCloseUp},
|
||||
{labelGraphic, "Graphic", ruleGraphic},
|
||||
{labelScreenshot, "Screenshot or document", ruleScreenshot},
|
||||
}
|
||||
|
||||
var out []domain.Badge
|
||||
for _, r := range rules {
|
||||
conf := r.Condition(res.Signals, res.Metadata)
|
||||
if conf <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, domain.Badge{
|
||||
Label: r.Label,
|
||||
Display: r.Display,
|
||||
Confidence: round2(clamp01(conf)),
|
||||
Source: SourceLocal,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const (
|
||||
labelSky = "sky"
|
||||
labelVegetation = "vegetation"
|
||||
labelNature = "nature"
|
||||
labelWater = "water"
|
||||
labelBeach = "beach"
|
||||
labelSunset = "sunset"
|
||||
labelSnow = "snow"
|
||||
labelIndoor = "indoor"
|
||||
labelPortrait = "portrait"
|
||||
labelLandscape = "landscape"
|
||||
labelPanorama = "panorama"
|
||||
labelLowLight = "low-light"
|
||||
labelNight = "night"
|
||||
labelOverexposed = "overexposed"
|
||||
labelMonochrome = "monochrome"
|
||||
labelVivid = "vivid"
|
||||
labelCloseUp = "close-up"
|
||||
labelGraphic = "graphic"
|
||||
labelScreenshot = "screenshot"
|
||||
)
|
||||
|
||||
// Thresholds are collected here so they can be read, and tuned, in one place.
|
||||
const (
|
||||
skyBlueShare = 0.40
|
||||
greenShare = 0.25
|
||||
waterBlueShare = 0.22
|
||||
waterBottomSmooth = 0.06
|
||||
beachSandShare = 0.28
|
||||
sunsetWarmShare = 0.18
|
||||
snowBrightness = 0.82
|
||||
snowLowSaturation = 0.12
|
||||
lowLightBrightness = 0.20
|
||||
nightBrightness = 0.16
|
||||
overexposed = 0.88
|
||||
monochromeSat = 0.10
|
||||
vividSat = 0.42
|
||||
closeUpEdges = 0.30
|
||||
graphicEdges = 0.38
|
||||
graphicSaturation = 0.28
|
||||
screenshotEdges = 0.22
|
||||
screenshotBright = 0.62
|
||||
)
|
||||
|
||||
// scale maps a signal against a threshold into a confidence that grows with
|
||||
// the margin, saturating at full confidence once the signal is twice the
|
||||
// threshold. A signal at exactly the threshold yields 0 and is therefore not
|
||||
// emitted, which keeps thresholds meaning "at least this much".
|
||||
func scale(value, threshold float64) float64 {
|
||||
if threshold <= 0 {
|
||||
return 0
|
||||
}
|
||||
ratio := value / threshold
|
||||
if ratio <= 1 {
|
||||
return 0
|
||||
}
|
||||
// Ease out so confidence approaches 1 asymptotically.
|
||||
t := (ratio - 1) / (ratio + 1)
|
||||
return 0.5 + 0.5*t*2
|
||||
}
|
||||
|
||||
// scaleBelow is the inverse of scale, for signals where a *low* value is the
|
||||
// positive signal, such as saturation for a monochrome picture. It returns zero
|
||||
// at or above the threshold and rises as the value falls towards zero.
|
||||
//
|
||||
// Thresholding an inverted signal by subtracting it and feeding the result to
|
||||
// scale would compare a margin against a threshold and silently emit nothing,
|
||||
// so the two directions are deliberately given separate helpers.
|
||||
func scaleBelow(value, threshold float64) float64 {
|
||||
if threshold <= 0 || value >= threshold {
|
||||
return 0
|
||||
}
|
||||
// ratio runs 0 at the threshold to 1 at zero.
|
||||
ratio := (threshold - value) / threshold
|
||||
if ratio <= 0 {
|
||||
return 0
|
||||
}
|
||||
return 0.4 + 0.5*ratio
|
||||
}
|
||||
|
||||
func ruleSky(s Signals, _ Metadata) float64 {
|
||||
// Sky sits in the upper band and is blue with little detail.
|
||||
blue := scale(s.Top.HueWeights.Blue, skyBlueShare)
|
||||
smooth := 1 - scale(s.Top.EdgeDensity, 0.10)
|
||||
return 0.75*blue + 0.25*smooth*blue
|
||||
}
|
||||
|
||||
func ruleVegetation(s Signals, _ Metadata) float64 {
|
||||
// Vegetation is green and can appear in any band, weighted towards the
|
||||
// lower half where ground and trees are.
|
||||
overall := scale(s.HueWeights.Green, greenShare)
|
||||
lower := scale(s.Bottom.HueWeights.Green, greenShare)
|
||||
return 0.5*overall + 0.5*lower
|
||||
}
|
||||
|
||||
func ruleNature(s Signals, _ Metadata) float64 {
|
||||
green := s.HueWeights.Green
|
||||
blue := s.HueWeights.Blue
|
||||
// Nature means the frame is dominated by vegetation or sky and is not a
|
||||
// document-like composition.
|
||||
if s.EdgeDensity > screenshotEdges {
|
||||
return 0
|
||||
}
|
||||
return 0.6*scale(green, greenShare) + 0.4*scale(blue, skyBlueShare)
|
||||
}
|
||||
|
||||
func ruleWater(s Signals, _ Metadata) float64 {
|
||||
// Water is blue in the lower band and has little structure, unlike a sky
|
||||
// which occupies the upper band.
|
||||
if s.Top.HueWeights.Blue > s.Bottom.HueWeights.Blue {
|
||||
return 0
|
||||
}
|
||||
blue := scale(s.Bottom.HueWeights.Blue, waterBlueShare)
|
||||
smooth := 1 - scale(s.Bottom.EdgeDensity, waterBottomSmooth)
|
||||
return 0.8*blue + 0.2*smooth*blue
|
||||
}
|
||||
|
||||
func ruleBeach(s Signals, _ Metadata) float64 {
|
||||
// A beach is sky above and warm sand below.
|
||||
sky := scale(s.Top.HueWeights.Blue, skyBlueShare)
|
||||
sand := scale(s.Bottom.HueWeights.Warm, beachSandShare)
|
||||
return 0.5 * sky * sand
|
||||
}
|
||||
|
||||
func ruleSunset(s Signals, _ Metadata) float64 {
|
||||
warm := scale(s.Middle.HueWeights.Warm, sunsetWarmShare)
|
||||
// Sunset keeps some light: a pitch-dark frame is night, not sunset.
|
||||
bright := 1 - scale(1-s.Brightness, 0.25)
|
||||
return warm * bright
|
||||
}
|
||||
|
||||
func ruleSnow(s Signals, _ Metadata) float64 {
|
||||
// Snow is very bright and almost colourless.
|
||||
bright := scale(s.Brightness, snowBrightness)
|
||||
flat := 1 - scale(s.Saturation, snowLowSaturation)
|
||||
return 0.7*bright + 0.3*flat*bright
|
||||
}
|
||||
|
||||
func ruleIndoor(s Signals, _ Metadata) float64 {
|
||||
// Indoor shots are shot close with plenty of detail and no sky.
|
||||
if s.Top.HueWeights.Blue > skyBlueShare/2 {
|
||||
return 0
|
||||
}
|
||||
detail := scale(s.EdgeDensity, closeUpEdges)
|
||||
return detail
|
||||
}
|
||||
|
||||
func rulePortrait(s Signals, _ Metadata) float64 {
|
||||
return orientationConfidence(s, 0.5, 0.95)
|
||||
}
|
||||
|
||||
func ruleLandscape(s Signals, _ Metadata) float64 {
|
||||
return orientationConfidence(s, 1.06, 2.2)
|
||||
}
|
||||
|
||||
func rulePanorama(s Signals, _ Metadata) float64 {
|
||||
return orientationConfidence(s, 2.5, 6.0)
|
||||
}
|
||||
|
||||
// orientationConfidence scores how strongly the aspect ratio sits inside the
|
||||
// band [min,max]. It returns zero outside the band, one at its centre, and
|
||||
// falls off towards the edges.
|
||||
func orientationConfidence(s Signals, min, max float64) float64 {
|
||||
if s.Width <= 0 || s.Height <= 0 {
|
||||
return 0
|
||||
}
|
||||
ratio := float64(s.Width) / float64(s.Height)
|
||||
if ratio < min || ratio > max {
|
||||
return 0
|
||||
}
|
||||
// The comparison happens in log space so a wide band and a tall band are
|
||||
// treated symmetrically.
|
||||
halfSpan := math.Log(max/min) / 2
|
||||
if halfSpan <= 0 {
|
||||
return 0
|
||||
}
|
||||
drift := abs(math.Log(ratio/math.Sqrt(min*max))) / halfSpan
|
||||
return 1 - clamp01(drift)
|
||||
}
|
||||
|
||||
// ruleNight prefers the clock time from EXIF and falls back to brightness alone
|
||||
// when the picture carries no timestamp.
|
||||
func ruleNight(s Signals, md Metadata) float64 {
|
||||
if md.TakenAt != nil {
|
||||
if h := md.TakenAt.UTC().Hour(); h >= 21 || h < 5 {
|
||||
return 0.9
|
||||
}
|
||||
// Daytime capture means the frame is not a night scene even if it is
|
||||
// dark, which usually means a window or an interior.
|
||||
return 0
|
||||
}
|
||||
return scaleBelow(s.Brightness, nightBrightness)
|
||||
}
|
||||
|
||||
func ruleLowLight(s Signals, _ Metadata) float64 {
|
||||
return scaleBelow(s.Brightness, lowLightBrightness)
|
||||
}
|
||||
|
||||
func ruleOverexposed(s Signals, _ Metadata) float64 {
|
||||
return scale(s.Brightness, overexposed)
|
||||
}
|
||||
|
||||
func ruleMonochrome(s Signals, _ Metadata) float64 {
|
||||
return scaleBelow(s.Saturation, monochromeSat)
|
||||
}
|
||||
|
||||
func ruleVivid(s Signals, _ Metadata) float64 {
|
||||
return scale(s.Saturation, vividSat)
|
||||
}
|
||||
|
||||
func ruleCloseUp(s Signals, _ Metadata) float64 {
|
||||
return scale(s.EdgeDensity, closeUpEdges)
|
||||
}
|
||||
|
||||
func ruleGraphic(s Signals, _ Metadata) float64 {
|
||||
// Graphics and illustrations have heavy outlines but muted colour.
|
||||
edges := scale(s.EdgeDensity, graphicEdges)
|
||||
muted := 1 - scale(s.Saturation, graphicSaturation)
|
||||
return 0.6*edges + 0.4*muted*edges
|
||||
}
|
||||
|
||||
func ruleScreenshot(s Signals, _ Metadata) float64 {
|
||||
// Screenshots and scans are flat, bright and edge-dense because text
|
||||
// produces many small high-contrast strokes.
|
||||
if s.Brightness < screenshotBright {
|
||||
return 0
|
||||
}
|
||||
edges := scale(s.EdgeDensity, screenshotEdges)
|
||||
return edges
|
||||
}
|
||||
|
||||
// Combine merges heuristic badges with model badges. Where both sources report
|
||||
// the same label the model wins, because a trained detector is more reliable
|
||||
// than a colour heuristic, but the heuristic confidence is retained as a floor
|
||||
// so a confident measurement is not discarded.
|
||||
func Combine(local, model []domain.Badge) []domain.Badge {
|
||||
byLabel := map[string]domain.Badge{}
|
||||
order := []string{}
|
||||
|
||||
add := func(b domain.Badge) {
|
||||
if b.Label == "" {
|
||||
return
|
||||
}
|
||||
if existing, ok := byLabel[b.Label]; ok {
|
||||
existing.Confidence = maxf(existing.Confidence, b.Confidence)
|
||||
// A model label is what actually identified the object, so it wins
|
||||
// the display name and keeps its own source.
|
||||
if b.Source != SourceLocal {
|
||||
existing.Display = b.Display
|
||||
existing.Source = b.Source
|
||||
}
|
||||
byLabel[b.Label] = existing
|
||||
return
|
||||
}
|
||||
byLabel[b.Label] = b
|
||||
order = append(order, b.Label)
|
||||
}
|
||||
|
||||
for _, b := range model {
|
||||
add(b)
|
||||
}
|
||||
for _, b := range local {
|
||||
add(b)
|
||||
}
|
||||
|
||||
out := make([]domain.Badge, 0, len(order))
|
||||
for _, label := range order {
|
||||
b := byLabel[label]
|
||||
if b.Display == "" {
|
||||
b.Display = Humanize(label)
|
||||
}
|
||||
b.Confidence = round2(clamp01(b.Confidence))
|
||||
out = append(out, b)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Confidence != out[j].Confidence {
|
||||
return out[i].Confidence > out[j].Confidence
|
||||
}
|
||||
return out[i].Label < out[j].Label
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Humanize turns a kebab-case label such as "low-light" into "Low light".
|
||||
func Humanize(label string) string {
|
||||
parts := strings.FieldsFunc(label, func(r rune) bool {
|
||||
return r == '-' || r == '_' || r == ' '
|
||||
})
|
||||
if len(parts) == 0 {
|
||||
return label
|
||||
}
|
||||
for i, p := range parts {
|
||||
p = strings.ToLower(p)
|
||||
// Only the first word is capitalised, so "low-light" reads "Low light"
|
||||
// rather than "Low Light".
|
||||
if i == 0 && p != "" {
|
||||
p = strings.ToUpper(p[:1]) + p[1:]
|
||||
}
|
||||
parts[i] = p
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func round2(v float64) float64 { return math.Round(v*100) / 100 }
|
||||
|
||||
func abs(v float64) float64 { return math.Abs(v) }
|
||||
|
||||
func maxf(a, b float64) float64 { return math.Max(a, b) }
|
||||
Loading…
Add table
Add a link
Reference in a new issue