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