diff --git a/Makefile b/Makefile index 94bcd75..0ec69ba 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,10 @@ COMPANION_WIN := companion/datascape-companion-windows-amd64.exe COMPANION_LIN := companion/datascape-companion-linux-amd64 COMPANION_SRCS := $(wildcard cmd/companion/*.go) $(wildcard cmd/companion/*.html) go.mod go.sum +# Release builds drop the symbol table, DWARF debug info and local source +# paths. Panics still print file:line stack traces; only debuggers lose out. +RELEASE_FLAGS := -trimpath -ldflags="-s -w" + EDITOR_BUNDLE := assets/editor/vendor/codemirror.bundle.js EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-build/package-lock.json @@ -14,10 +18,10 @@ EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-bu companion-release: $(COMPANION_WIN) $(COMPANION_LIN) $(COMPANION_WIN): $(COMPANION_SRCS) - GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui" -o $@ ./cmd/companion + GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H windowsgui" -o $@ ./cmd/companion $(COMPANION_LIN): $(COMPANION_SRCS) - GOOS=linux GOARCH=amd64 go build -o $@ ./cmd/companion + GOOS=linux GOARCH=amd64 go build $(RELEASE_FLAGS) -o $@ ./cmd/companion companion-windows: $(COMPANION_WIN) companion-linux: $(COMPANION_LIN) @@ -37,7 +41,7 @@ $(EDITOR_BUNDLE): $(EDITOR_SRCS) cd editor-build && npm ci && npm run build deploy: companion-release - GOOS=linux GOARCH=arm GOARM=7 go build -o datascape-arm . + GOOS=linux GOARCH=arm GOARM=7 go build $(RELEASE_FLAGS) -o datascape-arm . ssh $(NAS) 'kill $$(cat /share/homes/luxick/.local/bin/datascape.pid) 2>/dev/null; rm -f /share/homes/luxick/.local/bin/datascape.pid' scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape ssh $(NAS) /share/homes/luxick/.local/bin/datascape-start.sh diff --git a/README.md b/README.md index b3839cf..7f19f45 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The wiki binary embeds the companion binaries, so build those first: ```bash make companion-release # once, and after companion changes go build -o datascape . # local -GOOS=linux GOARCH=arm go build -o datascape . # QNAP NAS +make deploy # QNAP NAS: stripped ARMv7 build, copy, restart ./datascape -dir ./wiki -addr :8080 -user me -pass secret ``` diff --git a/thumb_image.go b/thumb_image.go index 30e0643..8f7dce8 100644 --- a/thumb_image.go +++ b/thumb_image.go @@ -57,6 +57,14 @@ func resizeBox(src image.Image, width int) image.Image { } dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) + // JPEGs decode to *image.YCbCr. Reading its planes directly avoids an + // interface call and a colour conversion per source pixel, which makes the + // resize ~4x faster; everything else takes the generic At() path. + avg := boxAvg + if ycc, ok := src.(*image.YCbCr); ok { + avg = func(_ image.Image, r image.Rectangle) color.RGBA { return boxAvgYCbCr(ycc, r) } + } + for y := 0; y < dstH; y++ { sy0 := y * srcH / dstH sy1 := (y + 1) * srcH / dstH @@ -69,23 +77,42 @@ func resizeBox(src image.Image, width int) image.Image { if sx1 == sx0 { sx1 = sx0 + 1 } - var r, g, bl, n uint64 - for sy := sy0; sy < sy1; sy++ { - for sx := sx0; sx < sx1; sx++ { - sr, sg, sb, _ := src.At(b.Min.X+sx, b.Min.Y+sy).RGBA() - r += uint64(sr >> 8) - g += uint64(sg >> 8) - bl += uint64(sb >> 8) - n++ - } - } - dst.SetRGBA(x, y, color.RGBA{ - R: uint8(r / n), - G: uint8(g / n), - B: uint8(bl / n), - A: 255, - }) + box := image.Rect(sx0, sy0, sx1, sy1).Add(b.Min) + dst.SetRGBA(x, y, avg(src, box)) } } return dst } + +// boxAvg returns the mean colour of the pixels of src inside r. +func boxAvg(src image.Image, r image.Rectangle) color.RGBA { + var red, g, bl, n uint64 + for y := r.Min.Y; y < r.Max.Y; y++ { + for x := r.Min.X; x < r.Max.X; x++ { + sr, sg, sb, _ := src.At(x, y).RGBA() + red += uint64(sr >> 8) + g += uint64(sg >> 8) + bl += uint64(sb >> 8) + n++ + } + } + return color.RGBA{R: uint8(red / n), G: uint8(g / n), B: uint8(bl / n), A: 255} +} + +// boxAvgYCbCr is boxAvg for *image.YCbCr, reading the sample planes directly. +// Each pixel is still converted to RGB before averaging so out-of-gamut +// samples clamp exactly as they do through At(). +func boxAvgYCbCr(src *image.YCbCr, r image.Rectangle) color.RGBA { + var red, g, bl, n uint64 + for y := r.Min.Y; y < r.Max.Y; y++ { + for x := r.Min.X; x < r.Max.X; x++ { + ci := src.COffset(x, y) + sr, sg, sb := color.YCbCrToRGB(src.Y[src.YOffset(x, y)], src.Cb[ci], src.Cr[ci]) + red += uint64(sr) + g += uint64(sg) + bl += uint64(sb) + n++ + } + } + return color.RGBA{R: uint8(red / n), G: uint8(g / n), B: uint8(bl / n), A: 255} +} diff --git a/thumb_image_test.go b/thumb_image_test.go new file mode 100644 index 0000000..20a3fe8 --- /dev/null +++ b/thumb_image_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "bytes" + "image" + "image/jpeg" + "testing" +) + +// testPhoto returns a JPEG-decoded (so *image.YCbCr, 4:2:0) image with an odd +// size and a non-zero origin to exercise the plane offset arithmetic. +func testPhoto(t testing.TB, w, h int) *image.YCbCr { + t.Helper() + src := image.NewRGBA(image.Rect(0, 0, w, h)) + for i := range src.Pix { + src.Pix[i] = uint8(i*7 + i/w) + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, src, &jpeg.Options{Quality: 90}); err != nil { + t.Fatal(err) + } + img, err := jpeg.Decode(&buf) + if err != nil { + t.Fatal(err) + } + return img.(*image.YCbCr).SubImage(image.Rect(3, 5, w, h)).(*image.YCbCr) +} + +func TestResizeBoxYCbCrMatchesGeneric(t *testing.T) { + src := testPhoto(t, 1001, 667) + fast := resizeBox(src, 120).(*image.RGBA) + // Hide the concrete type so resizeBox takes the generic At() path. + slow := resizeBox(struct{ image.Image }{src}, 120).(*image.RGBA) + if fast.Bounds() != slow.Bounds() { + t.Fatalf("bounds %v != %v", fast.Bounds(), slow.Bounds()) + } + for i := range fast.Pix { + if fast.Pix[i] != slow.Pix[i] { + t.Fatalf("pixel byte %d: fast %d, generic %d", i, fast.Pix[i], slow.Pix[i]) + } + } +} + +func BenchmarkResizeBox(b *testing.B) { + src := testPhoto(b, 4000, 3000) + b.Run("ycbcr", func(b *testing.B) { + for i := 0; i < b.N; i++ { + resizeBox(src, 300) + } + }) + b.Run("generic", func(b *testing.B) { + for i := 0; i < b.N; i++ { + resizeBox(struct{ image.Image }{src}, 300) + } + }) +}