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