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>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package imaging_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
|
||||
"osdb/internal/imaging"
|
||||
)
|
||||
|
||||
func makeWhitePNG(w, h int) []byte {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, color.White)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
_ = png.Encode(&buf, img)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func makeContentPNG(w, h, contentX, contentY, contentW, contentH int) []byte {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, color.White)
|
||||
}
|
||||
}
|
||||
for y := contentY; y < contentY+contentH; y++ {
|
||||
for x := contentX; x < contentX+contentW; x++ {
|
||||
img.Set(x, y, color.RGBA{R: 100, G: 50, B: 30, A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
_ = png.Encode(&buf, img)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func imageDims(data []byte) (int, int) {
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
b := img.Bounds()
|
||||
return b.Dx(), b.Dy()
|
||||
}
|
||||
|
||||
func TestCropToContent_InvalidData(t *testing.T) {
|
||||
result, err := imaging.CropToContent([]byte("not an image"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if !bytes.Equal(result, []byte("not an image")) {
|
||||
t.Error("expected original bytes returned on decode failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropToContent_AllBackground(t *testing.T) {
|
||||
src := makeWhitePNG(100, 100)
|
||||
result, err := imaging.CropToContent(src)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
t.Error("expected non-empty result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropToContent_CropsToContent(t *testing.T) {
|
||||
// 200x200 white image with a 20x20 content block at (80,80)
|
||||
src := makeContentPNG(200, 200, 80, 80, 20, 20)
|
||||
result, err := imaging.CropToContent(src)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
w, h := imageDims(result)
|
||||
if w >= 200 || h >= 200 {
|
||||
t.Errorf("expected smaller result, got %dx%d", w, h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropToContent_PreservesContentWithPadding(t *testing.T) {
|
||||
// 200x200 white image with a 30x30 red block at (85,85)
|
||||
src := makeContentPNG(200, 200, 85, 85, 30, 30)
|
||||
result, err := imaging.CropToContent(src)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Result should be smaller than original but include padding
|
||||
w, h := imageDims(result)
|
||||
if w == 0 || h == 0 {
|
||||
t.Error("expected valid image dimensions")
|
||||
}
|
||||
if w >= 200 || h >= 200 {
|
||||
t.Errorf("expected crop, got %dx%d", w, h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropToContent_ScalesDownLargeImage(t *testing.T) {
|
||||
// 600x600 image with content filling most of it
|
||||
src := makeContentPNG(600, 600, 10, 10, 580, 580)
|
||||
result, err := imaging.CropToContent(src)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
w, h := imageDims(result)
|
||||
if w > 300 || h > 300 {
|
||||
t.Errorf("expected scale-down to ≤300, got %dx%d", w, h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropToContent_ReturnsJPEG(t *testing.T) {
|
||||
src := makeContentPNG(100, 100, 20, 20, 60, 60)
|
||||
result, err := imaging.CropToContent(src)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// JPEG magic bytes: FF D8 FF
|
||||
if len(result) < 3 || result[0] != 0xFF || result[1] != 0xD8 {
|
||||
// Check if we got back original (all-background case returns src)
|
||||
if !bytes.Equal(result, src) {
|
||||
t.Error("expected JPEG output")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropToContent_JPEGInput(t *testing.T) {
|
||||
// Create a JPEG input
|
||||
img := image.NewRGBA(image.Rect(0, 0, 100, 100))
|
||||
for y := 0; y < 100; y++ {
|
||||
for x := 0; x < 100; x++ {
|
||||
if x > 20 && x < 80 && y > 20 && y < 80 {
|
||||
img.Set(x, y, color.RGBA{R: 200, G: 100, B: 50, A: 255})
|
||||
} else {
|
||||
img.Set(x, y, color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
_ = jpeg.Encode(&buf, img, nil)
|
||||
|
||||
result, err := imaging.CropToContent(buf.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
t.Error("expected non-empty result")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user