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 000000000..1fdacd651 Binary files /dev/null and b/pkg/images/testdata/f1-s.jpg differ diff --git a/pkg/server/image.go b/pkg/server/image.go index 11f898fd6..1d5735900 100644 --- a/pkg/server/image.go +++ b/pkg/server/image.go @@ -33,7 +33,6 @@ import ( "camlistore.org/pkg/blobserver" "camlistore.org/pkg/images" "camlistore.org/pkg/magic" - "camlistore.org/pkg/misc/resize" "camlistore.org/pkg/schema" ) @@ -149,8 +148,6 @@ func (ih *ImageHandler) scaledCached(buf *bytes.Buffer, file *blobref.BlobRef) ( } func (ih *ImageHandler) scaleImage(buf *bytes.Buffer, file *blobref.BlobRef) (format string, err error) { - mw, mh := ih.MaxWidth, ih.MaxHeight - fr, err := schema.NewFileReader(ih.storageSeekFetcher(), file) if err != nil { return format, err @@ -161,7 +158,8 @@ func (ih *ImageHandler) scaleImage(buf *bytes.Buffer, file *blobref.BlobRef) (fo if err != nil { return format, fmt.Errorf("image resize: error reading image %s: %v", file, err) } - i, imConfig, err := images.Decode(bytes.NewReader(buf.Bytes()), nil) + i, imConfig, err := images.Decode(bytes.NewReader(buf.Bytes()), + &images.DecodeOpts{MaxWidth: ih.MaxWidth, MaxHeight: ih.MaxHeight}) if err != nil { return format, err } @@ -176,34 +174,7 @@ func (ih *ImageHandler) scaleImage(buf *bytes.Buffer, file *blobref.BlobRef) (fo b = i.Bounds() } - // only do downscaling, otherwise just serve the original image - if mw < b.Dx() || mh < b.Dy() { - useBytesUnchanged = false - - 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() - } - 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 {