initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
427
api/internal/analysis/local.go
Normal file
427
api/internal/analysis/local.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue