From fbcb4411df1031b50926856371aab1e7c952449d Mon Sep 17 00:00:00 2001 From: mpl Date: Thu, 14 Feb 2013 18:18:58 +0100 Subject: [PATCH] images: move rescaling from server/image to pkg/images Fixes http://code.google.com/p/camlistore/issues/detail?id=94 Change-Id: Ifa73e0a3ccbbcaef31ae8870d39f63b8a90aad26 --- pkg/images/images.go | 88 ++++++++++++++++++++++++++++++----- pkg/images/images_test.go | 88 +++++++++++++++++++++++++++++++---- pkg/images/testdata/f1-s.jpg | Bin 0 -> 960 bytes pkg/server/image.go | 33 +------------ 4 files changed, 159 insertions(+), 50 deletions(-) create mode 100644 pkg/images/testdata/f1-s.jpg diff --git a/pkg/images/images.go b/pkg/images/images.go index c45dd454b..567dffaf8 100644 --- a/pkg/images/images.go +++ b/pkg/images/images.go @@ -21,7 +21,6 @@ import ( "fmt" "image" "image/draw" - "image/jpeg" "io" "log" "os" @@ -29,6 +28,7 @@ import ( _ "image/gif" _ "image/png" + "camlistore.org/pkg/misc/resize" "camlistore.org/third_party/github.com/camlistore/goexif/exif" ) @@ -57,9 +57,18 @@ type DecodeOpts struct { Flip interface{} // MaxWidgth and MaxHeight optionally specify bounds on the - // final image's size. + // image's size. Rescaling is done before flipping or rotating. + // Proportions are conserved, so the smallest of the two is used + // as the decisive one if needed. MaxWidth, MaxHeight int + // ScaleWidth and ScaleHeight optionally specify how to rescale the + // image's dimensions. Rescaling is done before flipping or rotating. + // Proportions are conserved, so the smallest of the two is used + // as the decisive one if needed. + // They overrule MaxWidth and MaxHeight. + ScaleWidth, ScaleHeight float32 + // TODO: consider alternate options if scaled ratio doesn't // match original ratio: // Crop bool @@ -164,6 +173,53 @@ func flip(im image.Image, dir FlipDirection) image.Image { return im } +func rescale(im image.Image, opts *DecodeOpts) image.Image { + mw, mh := opts.MaxWidth, opts.MaxHeight + mwf, mhf := opts.ScaleWidth, opts.ScaleHeight + b := im.Bounds() + // only do downscaling, otherwise just serve the original image + if !opts.wantRescale(b) { + return im + } + // ScaleWidth and ScaleHeight overrule MaxWidth and MaxHeight + if mwf > 0.0 && mwf <= 1 { + mw = int(mwf * float32(b.Dx())) + } + if mhf > 0.0 && mhf <= 1 { + mh = int(mhf * float32(b.Dy())) + } + + const huge = 2400 + // If it's gigantic, it's more efficient to downsample first + // and then resize; resizing will smooth out the roughness. + // (trusting the moustachio guys on that one). + if b.Dx() > huge || b.Dy() > huge { + w, h := mw*2, mh*2 + if b.Dx() > b.Dy() { + w = b.Dx() * h / b.Dy() + } else { + h = b.Dy() * w / b.Dx() + } + im = resize.Resample(im, b, w, h) + b = im.Bounds() + } + // conserve proportions. use the smallest of the two as the decisive one. + if mw > mh { + mw = b.Dx() * mh / b.Dy() + } else { + mh = b.Dy() * mw / b.Dx() + } + return resize.Resize(im, b, mw, mh) +} + +func (opts *DecodeOpts) wantRescale(b image.Rectangle) bool { + return opts != nil && + (opts.MaxWidth > 0 && opts.MaxWidth < b.Dx() || + opts.MaxHeight > 0 && opts.MaxHeight < b.Dy() || + opts.ScaleWidth > 0.0 && opts.ScaleWidth < float32(b.Dx()) || + opts.ScaleHeight > 0.0 && opts.ScaleHeight < float32(b.Dy())) +} + func (opts *DecodeOpts) forcedRotate() bool { return opts != nil && opts.Rotate != nil } @@ -195,20 +251,24 @@ func Decode(r io.Reader, opts *DecodeOpts) (image.Image, Config, error) { flipMode := FlipDirection(0) if opts.useEXIF() { ex, err := exif.Decode(tr) - if err != nil { - imageDebug("No valid EXIF; will not rotate or flip.") + maybeRescale := func() (image.Image, Config, error) { im, format, err := image.Decode(io.MultiReader(&buf, r)) + if err == nil && opts.wantRescale(im.Bounds()) { + im = rescale(im, opts) + c.Modified = true + } c.Format = format c.setBounds(im) return im, c, err } + if err != nil { + imageDebug("No valid EXIF; will not rotate or flip.") + return maybeRescale() + } tag, err := ex.Get(exif.Orientation) if err != nil { imageDebug("No \"Orientation\" tag in EXIF; will not rotate or flip.") - im, format, err := image.Decode(io.MultiReader(&buf, r)) - c.Format = format - c.setBounds(im) - return im, c, err + return maybeRescale() } orient := tag.Int(0) switch orient { @@ -249,16 +309,22 @@ func Decode(r io.Reader, opts *DecodeOpts) (image.Image, Config, error) { } } - im, err := jpeg.Decode(io.MultiReader(&buf, r)) + im, format, err := image.Decode(io.MultiReader(&buf, r)) if err != nil { return nil, c, err } + rescaled := false + if opts.wantRescale(im.Bounds()) { + im = rescale(im, opts) + rescaled = true + } im = flip(rotate(im, angle), flipMode) modified := true - if angle == 0 && flipMode == 0 { + if angle == 0 && flipMode == 0 && !rescaled { modified = false } - c.Format = "jpeg" + + c.Format = format c.Modified = modified c.setBounds(im) return im, c, nil diff --git a/pkg/images/images_test.go b/pkg/images/images_test.go index 341b79b5b..f4c830451 100644 --- a/pkg/images/images_test.go +++ b/pkg/images/images_test.go @@ -20,7 +20,7 @@ import ( "image" "image/jpeg" "os" - "path" + "path/filepath" "strings" "testing" "time" @@ -35,7 +35,7 @@ func equals(im1, im2 image.Image) bool { for x := 0; x < im1.Bounds().Dx(); x++ { r1, g1, b1, a1 := im1.At(x, y).RGBA() r2, g2, b2, a2 := im2.At(x, y).RGBA() - if !(r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2) { + if r1 != r2 || g1 != g2 || b1 != b2 || a1 != a2 { return false } } @@ -44,7 +44,20 @@ func equals(im1, im2 image.Image) bool { } func straightFImage(t *testing.T) image.Image { - g, err := os.Open(path.Join(datadir, "f1.jpg")) + g, err := os.Open(filepath.Join(datadir, "f1.jpg")) + if err != nil { + t.Fatal(err) + } + defer g.Close() + straightF, err := jpeg.Decode(g) + if err != nil { + t.Fatal(err) + } + return straightF +} + +func smallStraightFImage(t *testing.T) image.Image { + g, err := os.Open(filepath.Join(datadir, "f1-s.jpg")) if err != nil { t.Fatal(err) } @@ -69,14 +82,16 @@ func sampleNames(t *testing.T) []string { return samples } -func TestExifCorrection(t *testing.T) { +// TestEXIFCorrection tests that the input files with EXIF metadata +// are correctly automatically rotated/flipped when decoded. +func TestEXIFCorrection(t *testing.T) { samples := sampleNames(t) straightF := straightFImage(t) for _, v := range samples { - if !strings.Contains(v, "exif") { + if !strings.Contains(v, "exif") || strings.HasSuffix(v, "-s.jpg") { continue } - name := path.Join(datadir, v) + name := filepath.Join(datadir, v) t.Logf("correcting %s with EXIF Orientation", name) f, err := os.Open(name) if err != nil { @@ -93,11 +108,17 @@ func TestExifCorrection(t *testing.T) { } } +// TestForcedCorrection tests that manually specifying the +// rotation/flipping to be applied when decoding works as +// expected. func TestForcedCorrection(t *testing.T) { samples := sampleNames(t) straightF := straightFImage(t) for _, v := range samples { - name := path.Join(datadir, v) + if strings.HasSuffix(v, "-s.jpg") { + continue + } + name := filepath.Join(datadir, v) t.Logf("forced correction of %s", name) f, err := os.Open(name) if err != nil { @@ -137,10 +158,61 @@ func TestForcedCorrection(t *testing.T) { } } +// TestRescale verifies that rescaling an image, without +// any rotation/flipping, produces the expected image. +func TestRescale(t *testing.T) { + name := filepath.Join(datadir, "f1.jpg") + t.Logf("rescaling %s with half-width and half-height", name) + f, err := os.Open(name) + if err != nil { + t.Fatal(err) + } + defer f.Close() + rescaledIm, _, err := Decode(f, &DecodeOpts{ScaleWidth: 0.5, ScaleHeight: 0.5}) + if err != nil { + t.Fatal(err) + } + + smallIm := smallStraightFImage(t) + + if !equals(rescaledIm, smallIm) { + t.Fatalf("%v not properly rescaled", name) + } +} + +// TestRescaleEXIF verifies that rescaling an image, followed +// by the automatic EXIF correction (rotation/flipping), +// produces the expected image. All the possible correction +// modes are tested. +func TestRescaleEXIF(t *testing.T) { + smallStraightF := smallStraightFImage(t) + samples := sampleNames(t) + for _, v := range samples { + if !strings.Contains(v, "exif") { + continue + } + name := filepath.Join(datadir, v) + t.Logf("rescaling %s with half-width and half-height", name) + f, err := os.Open(name) + if err != nil { + t.Fatal(err) + } + defer f.Close() + rescaledIm, _, err := Decode(f, &DecodeOpts{ScaleWidth: 0.5, ScaleHeight: 0.5}) + if err != nil { + t.Fatal(err) + } + + if !equals(rescaledIm, smallStraightF) { + t.Fatalf("%v not properly rescaled", name) + } + } +} + // TODO(mpl): move this test to the goexif lib if/when we contribute // back the DateTime stuff to upstream. func TestDateTime(t *testing.T) { - f, err := os.Open(path.Join(datadir, "f1-exif.jpg")) + f, err := os.Open(filepath.Join(datadir, "f1-exif.jpg")) if err != nil { t.Fatal(err) } diff --git a/pkg/images/testdata/f1-s.jpg b/pkg/images/testdata/f1-s.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1fdacd651786ca1181de034aa39324d2ad637cce GIT binary patch literal 960 zcmex=L?6mQqXwbzE zD#l4gO`Kd};u4Zls%q*Qnp!5NX66=_R?aT2ZtfnQUcn)uVc`*xQOPN(Y3Ui6S;Zx# zW#tu>Rn0A}ZS5VMU6UqHnL2IyjG40*Enc#8+42=DS8dw7W$U)>J9h3mboj{8W5-XN zJay^vm8;jT-?(|};iJb-o<4j2;^nK4pFV&2`tAFVpT8IxnBgG}@eq=K1cClxVqsxs zVF&q#k*OSrnFU!`6%E;h90S=C3x$=88aYIqCNA7~kW<+>=!0ld(M2vX6_bamA3mRh)e{h!nQ7iw)RQyk>_#fkR5Yp8D zxhVd_j{Of!{xh^5{m;N%{BPCsL+5|E+dq^6ibVcr2)zHc{_Xvrt^XNP|1%sp{{8nq zh2;MXYZm=ycs9BIXH4LK1~$Y04Ceyt-<{n5{hjvjKMVLD@MEYE0IInbSpV5`|DSb0 zHH#YTPl*5H02;&slxV;BpW$#|{jtFM!)73KXwrX%S*k$CbpI3f{}B`aVY~gSbu~Kw z8Cr_}37-EEI{&Nezw7_*{%4pG|1JKHPyPP$KNS1_2ps<>v-3X#uj_vXNg(D`hhQ%6 ze;L#NXx{(f^q=9O-+zWfcm6Z1+WFD{pOF0z51>%ye}*gEzm~xI@1lSI1u(jb1h9W#$MEWUpig;#o|pp+jk}ZU-`@%U{?ma~B+x huge || b.Dy() > huge { - w, h := mw*2, mh*2 - if b.Dx() > b.Dy() { - w = b.Dx() * h / b.Dy() - } else { - h = b.Dy() * w / b.Dx() - } - i = resize.Resample(i, i.Bounds(), w, h) - b = i.Bounds() - } - // conserve proportions. use the smallest of the two as the decisive one. - if mw > mh { - mw = b.Dx() * mh / b.Dy() - } else { - mh = b.Dy() * mw / b.Dx() - } - } - if !useBytesUnchanged { - i = resize.Resize(i, b, mw, mh) // Encode as a new image buf.Reset() switch format {