Files
osdb-1-claude-opusplan/backend/internal/imaging/crop.go
juliaandClaude Sonnet 4.6 649c9687ac feat: fruit overview with thumbnails and card layout (story #07)
- Auto-crop thumbnails on image upload (Go stdlib, no external deps):
  find non-background bounding box, pad 5px, scale to ≤300px, encode JPEG
- Add thumbnail_data BYTEA column to fruit_images and publication_fruit_images
- New GET /api/v1/fruits/:id/images/:imageId/thumbnail endpoint (fallback to full data)
- New GET /api/v1/publications/:id/fruit-images/:imgId/thumbnail endpoint
- New POST /api/v1/admin/backfill-thumbnails to retroactively generate thumbnails
- ListFruits response now includes synonyms and thumbnail_url per fruit
- Replace FruitList table with responsive FruitCard grid (thumbnail + name + OSDB ID + type + synonyms)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:02:14 +02:00

138 lines
3.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package imaging
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/jpeg"
_ "image/png"
)
const (
bgThreshold = 10
padding = 5
maxDim = 300
)
// CropToContent auto-crops src to the bounding box of non-background pixels,
// adds padding, scales to fit within maxDim×maxDim, and re-encodes as JPEG.
// Returns the original bytes unchanged if decoding fails.
func CropToContent(src []byte) ([]byte, error) {
img, _, err := image.Decode(bytes.NewReader(src))
if err != nil {
return src, nil
}
bounds := img.Bounds()
if bounds.Dx() == 0 || bounds.Dy() == 0 {
return src, nil
}
bgColor := img.At(bounds.Min.X, bounds.Min.Y)
minX, minY, maxX, maxY := findContentBounds(img, bgColor, bounds)
if minX > maxX || minY > maxY {
return src, nil
}
minX = max(bounds.Min.X, minX-padding)
minY = max(bounds.Min.Y, minY-padding)
maxX = min(bounds.Max.X-1, maxX+padding)
maxY = min(bounds.Max.Y-1, maxY+padding)
cropRect := image.Rect(minX, minY, maxX+1, maxY+1)
cropped := cropImage(img, cropRect)
scaled := scaleDown(cropped)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, scaled, &jpeg.Options{Quality: 85}); err != nil {
return src, nil
}
return buf.Bytes(), nil
}
func findContentBounds(img image.Image, bg color.Color, bounds image.Rectangle) (minX, minY, maxX, maxY int) {
minX = bounds.Max.X
minY = bounds.Max.Y
maxX = bounds.Min.X - 1
maxY = bounds.Min.Y - 1
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
if channelDiff(img.At(x, y), bg) > bgThreshold {
if x < minX {
minX = x
}
if y < minY {
minY = y
}
if x > maxX {
maxX = x
}
if y > maxY {
maxY = y
}
}
}
}
return
}
func channelDiff(c1, c2 color.Color) int {
r1, g1, b1, _ := c1.RGBA()
r2, g2, b2, _ := c2.RGBA()
dr := abs(int(r1>>8) - int(r2>>8))
dg := abs(int(g1>>8) - int(g2>>8))
db := abs(int(b1>>8) - int(b2>>8))
return max(dr, max(dg, db))
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
type subImager interface {
SubImage(r image.Rectangle) image.Image
}
func cropImage(img image.Image, rect image.Rectangle) image.Image {
if si, ok := img.(subImager); ok {
return si.SubImage(rect)
}
dst := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
draw.Draw(dst, dst.Bounds(), img, rect.Min, draw.Src)
return dst
}
func scaleDown(img image.Image) image.Image {
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
if w <= maxDim && h <= maxDim {
return img
}
scaleW := float64(maxDim) / float64(w)
scaleH := float64(maxDim) / float64(h)
scale := scaleW
if scaleH < scale {
scale = scaleH
}
newW := max(1, int(float64(w)*scale))
newH := max(1, int(float64(h)*scale))
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
for y := 0; y < newH; y++ {
for x := 0; x < newW; x++ {
srcX := bounds.Min.X + int(float64(x)/scale)
srcY := bounds.Min.Y + int(float64(y)/scale)
dst.Set(x, y, img.At(srcX, srcY))
}
}
return dst
}