Repository: pion/mediadevices
Pull request #62 (2020-02-16 09:28:52)

Optimize image processing
branched from #54 non-ycbcr-input

at-wat: After applying this and https://github.com/pion/mediadevices/pull/55/commits/c46a89b4ba5b12559bdbc8f0a9b997098ffb9d90, CPU usage of screen casting example decreases to 40% on my laptop, which was saturated to 100% before.
(but, still twice or three times heavyer than gstreamer)
lherman-cs: Oops, sorry. I didn't see this one. Could change the target to be master instead.
at-wat: rebeased

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..6dc7935 100644
--- a/pkg/codec/vpx/vpx.go
+++ b/pkg/codec/vpx/vpx.go
@@ -65,11 +65,13 @@ 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
 	tStart     int
 	tLastFrame int
+	frame      []byte
 }
 
 func init() {
@@ -107,6 +109,9 @@ func newEncoder(r video.Reader, p prop.Media, codecIface *C.vpx_codec_iface_t) (
 	cfg.rc_target_bitrate = C.uint(p.BitRate) / 1000
 	cfg.kf_max_dist = C.uint(p.KeyFrameInterval)
 
+	cfg.rc_resize_allowed = 0
+	cfg.g_pass = C.VPX_RC_ONE_PASS
+
 	raw := &C.vpx_image_t{}
 	if C.vpx_img_alloc(raw, C.VPX_IMG_FMT_I420, cfg.g_w, cfg.g_h, 1) == nil {
 		return nil, errors.New("vpx_img_alloc failed")
@@ -126,8 +131,10 @@ 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,
+		frame:      make([]byte, 1024),
 	}, nil
 }
 
@@ -145,6 +152,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 +162,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,
@@ -164,7 +184,7 @@ func (e *encoder) Read(p []byte) (int, error) {
 	e.frameIndex++
 	e.tLastFrame = t
 
-	var frame []byte
+	e.frame = e.frame[:0]
 	var iter C.vpx_codec_iter_t
 	for {
 		pkt := C.vpx_codec_get_cx_data(e.codec, &iter)
@@ -173,12 +193,12 @@ func (e *encoder) Read(p []byte) (int, error) {
 		}
 		if pkt.kind == C.VPX_CODEC_CX_FRAME_PKT {
 			encoded := C.GoBytes(unsafe.Pointer(C.pktBuf(pkt)), C.pktSz(pkt))
-			frame = append(frame, encoded...)
+			e.frame = append(e.frame, encoded...)
 		}
 	}
-	n, err := mio.Copy(p, frame)
+	n, err := mio.Copy(p, e.frame)
 	if err != nil {
-		e.buff = frame
+		e.buff = e.frame
 	}
 	return n, err
 }
diff --git a/pkg/driver/screen/x11_linux.go b/pkg/driver/screen/x11_linux.go
index 6717827..12b3559 100644
--- a/pkg/driver/screen/x11_linux.go
+++ b/pkg/driver/screen/x11_linux.go
@@ -65,9 +65,11 @@ func (s *screen) VideoRecord(p prop.Media) (video.Reader, error) {
 	}
 	s.tick = time.NewTicker(time.Duration(float32(time.Second) / p.FrameRate))
 
+	var dst image.RGBA
+
 	r := video.ReaderFunc(func() (image.Image, error) {
 		<-s.tick.C
-		return s.reader.Read(), nil
+		return s.reader.Read().ToRGBA(&dst), nil
 	})
 	return r, nil
 }
diff --git a/pkg/driver/screen/x11capture_linux.go b/pkg/driver/screen/x11capture_linux.go
index 056d559..fb1ba44 100644
--- a/pkg/driver/screen/x11capture_linux.go
+++ b/pkg/driver/screen/x11capture_linux.go
@@ -1,11 +1,36 @@
 package screen
 
 // #cgo pkg-config: x11 xext
+// #include <stdint.h>
 // #include <sys/shm.h>
 // #include <X11/Xlib.h>
 // #define XUTIL_DEFINE_FUNCTIONS
 // #include <X11/Xutil.h>
 // #include <X11/extensions/XShm.h>
+// void copyRGBA(void *dst, char *src, size_t l) { // 64bit aligned copy
+//   uint64_t *d = (uint64_t*)dst;
+//   uint64_t *s = (uint64_t*)src;
+//   l /= 8;
+//   for (size_t i = 0; i < l; i ++) {
+//     uint64_t v = *s;
+//     // Reorder BGR to RGB
+//     *d = 0xFF000000FF000000 |
+//          ((v >> 16) & 0xFF00000000) | (v & 0xFF0000000000) | ((v & 0xFF00000000) << 16) |
+//          ((v >> 16) & 0xFF) | (v & 0xFF00) | ((v & 0xFF) << 16);
+//     d++;
+//     s++;
+//   }
+// }
+// char *align64(char *ptr) { // return 64bit aligned pointer
+//   if (((size_t)ptr & 0x07) == 0) {
+//     return ptr;
+//   }
+//   // Clear lower 3bits to align the address to 8bytes.
+//   return (char*)(((size_t)ptr & (~(size_t)0x07)) + 0x08);
+// }
+// size_t align64ForTest(size_t ptr) {
+//   return (size_t)align64((char*)ptr);
+// }
 import "C"
 
 import (
@@ -61,10 +86,7 @@ func (s *shmImage) ColorModel() color.Model {
 }
 
 func (s *shmImage) Bounds() image.Rectangle {
-	return image.Rectangle{
-		Min: image.Point{X: 0, Y: 0},
-		Max: image.Point{X: int(s.img.width), Y: int(s.img.height)},
-	}
+	return image.Rect(0, 0, int(s.img.width), int(s.img.height))
 }
 
 type colorFunc func() (r, g, b, a uint32)
@@ -78,12 +100,33 @@ func (s *shmImage) At(x, y int) color.Color {
 	b := uint32(s.b[addr]) * 0x100
 	g := uint32(s.b[addr+1]) * 0x100
 	r := uint32(s.b[addr+2]) * 0x100
-	a := uint32(s.b[addr+3]) * 0x100
 	return colorFunc(func() (_, _, _, _ uint32) {
-		return r, g, b, a
+		return r, g, b, 0xFFFF
 	})
 }
 
+func (s *shmImage) RGBAAt(x, y int) color.RGBA {
+	addr := (x + y*int(s.img.width)) * 4
+	b := s.b[addr]
+	g := s.b[addr+1]
+	r := s.b[addr+2]
+	return color.RGBA{R: r, G: g, B: b, A: 0xFF}
+}
+
+func (s *shmImage) ToRGBA(dst *image.RGBA) *image.RGBA {
+	dst.Rect = s.Bounds()
+	dst.Stride = int(s.img.width) * 4
+	l := int(4 * s.img.width * s.img.height)
+	if len(dst.Pix) < l {
+		if cap(dst.Pix) < l {
+			dst.Pix = make([]uint8, l)
+		}
+		dst.Pix = dst.Pix[:l]
+	}
+	C.copyRGBA(unsafe.Pointer(&dst.Pix[0]), s.img.data, C.ulong(len(dst.Pix)))
+	return dst
+}
+
 func newShmImage(dp *C.Display, screen int) (*shmImage, error) {
 	cScreen := C.int(screen)
 	w := int(C.XDisplayWidth(dp, cScreen))
@@ -93,7 +136,7 @@ func newShmImage(dp *C.Display, screen int) (*shmImage, error) {
 
 	s := &shmImage{dp: dp}
 
-	s.shm.shmid = C.shmget(C.IPC_PRIVATE, C.ulong(w*h*4), C.IPC_CREAT|0600)
+	s.shm.shmid = C.shmget(C.IPC_PRIVATE, C.ulong(w*h*4+8), C.IPC_CREAT|0600)
 	if s.shm.shmid == -1 {
 		return nil, errors.New("failed to get shared memory")
 	}
@@ -106,7 +149,7 @@ func newShmImage(dp *C.Display, screen int) (*shmImage, error) {
 	C.shmctl(s.shm.shmid, C.IPC_RMID, nil)
 
 	s.img = C.XShmCreateImage(
-		dp, v, C.uint(depth), C.ZPixmap, s.shm.shmaddr, &s.shm, C.uint(w), C.uint(h))
+		dp, v, C.uint(depth), C.ZPixmap, C.align64(s.shm.shmaddr), &s.shm, C.uint(w), C.uint(h))
 	if s.img == nil {
 		s.Free()
 		return nil, errors.New("failed to create XShm image")
@@ -160,3 +203,8 @@ func (r *reader) Close() {
 	r.img.Free()
 	C.XCloseDisplay(r.dp)
 }
+
+// cAlign64 is fot testing
+func cAlign64(ptr uintptr) uintptr {
+	return uintptr(C.align64ForTest(C.ulong(uintptr(ptr))))
+}
diff --git a/pkg/io/video/convert.go b/pkg/io/video/convert.go
index 71c3df8..427629e 100644
--- a/pkg/io/video/convert.go
+++ b/pkg/io/video/convert.go
@@ -28,27 +28,44 @@ func imageToYCbCr(dst *image.YCbCr, src image.Image) {
 		i0 := 1 * flat
 		i1 := 2 * flat
 		i2 := 3 * flat
-		b := make([]uint8, i2)
-		dst.Y = b[:i0:i0]
-		dst.Cb = b[i0:i1:i1]
-		dst.Cr = b[i1:i2:i2]
+		if cap(dst.Y) < i2 {
+			dst.Y = make([]uint8, i2)
+		}
+		dst.Y = dst.Y[:i0]
+		dst.Cb = dst.Y[i0:i1]
+		dst.Cr = dst.Y[i1:i2]
 	}
 	dst.SubsampleRatio = image.YCbCrSubsampleRatio444
 	dst.YStride = dx
 	dst.CStride = dx
 	dst.Rect = bounds
 
-	i := 0
-	for yi := 0; yi < dy; yi++ {
-		for xi := 0; xi < dx; xi++ {
-			// TODO: probably try to get the alpha value with something like
-			// https://en.wikipedia.org/wiki/Alpha_compositing
-			r, g, b, _ := src.At(xi, yi).RGBA()
-			yy, cb, cr := color.RGBToYCbCr(uint8(r/256), uint8(g/256), uint8(b/256))
-			dst.Y[i] = yy
-			dst.Cb[i] = cb
-			dst.Cr[i] = cr
-			i++
+	switch s := src.(type) {
+	case *image.RGBA:
+		i := 0
+		addr := 0
+		for yi := 0; yi < dy; yi++ {
+			for xi := 0; xi < dx; xi++ {
+				dst.Y[i], dst.Cb[i], dst.Cr[i] = color.RGBToYCbCr(
+					s.Pix[addr+0], s.Pix[addr+1], s.Pix[addr+2],
+				)
+				addr += 4
+				i++
+			}
+		}
+	default:
+		i := 0
+		for yi := 0; yi < dy; yi++ {
+			for xi := 0; xi < dx; xi++ {
+				// TODO: probably try to get the alpha value with something like
+				// https://en.wikipedia.org/wiki/Alpha_compositing
+				r, g, b, _ := src.At(xi, yi).RGBA()
+				yy, cb, cr := color.RGBToYCbCr(uint8(r/256), uint8(g/256), uint8(b/256))
+				dst.Y[i] = yy
+				dst.Cb[i] = cb
+				dst.Cr[i] = cr
+				i++
+			}
 		}
 	}
 }
@@ -68,37 +85,45 @@ func ToI420(r Reader) Reader {
 		// Covert pixel format to I420
 		switch yuvImg.SubsampleRatio {
 		case image.YCbCrSubsampleRatio444:
+			addrSrc0 := 0
+			addrSrc1 := yuvImg.CStride
+			addrDst := 0
 			for i := 0; i < h/2; i++ {
-				addrSrc := i * 2 * yuvImg.CStride
-				addrDst := i * yuvImg.CStride / 2
 				for j := 0; j < yuvImg.CStride/2; j++ {
-					j2 := j * 2
-					cb := uint16(yuvImg.Cb[addrSrc+j2]) + uint16(yuvImg.Cb[addrSrc+yuvImg.CStride+j2]) +
-						uint16(yuvImg.Cb[addrSrc+j2+1]) + uint16(yuvImg.Cb[addrSrc+yuvImg.CStride+j2+1])
-					cr := uint16(yuvImg.Cr[addrSrc+j2]) + uint16(yuvImg.Cr[addrSrc+yuvImg.CStride+j2]) +
-						uint16(yuvImg.Cr[addrSrc+j2+1]) + uint16(yuvImg.Cr[addrSrc+yuvImg.CStride+j2+1])
-					yuvImg.Cb[addrDst+j] = uint8(cb / 4)
-					yuvImg.Cr[addrDst+j] = uint8(cr / 4)
+					cb := uint16(yuvImg.Cb[addrSrc0]) + uint16(yuvImg.Cb[addrSrc1]) +
+						uint16(yuvImg.Cb[addrSrc0+1]) + uint16(yuvImg.Cb[addrSrc1+1])
+					cr := uint16(yuvImg.Cr[addrSrc0]) + uint16(yuvImg.Cr[addrSrc1]) +
+						uint16(yuvImg.Cr[addrSrc0+1]) + uint16(yuvImg.Cr[addrSrc1+1])
+					yuvImg.Cb[addrDst] = uint8(cb / 4)
+					yuvImg.Cr[addrDst] = uint8(cr / 4)
+					addrSrc0 += 2
+					addrSrc1 += 2
+					addrDst++
 				}
+				addrSrc0 += yuvImg.CStride
+				addrSrc1 += yuvImg.CStride
 			}
 			yuvImg.CStride = yuvImg.CStride / 2
 			cLen := yuvImg.CStride * (h / 2)
-			yuvImg.Cb = yuvImg.Cb[:cLen:cLen]
-			yuvImg.Cr = yuvImg.Cr[:cLen:cLen]
+			yuvImg.Cb = yuvImg.Cb[:cLen]
+			yuvImg.Cr = yuvImg.Cr[:cLen]
 		case image.YCbCrSubsampleRatio422:
+			addrSrc := 0
+			addrDst := 0
 			for i := 0; i < h/2; i++ {
-				addrSrc := i * 2 * yuvImg.CStride
-				addrDst := i * yuvImg.CStride
 				for j := 0; j < yuvImg.CStride; j++ {
-					cb := uint16(yuvImg.Cb[addrSrc+j]) + uint16(yuvImg.Cb[addrSrc+yuvImg.CStride+j])
-					cr := uint16(yuvImg.Cr[addrSrc+j]) + uint16(yuvImg.Cr[addrSrc+yuvImg.CStride+j])
-					yuvImg.Cb[addrDst+j] = uint8(cb / 2)
-					yuvImg.Cr[addrDst+j] = uint8(cr / 2)
+					cb := uint16(yuvImg.Cb[addrSrc]) + uint16(yuvImg.Cb[addrSrc+yuvImg.CStride])
+					cr := uint16(yuvImg.Cr[addrSrc]) + uint16(yuvImg.Cr[addrSrc+yuvImg.CStride])
+					yuvImg.Cb[addrDst] = uint8(cb / 2)
+					yuvImg.Cr[addrDst] = uint8(cr / 2)
+					addrDst++
+					addrSrc++
 				}
+				addrSrc += yuvImg.CStride
 			}
 			cLen := yuvImg.CStride * (h / 2)
-			yuvImg.Cb = yuvImg.Cb[:cLen:cLen]
-			yuvImg.Cr = yuvImg.Cr[:cLen:cLen]
+			yuvImg.Cb = yuvImg.Cb[:cLen]
+			yuvImg.Cr = yuvImg.Cr[:cLen]
 		case image.YCbCrSubsampleRatio420:
 		default:
 			return nil, fmt.Errorf("unsupported pixel format: %s", yuvImg.SubsampleRatio)
@@ -108,3 +133,52 @@ func ToI420(r Reader) Reader {
 		return &yuvImg, nil
 	})
 }
+
+// imageToRGBA converts src to *image.RGBA and store it to dst
+func imageToRGBA(dst *image.RGBA, src image.Image) {
+	if dst == nil {
+		panic("dst can't be nil")
+	}
+
+	srcRGBA, ok := src.(*image.RGBA)
+	if ok {
+		*dst = *srcRGBA
+		return
+	}
+
+	bounds := src.Bounds()
+	dy := bounds.Dy()
+	dx := bounds.Dx()
+
+	if len(dst.Pix) < 4*dx*dy {
+		dst.Pix = make([]uint8, 4*dx*dy)
+	}
+	dst.Stride = 4 * dx
+	dst.Rect = bounds
+
+	i := 0
+	for yi := 0; yi < dy; yi++ {
+		for xi := 0; xi < dx; xi++ {
+			r, g, b, a := src.At(xi, yi).RGBA()
+			dst.Pix[i+0] = uint8(r / 0x100)
+			dst.Pix[i+1] = uint8(g / 0x100)
+			dst.Pix[i+2] = uint8(b / 0x100)
+			dst.Pix[i+3] = uint8(a / 0x100)
+			i += 4
+		}
+	}
+}
+
+// ToRGBA converts r to a new reader that will output images in RGBA format
+func ToRGBA(r Reader) Reader {
+	var dst image.RGBA
+	return ReaderFunc(func() (image.Image, error) {
+		img, err := r.Read()
+		if err != nil {
+			return nil, err
+		}
+
+		imageToRGBA(&dst, img)
+		return &dst, nil
+	})
+}
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/driver/screen/x11capture_linux_test.go b/pkg/driver/screen/x11capture_linux_test.go
new file mode 100644
index 0000000..9d764f3
--- /dev/null
+++ b/pkg/driver/screen/x11capture_linux_test.go
@@ -0,0 +1,17 @@
+package screen
+
+import (
+	"testing"
+)
+
+func TestAlign64(t *testing.T) {
+	if ret := cAlign64(0x00010008); ret != 0x00010008 {
+		t.Errorf("Wrong alignment, expected %x, got %x", 0x00010008, ret)
+	}
+	if ret := cAlign64(0x00010006); ret != 0x00010008 {
+		t.Errorf("Wrong alignment, expected %x, got %x", 0x00010008, ret)
+	}
+	if ret := cAlign64(0x00010009); ret != 0x00010010 {
+		t.Errorf("Wrong alignment, expected %x, got %x", 0x00010010, ret)
+	}
+}
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)
+								}
+							}
+						})
+					}
+				})
+			}
+		})
+	}
+}