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 }