Repository: pion/mediadevices (go)

Issue:
Add video scaling transform
Some scaling kernels are available in `x/image/draw` package.
https://pkg.go.dev/golang.org/x/image/draw?tab=doc#pkg-variables

Pull request:
Add video scaler
- Add video scaler transform
- Add scaler to screencast example
- Support size variable encoding of vpx

### Reference issue
Fixes #53


Patch:
diff --git a/examples/screenshare/main.go b/examples/screenshare/main.go
index 0bfafc7..f98d0b3 100644
--- a/examples/screenshare/main.go
+++ b/examples/screenshare/main.go
@@ -9,6 +9,7 @@ import (
 	_ "github.com/pion/mediadevices/pkg/codec/opus"     // This is required to register opus audio encoder
 	_ "github.com/pion/mediadevices/pkg/codec/vpx"      // This is required to register VP8/VP9 video encoder
 	_ "github.com/pion/mediadevices/pkg/driver/screen"  // This is required to register screen capture adapter
+	"github.com/pion/mediadevices/pkg/io/video"
 	"github.com/pion/webrtc/v2"
 )
 
@@ -52,9 +53,8 @@ func main() {
 		Video: func(c *mediadevices.MediaTrackConstraints) {
 			c.CodecName = videoCodecName
 			c.Enabled = true
-			c.Width = 640
-			c.Height = 480
-			c.BitRate = 100000 // 100kbps
+			c.BitRate = 100000                           // 100kbps
+			c.VideoTransform = video.Scale(-1, 360, nil) // Resize to 360p
 		},
 	})
 	if err != nil {
diff --git a/go.mod b/go.mod
index 0ab9bf1..42bbaae 100644
--- a/go.mod
+++ b/go.mod
@@ -10,5 +10,6 @@ require (
 	github.com/pion/rtp v1.2.0
 	github.com/pion/webrtc/v2 v2.1.19-0.20200106051345-726a16faa60d
 	github.com/satori/go.uuid v1.2.0
+	golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8
 	gopkg.in/hraban/opus.v2 v2.0.0-20191117073431-57179dff69a6
 )
diff --git a/pkg/codec/vpx/vpx.go b/pkg/codec/vpx/vpx.go
index 765abfb..a569ee4 100644
--- a/pkg/codec/vpx/vpx.go
+++ b/pkg/codec/vpx/vpx.go
@@ -65,6 +65,7 @@ import (
 type encoder struct {
 	codec      *C.vpx_codec_ctx_t
 	raw        *C.vpx_image_t
+	cfg        *C.vpx_codec_enc_cfg_t
 	r          video.Reader
 	frameIndex int
 	buff       []byte
@@ -126,6 +127,7 @@ func newEncoder(r video.Reader, p prop.Media, codecIface *C.vpx_codec_iface_t) (
 		r:          video.ToI420(r),
 		codec:      codec,
 		raw:        rawNoBuffer,
+		cfg:        cfg,
 		tStart:     t0,
 		tLastFrame: t0,
 	}, nil
@@ -145,6 +147,9 @@ func (e *encoder) Read(p []byte) (int, error) {
 		return 0, err
 	}
 	yuvImg := img.(*image.YCbCr)
+	bounds := yuvImg.Bounds()
+	height := C.int(bounds.Dy())
+	width := C.int(bounds.Dx())
 
 	e.raw.stride[0] = C.int(yuvImg.YStride)
 	e.raw.stride[1] = C.int(yuvImg.CStride)
@@ -152,6 +157,16 @@ func (e *encoder) Read(p []byte) (int, error) {
 
 	t := time.Now().Nanosecond() / 1000000
 
+	if e.cfg.g_w != C.uint(width) || e.cfg.g_h != C.uint(height) {
+		e.cfg.g_w, e.cfg.g_h = C.uint(width), C.uint(height)
+		if ec := C.vpx_codec_enc_config_set(e.codec, e.cfg); ec != C.VPX_CODEC_OK {
+			return 0, fmt.Errorf("vpx_codec_enc_config_set failed (%d)", ec)
+		}
+		e.raw.w, e.raw.h = C.uint(width), C.uint(height)
+		e.raw.r_w, e.raw.r_h = C.uint(width), C.uint(height)
+		e.raw.d_w, e.raw.d_h = C.uint(width), C.uint(height)
+	}
+
 	var flags int
 	if ec := C.encode_wrapper(
 		e.codec, e.raw,
diff --git a/pkg/io/video/scale.go b/pkg/io/video/scale.go
new file mode 100644
index 0000000..a7a40d6
--- /dev/null
+++ b/pkg/io/video/scale.go
@@ -0,0 +1,190 @@
+package video
+
+import (
+	"errors"
+	"image"
+
+	"golang.org/x/image/draw"
+)
+
+var (
+	scalerTestAlgos = map[string]Scaler{
+		"NearestNeighbor": ScalerNearestNeighbor,
+	}
+	scalerBenchAlgos = map[string]Scaler{
+		"NearestNeighbor": ScalerNearestNeighbor,
+		"ApproxBiLinear":  ScalerApproxBiLinear,
+		"BiLinear":        ScalerBiLinear,
+	}
+)
+
+// Scaler represents scaling algorithm
+type Scaler draw.Scaler
+
+// List of scaling algorithms
+var (
+	ScalerNearestNeighbor = Scaler(draw.NearestNeighbor)
+	ScalerApproxBiLinear  = Scaler(draw.ApproxBiLinear)
+	ScalerBiLinear        = Scaler(draw.BiLinear)
+	ScalerCatmullRom      = Scaler(draw.CatmullRom)
+)
+
+var errUnsupportedImageType = errors.New("scaling: unsupported image type")
+
+// Scale returns video scaling transform.
+// Setting scaler=nil to use default scaler. (ScalerNearestNeighbor)
+// Negative width or height value will keep the aspect ratio of incoming image.
+//
+// Note: computation cost to scale YCbCr format is 10 times higher than RGB
+// due to the implementation in x/image/draw package.
+func Scale(width, height int, scaler Scaler) TransformFunc {
+	return func(r Reader) Reader {
+		scalerCached := ScalerNearestNeighbor
+		if scaler != nil {
+			scalerCached = scaler
+		}
+
+		// cacheScaler makes cached version of Scaler if available.
+		cacheScaler := func(dRect, sRect image.Rectangle) {}
+		if kernel, ok := scaler.(interface {
+			NewScaler(int, int, int, int) draw.Scaler
+		}); ok {
+			cacheScaler = func(dRect, sRect image.Rectangle) {
+				scalerCached = kernel.NewScaler(dRect.Dx(), dRect.Dy(), sRect.Dx(), sRect.Dy())
+			}
+		}
+
+		var rect image.Rectangle
+		var imgScaled image.Image
+		if width > 0 && height > 0 {
+			rect = image.Rect(0, 0, width, height)
+		} else if width <= 0 && height <= 0 {
+			panic("Both width and height are negative!")
+		}
+
+		// updateRect updates output image size
+		updateRect := func(r image.Rectangle) {}
+		if height <= 0 {
+			updateRect = func(r image.Rectangle) {
+				h := r.Dy() * width / r.Dx()
+				rect = image.Rect(0, 0, width, h)
+			}
+		} else if width <= 0 {
+			updateRect = func(r image.Rectangle) {
+				w := r.Dx() * height / r.Dy()
+				rect = image.Rect(0, 0, w, height)
+			}
+		}
+
+		// fixedRect returns Rectangle of chroma plane
+		fixedRect := func(rect image.Rectangle, sr image.YCbCrSubsampleRatio) image.Rectangle {
+			switch sr {
+			case image.YCbCrSubsampleRatio444:
+			case image.YCbCrSubsampleRatio422:
+				rect.Max.X /= 2
+			case image.YCbCrSubsampleRatio420:
+				rect.Max.X /= 2
+				rect.Max.Y /= 2
+			}
+			return rect
+		}
+
+		src := &rgbLikeYCbCr{y: &image.Gray{}, cb: &image.Gray{}, cr: &image.Gray{}}
+		dst := &rgbLikeYCbCr{y: &image.Gray{}, cb: &image.Gray{}, cr: &image.Gray{}}
+
+		// ycbcrRealloc reallocs image.YCbCr if needed
+		ycbcrRealloc := func(i1 *image.YCbCr) {
+			if imgScaled == nil || imgScaled.ColorModel() != i1.ColorModel() {
+				imgScaled = image.NewYCbCr(rect, i1.SubsampleRatio)
+			}
+			imgDst := imgScaled.(*image.YCbCr)
+			if imgDst.Rect.Dx() != i1.Rect.Dx() || imgDst.Rect.Dy() != i1.Rect.Dy() {
+				updateRect(i1.Rect)
+				cacheScaler(rect, i1.Rect)
+			}
+
+			yDx := rect.Dx()
+			yDy := rect.Dy()
+			cRect := fixedRect(rect, i1.SubsampleRatio)
+			cDx := cRect.Dx()
+			cDy := cRect.Dx()
+			yLen := yDx * yDy
+			cLen := cDx * cDy
+			if len(imgDst.Y) < yLen {
+				if cap(imgDst.Y) < yLen {
+					imgDst.Y = make([]uint8, yLen)
+				}
+				imgDst.Y = imgDst.Y[:yLen]
+			}
+			if len(imgDst.Cr) < cLen {
+				if cap(imgDst.Cr) < cLen {
+					imgDst.Cr = make([]uint8, cLen)
+				}
+				imgDst.Cr = imgDst.Cr[:cLen]
+			}
+			if len(imgDst.Cb) < cLen {
+				if cap(imgDst.Cb) < cLen {
+					imgDst.Cb = make([]uint8, cLen)
+				}
+				imgDst.Cb = imgDst.Cb[:cLen]
+			}
+			*dst.y = image.Gray{Pix: imgDst.Y, Stride: imgDst.YStride, Rect: rect}
+			*dst.cb = image.Gray{Pix: imgDst.Cb, Stride: imgDst.CStride, Rect: cRect}
+			*dst.cr = image.Gray{Pix: imgDst.Cr, Stride: imgDst.CStride, Rect: cRect}
+		}
+		// ycbcrRealloc reallocs image.RGBA if needed
+		rgbaRealloc := func(i1 *image.RGBA) {
+			if imgScaled == nil || imgScaled.ColorModel() != i1.ColorModel() {
+				imgScaled = image.NewRGBA(rect)
+			}
+			imgDst := imgScaled.(*image.RGBA)
+			if imgDst.Rect.Dx() != i1.Rect.Dx() || imgDst.Rect.Dy() != i1.Rect.Dy() {
+				updateRect(i1.Rect)
+				cacheScaler(rect, i1.Rect)
+			}
+
+			l := 4 * rect.Dx() * rect.Dy()
+			if len(imgDst.Pix) < l {
+				if cap(imgDst.Pix) < l {
+					imgDst.Pix = make([]uint8, l)
+				}
+				imgDst.Pix = imgDst.Pix[:l]
+			}
+		}
+
+		return ReaderFunc(func() (image.Image, error) {
+			img, err := r.Read()
+			if err != nil {
+				return nil, err
+			}
+
+			switch v := img.(type) {
+			case *image.RGBA:
+				rgbaRealloc(v)
+				dst := imgScaled.(*image.RGBA)
+				scalerCached.Scale(dst, rect, v, v.Rect, draw.Src, nil)
+
+				cloned := *dst // clone metadata
+				return &cloned, nil
+
+			case *image.YCbCr:
+				ycbcrRealloc(v)
+				// Scale each plane
+				*src.y = image.Gray{Pix: v.Y, Stride: v.YStride, Rect: v.Rect}
+				*src.cb = image.Gray{
+					Pix: v.Cb, Stride: v.CStride, Rect: fixedRect(v.Rect, v.SubsampleRatio),
+				}
+				*src.cr = image.Gray{
+					Pix: v.Cr, Stride: v.CStride, Rect: fixedRect(v.Rect, v.SubsampleRatio),
+				}
+				scalerCached.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Src, nil)
+
+				cloned := *(imgScaled.(*image.YCbCr)) // clone metadata
+				return &cloned, nil
+
+			default:
+				return nil, errUnsupportedImageType
+			}
+		})
+	}
+}
diff --git a/pkg/io/video/scaler_cgo.c b/pkg/io/video/scaler_cgo.c
new file mode 100644
index 0000000..82e67b8
--- /dev/null
+++ b/pkg/io/video/scaler_cgo.c
@@ -0,0 +1,21 @@
+#include "_cgo_export.h"
+
+void fastNearestNeighbor(
+    unsigned char* dst, unsigned const char* src,
+    const int ch,
+    const int dw, const int dh, const int dstride,
+    const int sw, const int sh, const int sstride)
+{
+  for (int dy = 0; dy < dh; dy++)
+  {
+    const int sy = dy * sh / dh;
+    const unsigned char* src2 = &src[sy * sstride];
+
+    for (int dx = 0; dx < dw; dx++)
+    {
+      const int sx = ch * (dx * sw / dw);
+      for (int c = 0; c < ch; c++)
+        *(dst++) = src2[sx + c];
+    }
+  }
+}
diff --git a/pkg/io/video/scaler_cgo.go b/pkg/io/video/scaler_cgo.go
new file mode 100644
index 0000000..330233b
--- /dev/null
+++ b/pkg/io/video/scaler_cgo.go
@@ -0,0 +1,94 @@
+// +build cgo
+
+package video
+
+import (
+	"golang.org/x/image/draw"
+	"image"
+)
+
+// void fastNearestNeighbor(
+//     unsigned char* dst, unsigned const char* src,
+//     const int ch,
+//     const int dw, const int dh, const int dstride,
+//     const int sw, const int sh, const int sstride);
+import "C"
+
+// List of scaling algorithms
+var (
+	// ScalerFastNearestNeighbor is a CGO version of NearestNeighbor scaler.
+	// This is roughly 4-times faster than draw.NearestNeighbor.
+	ScalerFastNearestNeighbor = Scaler(&FastNearestNeighbor{})
+)
+
+var (
+	scalerTestAlgosCGO = map[string]Scaler{
+		"FastNearestNeighbor": ScalerFastNearestNeighbor,
+	}
+	scalerBenchAlgosCGO = map[string]Scaler{
+		"FastNearestNeighbor": ScalerFastNearestNeighbor,
+	}
+)
+
+func init() {
+	// Append test conditions
+	for k, v := range scalerTestAlgosCGO {
+		scalerTestAlgos[k] = v
+	}
+	for k, v := range scalerBenchAlgosCGO {
+		scalerBenchAlgos[k] = v
+	}
+}
+
+// FastNearestNeighbor is a CGO version of NearestNeighbor scaler.
+type FastNearestNeighbor struct {
+}
+
+// Scale implements the draw.Scaler interface.
+func (f *FastNearestNeighbor) Scale(dst draw.Image, dr image.Rectangle, src image.Image, sr image.Rectangle, op draw.Op, opts *draw.Options) {
+	switch s := src.(type) {
+	case (*image.RGBA):
+		d := dst.(*image.RGBA)
+		l := d.Stride * d.Rect.Dy()
+		if len(d.Pix) < l {
+			if cap(d.Pix) < l {
+				d.Pix = make([]uint8, l)
+			}
+			d.Pix = d.Pix[:l]
+		}
+		C.fastNearestNeighbor(
+			(*C.uchar)(&d.Pix[dr.Min.X+d.Stride*dr.Min.Y]),
+			(*C.uchar)(&s.Pix[sr.Min.X+s.Stride*sr.Min.Y]),
+			4,
+			C.int(dr.Dx()), C.int(dr.Dy()), C.int(d.Stride),
+			C.int(sr.Dx()), C.int(sr.Dy()), C.int(s.Stride),
+		)
+	case (*image.Gray):
+		d := dst.(*image.Gray)
+		l := d.Stride * d.Rect.Dy()
+		if len(d.Pix) < l {
+			if cap(d.Pix) < l {
+				d.Pix = make([]uint8, l)
+			}
+			d.Pix = d.Pix[:l]
+		}
+		C.fastNearestNeighbor(
+			(*C.uchar)(&d.Pix[dr.Min.X+d.Stride*dr.Min.Y]),
+			(*C.uchar)(&s.Pix[sr.Min.X+s.Stride*sr.Min.Y]),
+			1,
+			C.int(dr.Dx()), C.int(dr.Dy()), C.int(d.Stride),
+			C.int(sr.Dx()), C.int(sr.Dy()), C.int(s.Stride),
+		)
+
+	case (*rgbLikeYCbCr):
+		d := dst.(*rgbLikeYCbCr)
+		f.Scale(d.y, dr, s.y, sr, op, opts)
+		dr2 := image.Rect(0, 0, d.cb.Stride, len(d.cb.Pix)/d.cb.Stride)
+		sr2 := image.Rect(0, 0, s.cb.Stride, len(s.cb.Pix)/s.cb.Stride)
+		f.Scale(d.cb, dr2, s.cb, sr2, op, opts)
+		f.Scale(d.cr, dr2, s.cr, sr2, op, opts)
+
+	default:
+		panic("unimplemented")
+	}
+}
diff --git a/pkg/io/video/scaleycrcb.go b/pkg/io/video/scaleycrcb.go
new file mode 100644
index 0000000..a951a11
--- /dev/null
+++ b/pkg/io/video/scaleycrcb.go
@@ -0,0 +1,39 @@
+package video
+
+import (
+	"image"
+	"image/color"
+)
+
+type rgbLikeYCbCr struct {
+	y  *image.Gray
+	cb *image.Gray
+	cr *image.Gray
+}
+
+func (p *rgbLikeYCbCr) ColorModel() color.Model {
+	return color.RGBAModel
+}
+
+func (p *rgbLikeYCbCr) Bounds() image.Rectangle {
+	return p.y.Rect
+}
+
+func (p *rgbLikeYCbCr) At(x, y int) color.Color {
+	var yy, cb, cr uint8
+	yy = p.y.GrayAt(x, y).Y
+	if (image.Point{x, y}.In(p.cb.Rect)) {
+		cb = p.cb.GrayAt(x, y).Y
+		cr = p.cr.GrayAt(x, y).Y
+	}
+	return color.RGBA{yy, cb, cr, 255}
+}
+
+func (p *rgbLikeYCbCr) Set(x, y int, c color.Color) {
+	rgb := c.(*color.RGBA64)
+	p.y.SetGray(x, y, color.Gray{uint8(rgb.R / 0x100)})
+	if (image.Point{x, y}.In(p.cb.Rect)) {
+		p.cb.SetGray(x, y, color.Gray{uint8(rgb.G / 0x100)})
+		p.cr.SetGray(x, y, color.Gray{uint8(rgb.B / 0x100)})
+	}
+}


Test patch:
diff --git a/pkg/io/video/convert_test.go b/pkg/io/video/convert_test.go
index 8af92ac..28f782f 100644
--- a/pkg/io/video/convert_test.go
+++ b/pkg/io/video/convert_test.go
@@ -6,6 +6,11 @@ import (
 	"testing"
 )
 
+var imageSizes = map[string][2]int{
+	"480p":  [2]int{720, 480},
+	"1080p": [2]int{1920, 1080},
+}
+
 func TestToI420(t *testing.T) {
 	cases := map[string]struct {
 		src      image.Image
@@ -115,7 +120,34 @@ func TestToI420(t *testing.T) {
 				t.Fatalf("Unexpected error: %v", err)
 			}
 			if !reflect.DeepEqual(c.expected, out) {
-				t.Errorf("Expected output image:\n%v\ngot:\n%v\n", c.expected, out)
+				t.Errorf("Expected output image:\n%v\ngot:\n%v", c.expected, out)
+			}
+		})
+	}
+}
+
+func BenchmarkToI420(b *testing.B) {
+	for name, sz := range imageSizes {
+		cases := map[string]image.Image{
+			"I444": image.NewYCbCr(image.Rect(0, 0, sz[0], sz[1]), image.YCbCrSubsampleRatio444),
+			"I422": image.NewYCbCr(image.Rect(0, 0, sz[0], sz[1]), image.YCbCrSubsampleRatio422),
+			"I420": image.NewYCbCr(image.Rect(0, 0, sz[0], sz[1]), image.YCbCrSubsampleRatio420),
+		}
+		b.Run(name, func(b *testing.B) {
+			for name, img := range cases {
+				img := img
+				b.Run(name, func(b *testing.B) {
+					r := ToI420(ReaderFunc(func() (image.Image, error) {
+						return img, nil
+					}))
+
+					for i := 0; i < b.N; i++ {
+						_, err := r.Read()
+						if err != nil {
+							b.Fatalf("Unexpected error: %v", err)
+						}
+					}
+				})
 			}
 		})
 	}
diff --git a/pkg/io/video/scale_test.go b/pkg/io/video/scale_test.go
new file mode 100644
index 0000000..893153a
--- /dev/null
+++ b/pkg/io/video/scale_test.go
@@ -0,0 +1,280 @@
+package video
+
+import (
+	"image"
+	"reflect"
+	"testing"
+)
+
+func TestScale(t *testing.T) {
+	cases := map[string]struct {
+		src           image.Image
+		width, height int
+		expected      image.Image
+	}{
+		"RGBA": {
+			src: &image.RGBA{
+				Pix: []uint8{
+					// R     G     B     A |   R     G     B     A |   R     G     B     A |   R     G     B     A
+					0x80, 0x00, 0x00, 0xFF, 0x80, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF,
+					0x80, 0x00, 0x00, 0xFF, 0x80, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF,
+					0x00, 0x40, 0x00, 0xFF, 0x00, 0x40, 0x00, 0xFF, 0x00, 0x00, 0x60, 0xFF, 0x00, 0x00, 0x60, 0xFF,
+					0x00, 0x40, 0x00, 0xFF, 0x00, 0x40, 0x00, 0xFF, 0x00, 0x00, 0x60, 0xFF, 0x00, 0x00, 0x60, 0xFF,
+				},
+				Stride: 16,
+				Rect:   image.Rect(0, 0, 4, 4),
+			},
+			width:  2,
+			height: 2,
+			expected: &image.RGBA{
+				Pix: []uint8{
+					// R     G     B     A |   R     G     B     A
+					0x80, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF,
+					0x00, 0x40, 0x00, 0xFF, 0x00, 0x00, 0x60, 0xFF,
+				},
+				Stride: 8,
+				Rect:   image.Rect(0, 0, 2, 2),
+			},
+		},
+		"I444": {
+			src: &image.YCbCr{
+				SubsampleRatio: image.YCbCrSubsampleRatio444,
+				Y: []uint8{
+					0xF0, 0xF0, 0x00, 0x00, 0x00, 0x00,
+					0xF0, 0xF0, 0x00, 0x00, 0x00, 0x00,
+					0x00, 0x00, 0x00, 0x00, 0x40, 0x40,
+					0x00, 0x00, 0x00, 0x00, 0x40, 0x40,
+					0x00, 0x00, 0x80, 0x80, 0x00, 0x00,
+					0x00, 0x00, 0x80, 0x80, 0x00, 0x00,
+				},
+				Cb: []uint8{
+					0x20, 0x20, 0x80, 0x80, 0x80, 0x80,
+					0x20, 0x20, 0x80, 0x80, 0x80, 0x80,
+					0x80, 0x80, 0x80, 0x80, 0xC0, 0xC0,
+					0x80, 0x80, 0x80, 0x80, 0xC0, 0xC0,
+					0x80, 0x80, 0x80, 0x80, 0x80, 0x80,
+					0x80, 0x80, 0x80, 0x80, 0x80, 0x80,
+				},
+				Cr: []uint8{
+					0xE0, 0xE0, 0x80, 0x80, 0x80, 0x80,
+					0xE0, 0xE0, 0x80, 0x80, 0x80, 0x80,
+					0x80, 0x80, 0x80, 0x80, 0x80, 0x80,
+					0x80, 0x80, 0x80, 0x80, 0x80, 0x80,
+					0x80, 0x80, 0x40, 0x40, 0x80, 0x80,
+					0x80, 0x80, 0x40, 0x40, 0x80, 0x80,
+				},
+				YStride: 6,
+				CStride: 6,
+				Rect:    image.Rect(0, 0, 6, 6),
+			},
+			width:  3,
+			height: 3,
+			expected: &image.YCbCr{
+				SubsampleRatio: image.YCbCrSubsampleRatio444,
+				Y: []uint8{
+					0xF0, 0x00, 0x00,
+					0x00, 0x00, 0x40,
+					0x00, 0x80, 0x00,
+				},
+				Cb: []uint8{
+					0x20, 0x80, 0x80,
+					0x80, 0x80, 0xC0,
+					0x80, 0x80, 0x80,
+				},
+				Cr: []uint8{
+					0xE0, 0x80, 0x80,
+					0x80, 0x80, 0x80,
+					0x80, 0x40, 0x80,
+				},
+				YStride: 3,
+				CStride: 3,
+				Rect:    image.Rect(0, 0, 3, 3),
+			},
+		},
+		"I422": {
+			src: &image.YCbCr{
+				SubsampleRatio: image.YCbCrSubsampleRatio422,
+				Y: []uint8{
+					0xF0, 0xF0, 0x10, 0x10, 0x00, 0x00, 0x00, 0x00,
+					0xF0, 0xF0, 0x10, 0x10, 0x00, 0x00, 0x00, 0x00,
+					0x00, 0x00, 0x00, 0x00, 0x40, 0x40, 0x00, 0x00,
+					0x00, 0x00, 0x00, 0x00, 0x40, 0x40, 0x00, 0x00,
+					0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+					0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+					0x00, 0x00, 0x80, 0x80, 0x30, 0x30, 0x00, 0x00,
+					0x00, 0x00, 0x80, 0x80, 0x30, 0x30, 0x00, 0x00,
+				},
+				Cb: []uint8{
+					0x20, 0x20, 0x80, 0x80,
+					0x20, 0x20, 0x80, 0x80,
+					0x80, 0x80, 0x80, 0x80,
+					0x80, 0x80, 0x80, 0x80,
+					0x80, 0x80, 0x80, 0x80,
+					0x80, 0x80, 0x80, 0x80,
+					0x80, 0x80, 0xE0, 0xE0,
+					0x80, 0x80, 0xE0, 0xE0,
+				},
+				Cr: []uint8{
+					0xE0, 0xE0, 0x80, 0x80,
+					0xE0, 0xE0, 0x80, 0x80,
+					0x80, 0x80, 0x80, 0x80,
+					0x80, 0x80, 0x80, 0x80,
+					0x80, 0x80, 0x80, 0x80,
+					0x80, 0x80, 0x80, 0x80,
+					0xF0, 0xF0, 0x40, 0x40,
+					0xF0, 0xF0, 0x40, 0x40,
+				},
+				YStride: 8,
+				CStride: 4,
+				Rect:    image.Rect(0, 0, 8, 8),
+			},
+			width:  4,
+			height: 4,
+			expected: &image.YCbCr{
+				SubsampleRatio: image.YCbCrSubsampleRatio422,
+				Y: []uint8{
+					0xF0, 0x10, 0x00, 0x00,
+					0x00, 0x00, 0x40, 0x00,
+					0x00, 0x00, 0x00, 0x00,
+					0x00, 0x80, 0x30, 0x00,
+				},
+				Cb: []uint8{
+					0x20, 0x80,
+					0x80, 0x80,
+					0x80, 0x80,
+					0x80, 0xE0,
+				},
+				Cr: []uint8{
+					0xE0, 0x80,
+					0x80, 0x80,
+					0x80, 0x80,
+					0xF0, 0x40,
+				},
+				YStride: 4,
+				CStride: 2,
+				Rect:    image.Rect(0, 0, 4, 4),
+			},
+		},
+		"I420": {
+			src: &image.YCbCr{
+				SubsampleRatio: image.YCbCrSubsampleRatio420,
+				Y: []uint8{
+					0xF0, 0xF0, 0x10, 0x10, 0x00, 0x00, 0x00, 0x00,
+					0xF0, 0xF0, 0x10, 0x10, 0x00, 0x00, 0x00, 0x00,
+					0x00, 0x00, 0x00, 0x00, 0x40, 0x40, 0x00, 0x00,
+					0x00, 0x00, 0x00, 0x00, 0x40, 0x40, 0x00, 0x00,
+					0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+					0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+					0x00, 0x00, 0x80, 0x80, 0x30, 0x30, 0x00, 0x00,
+					0x00, 0x00, 0x80, 0x80, 0x30, 0x30, 0x00, 0x00,
+				},
+				Cb: []uint8{
+					0x20, 0x20, 0x80, 0x80,
+					0x20, 0x20, 0x80, 0x80,
+					0x80, 0x80, 0xE0, 0xE0,
+					0x80, 0x80, 0xE0, 0xE0,
+				},
+				Cr: []uint8{
+					0xE0, 0xE0, 0x80, 0x80,
+					0xE0, 0xE0, 0x80, 0x80,
+					0xF0, 0xF0, 0x40, 0x40,
+					0xF0, 0xF0, 0x40, 0x40,
+				},
+				YStride: 8,
+				CStride: 4,
+				Rect:    image.Rect(0, 0, 8, 8),
+			},
+			width:  4,
+			height: 4,
+			expected: &image.YCbCr{
+				SubsampleRatio: image.YCbCrSubsampleRatio420,
+				Y: []uint8{
+					0xF0, 0x10, 0x00, 0x00,
+					0x00, 0x00, 0x40, 0x00,
+					0x00, 0x00, 0x00, 0x00,
+					0x00, 0x80, 0x30, 0x00,
+				},
+				Cb: []uint8{
+					0x20, 0x80,
+					0x80, 0xE0,
+				},
+				Cr: []uint8{
+					0xE0, 0x80,
+					0xF0, 0x40,
+				},
+				YStride: 4,
+				CStride: 2,
+				Rect:    image.Rect(0, 0, 4, 4),
+			},
+		},
+	}
+	for name, algo := range scalerTestAlgos {
+		algo := algo
+		t.Run(name, func(t *testing.T) {
+			for name, c := range cases {
+				c := c
+				t.Run(name, func(t *testing.T) {
+					trans := Scale(c.width, c.height, algo)
+					r := trans(ReaderFunc(func() (image.Image, error) {
+						return c.src, nil
+					}))
+					for i := 0; i < 4; i++ {
+						out, err := r.Read()
+						if err != nil {
+							t.Fatalf("Unexpected error: %v", err)
+						}
+						if !reflect.DeepEqual(c.expected, out) {
+							t.Errorf("Expected output image:\n%v\ngot:\n%v\nrepeat: %d", c.expected, out, i)
+						}
+						// Destroy output contents
+						switch v := out.(type) {
+						case *image.RGBA:
+							v.Stride = 10
+							v.Pix = v.Pix[:1]
+							v.Rect.Max.X = 1
+						case *image.YCbCr:
+							v.YStride = 10
+							v.CStride = 100
+							v.Y = v.Y[:1]
+							v.Cb = v.Cb[:2]
+							v.Cr = v.Cr[:1]
+							v.Rect.Max.X = 1
+						}
+					}
+				})
+			}
+		})
+	}
+}
+
+func BenchmarkScale(b *testing.B) {
+	for name, algo := range scalerBenchAlgos {
+		algo := algo
+		b.Run(name, func(b *testing.B) {
+			for name, sz := range imageSizes {
+				cases := map[string]image.Image{
+					"RGBA": image.NewRGBA(image.Rect(0, 0, sz[0], sz[1])),
+					"I444": image.NewYCbCr(image.Rect(0, 0, sz[0], sz[1]), image.YCbCrSubsampleRatio444),
+				}
+				b.Run(name, func(b *testing.B) {
+					for name, img := range cases {
+						img := img
+						b.Run(name, func(b *testing.B) {
+							trans := Scale(640, 360, algo)
+							r := trans(ReaderFunc(func() (image.Image, error) {
+								return img, nil
+							}))
+
+							for i := 0; i < b.N; i++ {
+								_, err := r.Read()
+								if err != nil {
+									b.Fatalf("Unexpected error: %v", err)
+								}
+							}
+						})
+					}
+				})
+			}
+		})
+	}
+}