images: move rescaling from server/image to pkg/images

Fixes http://code.google.com/p/camlistore/issues/detail?id=94

Change-Id: Ifa73e0a3ccbbcaef31ae8870d39f63b8a90aad26
This commit is contained in:
mpl 2013-02-14 18:18:58 +01:00
parent 1a79ebeb30
commit fbcb4411df
4 changed files with 159 additions and 50 deletions

View File

@ -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

View File

@ -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)
}

BIN
pkg/images/testdata/f1-s.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 B

View File

@ -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 {