diff --git a/README.md b/README.md index b6c9941..827e64e 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ out := canvas.MustRender(doc) png.Encode(w, out.ToImage(8)) ``` -A layer's content can be smaller than the document. Set `Layer.Origin` to where its top-left sits. A layer can also build its content when it is composited by setting `Layer.Load`, so a document of many large layers does not hold them all at once. +A layer's content can be smaller than the document. Set `Layer.Origin` to where its top-left sits. A layer can also build its content when it is composited by setting `Layer.Load`, so a document of many large layers does not hold them all at once. A layer can take an 8-bit `image.Image` through `Layer.Image` or `Layer.LoadImage` instead, which is blended straight into the document without converting it to a float buffer first. ## Documentation diff --git a/blend/example_test.go b/blend/example_test.go index c1d5b4c..7bfccc2 100644 --- a/blend/example_test.go +++ b/blend/example_test.go @@ -2,6 +2,8 @@ package blend_test import ( "fmt" + "image" + "image/color" "github.com/odevine/impasto/blend" "github.com/odevine/impasto/raster" @@ -60,3 +62,52 @@ func ExampleMode() { // Normal SoftLight Luminosity // false Mode(invalid) } + +// CompositeNRGBA blends a decoded 8-bit image straight into a buffer, with no +// float copy of the image in between. A transparent pixel leaves the backdrop +// alone, and an opaque one covers it +func ExampleCompositeNRGBA() { + dst := raster.MustNewBuffer(2, 1) + dst.Set(0, 0, 0.5, 0.5, 0.5, 1) + dst.Set(1, 0, 0.5, 0.5, 0.5, 1) + + src := image.NewNRGBA(image.Rect(0, 0, 2, 1)) + src.SetNRGBA(1, 0, color.NRGBA{R: 255, G: 255, B: 255, A: 255}) // pixel 0 stays transparent + + blend.CompositeNRGBA(dst, src, image.Point{}, blend.Normal, 1) + for x := 0; x < 2; x++ { + r, _, _, _ := dst.At(x, 0) + fmt.Printf("pixel %d: %.2f\n", x, r) + } + // Output: + // pixel 0: 0.50 + // pixel 1: 1.00 +} + +// Index records where an image's visible pixels are, so a frame that is mostly +// transparent is blended in proportion to what it draws. Build the index once +// where the decoded image is kept and reuse it. The result is identical to +// CompositeNRGBA +func ExampleIndex() { + frame := image.NewNRGBA(image.Rect(0, 0, 200, 100)) + for y := 0; y < 100; y++ { + for x := 0; x < 200; x++ { + if x < 10 || x >= 190 || y < 10 || y >= 90 { + frame.SetNRGBA(x, y, color.NRGBA{R: 255, A: 255}) + } + } + } + indexed := blend.Index(frame) + + plain := raster.MustNewBuffer(200, 100) + fast := raster.MustNewBuffer(200, 100) + blend.CompositeNRGBA(plain, frame, image.Point{}, blend.Normal, 1) + blend.CompositeIndexed(fast, indexed, image.Point{}, blend.Normal, 1) + + same := true + for i := range plain.Pix { + same = same && plain.Pix[i] == fast.Pix[i] + } + fmt.Println("identical:", same) + // Output: identical: true +} diff --git a/blend/image.go b/blend/image.go new file mode 100644 index 0000000..f5ceb61 --- /dev/null +++ b/blend/image.go @@ -0,0 +1,104 @@ +package blend + +import ( + "image" + + "github.com/odevine/impasto/internal/parallel" + "github.com/odevine/impasto/raster" +) + +// srgb8ToLinear maps an 8-bit sRGB code to linear light. It holds the values +// raster's own ingest table holds, since both come from raster.SRGBToLinear +var srgb8ToLinear [256]float32 + +func init() { + for i := range srgb8ToLinear { + srgb8ToLinear[i] = raster.SRGBToLinear(float32(i) / 255.0) + } +} + +// CompositeNRGBA blends an 8-bit straight-alpha sRGB image over the part of dst +// it covers when its top-left sits at origin, clipped to dst, with mode m at +// the given opacity in [0,1]. It touches nothing outside that rectangle. +// +// The result is exactly what converting src with raster.FromImage and passing +// the buffer to CompositeRect gives, without building that buffer. The image is +// never written to, so one decoded image can serve any number of concurrent +// calls. A pixel with no alpha leaves dst alone, and in Normal mode a fully +// opaque pixel at full opacity replaces it, so neither costs any arithmetic. +// Work is split into fixed row bands, as in Composite +func CompositeNRGBA(dst *raster.Buffer, src *image.NRGBA, origin image.Point, m Mode, opacity float32) { + if opacity <= 0 { + return + } + if opacity > 1 { + opacity = 1 + } + sb := src.Bounds() + r := image.Rect(origin.X, origin.Y, origin.X+sb.Dx(), origin.Y+sb.Dy()). + Intersect(image.Rect(0, 0, dst.Width, dst.Height)) + if r.Empty() { + return + } + parallel.Rows(r.Dy(), func(lo, hi int) { + for y := r.Min.Y + lo; y < r.Min.Y+hi; y++ { + di := (y*dst.Width + r.Min.X) * 4 + si := src.PixOffset(sb.Min.X+r.Min.X-origin.X, sb.Min.Y+y-origin.Y) + if m == Normal { + normalRow(dst.Pix[di:di+r.Dx()*4], src.Pix[si:si+r.Dx()*4], opacity) + } else { + modeRow(dst.Pix[di:di+r.Dx()*4], src.Pix[si:si+r.Dx()*4], m, opacity) + } + } + }) +} + +// normalRow composites one row of straight-alpha 8-bit pixels over dst with +// source-over. The arithmetic is compositeNormal's, applied to the premultiplied +// linear value ingest would have produced +func normalRow(dst []float32, src []uint8, opacity float32) { + for i := 0; i+3 < len(src); i += 4 { + a8 := src[i+3] + if a8 == 0 { + continue + } + if a8 == 255 && opacity == 1 { + dst[i] = srgb8ToLinear[src[i]] + dst[i+1] = srgb8ToLinear[src[i+1]] + dst[i+2] = srgb8ToLinear[src[i+2]] + dst[i+3] = 1 + continue + } + a := float32(a8) / 255.0 + sr := srgb8ToLinear[src[i]] * a * opacity + sg := srgb8ToLinear[src[i+1]] * a * opacity + sb := srgb8ToLinear[src[i+2]] * a * opacity + sa := a * opacity + inv := 1 - sa + dst[i] = sr + dst[i]*inv + dst[i+1] = sg + dst[i+1]*inv + dst[i+2] = sb + dst[i+2]*inv + dst[i+3] = sa + dst[i+3]*inv + } +} + +// modeRow composites one row of straight-alpha 8-bit pixels over dst with any +// mode, through the same BlendPixel the buffer path uses. A pixel with no alpha +// returns the backdrop unchanged, so it is skipped +func modeRow(dst []float32, src []uint8, m Mode, opacity float32) { + for i := 0; i+3 < len(src); i += 4 { + a8 := src[i+3] + if a8 == 0 { + continue + } + a := float32(a8) / 255.0 + cs := [4]float32{ + srgb8ToLinear[src[i]] * a * opacity, + srgb8ToLinear[src[i+1]] * a * opacity, + srgb8ToLinear[src[i+2]] * a * opacity, + a * opacity, + } + out := BlendPixel([4]float32{dst[i], dst[i+1], dst[i+2], dst[i+3]}, cs, m) + dst[i], dst[i+1], dst[i+2], dst[i+3] = out[0], out[1], out[2], out[3] + } +} diff --git a/blend/image_test.go b/blend/image_test.go new file mode 100644 index 0000000..5c0609d --- /dev/null +++ b/blend/image_test.go @@ -0,0 +1,124 @@ +package blend + +import ( + "image" + "testing" + + "github.com/odevine/impasto/internal/testimg" + "github.com/odevine/impasto/raster" +) + +// backdrop is a buffer with a color and alpha that vary, so a blend has +// something to differ from +func backdrop(w, h int) *raster.Buffer { + buf := raster.MustNewBuffer(w, h) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + a := 0.25 + 0.75*float32((x+y)%5)/4 + buf.Set(x, y, a*float32(x%7)/6, a*float32(y%5)/4, a*float32((x+y)%3)/2, a) + } + } + return buf +} + +func sameBits(t *testing.T, got, want *raster.Buffer, what string) { + t.Helper() + testimg.EqualBits(t, got.Pix, want.Pix, what) +} + +func TestCompositeNRGBAMatchesBuffer(t *testing.T) { + const w, h = 48, 36 + src := testimg.Patterned(31, 23) + buf, err := raster.FromImage(src) + if err != nil { + t.Fatal(err) + } + origins := []image.Point{{0, 0}, {10, 7}, {-5, -3}, {30, 20}, {-40, 0}, {100, 100}} + for m := Normal; m < numModes; m++ { + for _, opacity := range []float32{1, 0.6, 0.01} { + for _, at := range origins { + want, got := backdrop(w, h), backdrop(w, h) + CompositeRect(want, buf, at, m, opacity) + CompositeNRGBA(got, src, at, m, opacity) + sameBits(t, got, want, m.String()) + } + } + } +} + +func TestCompositeNRGBAReadsASubImageAtItsOwnBounds(t *testing.T) { + full := testimg.Patterned(40, 30) + sub := full.SubImage(image.Rect(7, 5, 29, 24)).(*image.NRGBA) + buf, err := raster.FromImage(sub) + if err != nil { + t.Fatal(err) + } + for _, m := range []Mode{Normal, Multiply, Hue} { + want, got := backdrop(48, 36), backdrop(48, 36) + CompositeRect(want, buf, image.Pt(6, 4), m, 0.8) + CompositeNRGBA(got, sub, image.Pt(6, 4), m, 0.8) + sameBits(t, got, want, m.String()) + } +} + +func TestCompositeNRGBATouchesOnlyItsRect(t *testing.T) { + src := testimg.Patterned(10, 8) + want, got := backdrop(30, 30), backdrop(30, 30) + CompositeNRGBA(got, src, image.Pt(12, 9), Normal, 1) + for y := 0; y < 30; y++ { + for x := 0; x < 30; x++ { + if image.Pt(x, y).In(image.Rect(12, 9, 22, 17)) { + continue + } + r1, g1, b1, a1 := got.At(x, y) + r2, g2, b2, a2 := want.At(x, y) + if r1 != r2 || g1 != g2 || b1 != b2 || a1 != a2 { + t.Fatalf("pixel (%d,%d) outside the image changed", x, y) + } + } + } +} + +func TestCompositeNRGBAIgnoresNonPositiveOpacityAndClampsAboveOne(t *testing.T) { + src := testimg.Patterned(10, 8) + before := backdrop(20, 20) + for _, o := range []float32{0, -1} { + got := backdrop(20, 20) + CompositeNRGBA(got, src, image.Point{}, Normal, o) + sameBits(t, got, before, "opacity") + } + want, got := backdrop(20, 20), backdrop(20, 20) + CompositeNRGBA(want, src, image.Point{}, Normal, 1) + CompositeNRGBA(got, src, image.Point{}, Normal, 3) + sameBits(t, got, want, "opacity above one") +} + +func TestCompositeNRGBANeverWritesToItsSource(t *testing.T) { + src := testimg.Patterned(16, 16) + pix := append([]uint8(nil), src.Pix...) + CompositeNRGBA(backdrop(20, 20), src, image.Point{}, Overlay, 0.5) + for i := range pix { + if src.Pix[i] != pix[i] { + t.Fatal("the source image was written to") + } + } +} + +func BenchmarkCompositeNRGBA(b *testing.B) { + src := testimg.Patterned(2048, 2048) + dst := raster.MustNewBuffer(2048, 2048) + b.SetBytes(2048 * 2048 * 4) + for i := 0; i < b.N; i++ { + CompositeNRGBA(dst, src, image.Point{}, Normal, 1) + } +} + +func BenchmarkCompositeRectFromImage(b *testing.B) { + src := testimg.Patterned(2048, 2048) + dst := raster.MustNewBuffer(2048, 2048) + b.SetBytes(2048 * 2048 * 4) + for i := 0; i < b.N; i++ { + buf, _ := raster.FromImage(src) + CompositeRect(dst, buf, image.Point{}, Normal, 1) + } +} diff --git a/blend/indexed.go b/blend/indexed.go new file mode 100644 index 0000000..6545561 --- /dev/null +++ b/blend/indexed.go @@ -0,0 +1,103 @@ +package blend + +import ( + "image" + + "github.com/odevine/impasto/internal/parallel" + "github.com/odevine/impasto/raster" +) + +// minGap is how many transparent pixels in a row end one run of visible pixels +// and start the next. A shorter gap costs less to blend through than to skip, +// and merging across it keeps the run list short for an image with fine detail +const minGap = 32 + +// Indexed is an *image.NRGBA with a record of where its visible pixels are, so +// compositing it skips whole stretches of transparent pixels without reading +// them. It embeds the image and is one, so it can be given anywhere an +// *image.NRGBA is, and a program that decodes an image once and composites it +// many times builds the index once and keeps it with the image. +// +// The record belongs to the image as it was when Index read it. Writing to the +// pixels afterward leaves the record wrong +type Indexed struct { + *image.NRGBA + // rows[y] is where row y's runs start in runs, and rows[y+1] where they end. + // A run is a half-open range of columns, counted from the image's own left edge + rows []int32 + runs []int32 +} + +// Index reads img once and returns it with the runs of columns in each row that +// have any alpha. Runs that sit within a short distance of one another are +// merged. The cost is one pass over the alpha bytes +func Index(img *image.NRGBA) *Indexed { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + ix := &Indexed{NRGBA: img, rows: make([]int32, h+1), runs: make([]int32, 0, 2*h)} + for y := 0; y < h; y++ { + ix.rows[y] = int32(len(ix.runs) / 2) + row := img.Pix[img.PixOffset(b.Min.X, b.Min.Y+y):] + row = row[:w*4] + start, last := -1, -1 + for x := 0; x < w; x++ { + if row[x*4+3] == 0 { + continue + } + if start >= 0 && x-last-1 >= minGap { + ix.runs = append(ix.runs, int32(start), int32(last+1)) + start = -1 + } + if start < 0 { + start = x + } + last = x + } + if start >= 0 { + ix.runs = append(ix.runs, int32(start), int32(last+1)) + } + } + ix.rows[h] = int32(len(ix.runs) / 2) + return ix +} + +// CompositeIndexed is CompositeNRGBA for an image whose visible pixels have been +// indexed. It blends only the runs the index lists, which gives the same result +// as blending every pixel, since a pixel with no alpha leaves the backdrop alone +// in every mode, and costs time in proportion to the visible area +func CompositeIndexed(dst *raster.Buffer, src *Indexed, origin image.Point, m Mode, opacity float32) { + if opacity <= 0 { + return + } + if opacity > 1 { + opacity = 1 + } + sb := src.Bounds() + r := image.Rect(origin.X, origin.Y, origin.X+sb.Dx(), origin.Y+sb.Dy()). + Intersect(image.Rect(0, 0, dst.Width, dst.Height)) + if r.Empty() { + return + } + // The part of each run that lands inside dst, counted from the image's left edge + from, to := int32(r.Min.X-origin.X), int32(r.Max.X-origin.X) + parallel.Rows(r.Dy(), func(lo, hi int) { + for y := r.Min.Y + lo; y < r.Min.Y+hi; y++ { + sy := y - origin.Y + rowStart := src.PixOffset(sb.Min.X, sb.Min.Y+sy) + for k := src.rows[sy]; k < src.rows[sy+1]; k++ { + x0, x1 := max(src.runs[2*k], from), min(src.runs[2*k+1], to) + if x0 >= x1 { + continue + } + di := (y*dst.Width + origin.X + int(x0)) * 4 + si := rowStart + int(x0)*4 + n := int(x1-x0) * 4 + if m == Normal { + normalRow(dst.Pix[di:di+n], src.Pix[si:si+n], opacity) + } else { + modeRow(dst.Pix[di:di+n], src.Pix[si:si+n], m, opacity) + } + } + } + }) +} diff --git a/blend/indexed_test.go b/blend/indexed_test.go new file mode 100644 index 0000000..6389ca1 --- /dev/null +++ b/blend/indexed_test.go @@ -0,0 +1,155 @@ +package blend + +import ( + "image" + "image/color" + "slices" + "testing" + + "github.com/odevine/impasto/internal/testimg" + "github.com/odevine/impasto/raster" +) + +func TestIndexListsTheVisibleRuns(t *testing.T) { + img := testimg.Sparse(120, 12, minGap) + ix := Index(img) + row := func(y int) []int32 { return ix.runs[2*ix.rows[y] : 2*ix.rows[y+1]] } + for y, want := range map[int][]int32{ + 0: nil, + 1: {0, 120}, + 2: {3, 10 + int32(minGap) + 9}, // a gap just under the limit is merged + 3: {3, 10, 10 + int32(minGap), 10 + int32(minGap) + 9}, // a gap at the limit is not + 4: {0, 5, 115, 120}, + 5: {40, 41, 119, 120}, + 6: nil, + 10: {0, 5, 115, 120}, + } { + if got := row(y); !slices.Equal(got, want) { + t.Errorf("row %d runs = %v, want %v", y, got, want) + } + } +} + +func TestIndexReadsASubImageAtItsOwnBounds(t *testing.T) { + full := testimg.Sparse(120, 12, minGap) + sub := full.SubImage(image.Rect(10, 1, 110, 5)).(*image.NRGBA) + ix := Index(sub) + if len(ix.rows) != 5 { + t.Fatalf("rows = %d entries, want 5 for 4 rows", len(ix.rows)) + } + // Row 1 of the parent is full, so its run is the sub-image's whole width + if got := ix.runs[2*ix.rows[0] : 2*ix.rows[1]]; !slices.Equal(got, []int32{0, 100}) { + t.Errorf("first row runs = %v, want [0 100]", got) + } +} + +func TestCompositeIndexedMatchesTheBufferPath(t *testing.T) { + const w, h = 150, 40 + src := testimg.Sparse(131, 29, minGap) + ix := Index(src) + buf, err := raster.FromImage(src) + if err != nil { + t.Fatal(err) + } + origins := []image.Point{{0, 0}, {7, 5}, {-20, -3}, {100, 20}, {-200, 0}, {300, 300}} + for m := Normal; m < numModes; m++ { + for _, opacity := range []float32{1, 0.6} { + for _, at := range origins { + want, got, plain := backdrop(w, h), backdrop(w, h), backdrop(w, h) + CompositeRect(want, buf, at, m, opacity) + CompositeIndexed(got, ix, at, m, opacity) + CompositeNRGBA(plain, src, at, m, opacity) + sameBits(t, got, want, m.String()) + sameBits(t, plain, want, m.String()) + } + } + } +} + +func TestCompositeIndexedReadsASubImageWhereItSits(t *testing.T) { + full := testimg.Sparse(120, 24, minGap) + sub := full.SubImage(image.Rect(7, 3, 100, 20)).(*image.NRGBA) + buf, _ := raster.FromImage(sub) + ix := Index(sub) + for _, m := range []Mode{Normal, Multiply} { + want, got := backdrop(130, 30), backdrop(130, 30) + CompositeRect(want, buf, image.Pt(11, 4), m, 0.8) + CompositeIndexed(got, ix, image.Pt(11, 4), m, 0.8) + sameBits(t, got, want, m.String()) + } +} + +func TestCompositeIndexedSkipsAnImageWithNothingVisible(t *testing.T) { + ix := Index(image.NewNRGBA(image.Rect(0, 0, 50, 50))) + if len(ix.runs) != 0 { + t.Fatalf("a transparent image has %d runs", len(ix.runs)/2) + } + want, got := backdrop(60, 60), backdrop(60, 60) + CompositeIndexed(got, ix, image.Pt(3, 3), Normal, 1) + sameBits(t, got, want, "transparent image") +} + +func TestCompositeIndexedIgnoresNonPositiveOpacityAndClampsAboveOne(t *testing.T) { + src := testimg.Sparse(60, 30, minGap) + ix := Index(src) + want, got := backdrop(70, 40), backdrop(70, 40) + for _, o := range []float32{0, -1} { + CompositeIndexed(got, ix, image.Point{}, Normal, o) + sameBits(t, got, want, "non-positive opacity") + } + CompositeNRGBA(want, src, image.Point{}, Normal, 1) + CompositeIndexed(got, ix, image.Point{}, Normal, 4) + sameBits(t, got, want, "opacity above one") +} + +// An index of a fully opaque image holds one run per row, and of a transparent one +// none +func TestIndexOfASolidImageIsOneRunPerRow(t *testing.T) { + ix := Index(testimg.Solid(50, 20, 255)) + if len(ix.runs) != 2*20 { + t.Errorf("runs = %d, want %d", len(ix.runs)/2, 20) + } +} + +// Both blend packages keep their own table of 8-bit sRGB codes, so every code +// must come out as raster.FromImage reads it +func TestSRGBTableMatchesIngest(t *testing.T) { + img := image.NewNRGBA(image.Rect(0, 0, 256, 1)) + for c := 0; c < 256; c++ { + img.SetNRGBA(c, 0, color.NRGBA{R: uint8(c), G: uint8(255 - c), B: uint8(c), A: 255}) + } + buf, err := raster.FromImage(img) + if err != nil { + t.Fatal(err) + } + for c := 0; c < 256; c++ { + r, g, b, a := buf.At(c, 0) + if r != srgb8ToLinear[c] || g != srgb8ToLinear[255-c] || b != srgb8ToLinear[c] || a != 1 { + t.Fatalf("code %d reads as %v %v %v %v, table has %v %v", c, r, g, b, a, srgb8ToLinear[c], srgb8ToLinear[255-c]) + } + } +} + +func BenchmarkCompositeHollowFrame(b *testing.B) { + frame := testimg.Hollow(2048, 2048, 60) + ix := Index(frame) + dst := raster.MustNewBuffer(2048, 2048) + b.Run("nrgba", func(b *testing.B) { + for b.Loop() { + CompositeNRGBA(dst, frame, image.Point{}, Normal, 1) + } + }) + b.Run("indexed", func(b *testing.B) { + for b.Loop() { + CompositeIndexed(dst, ix, image.Point{}, Normal, 1) + } + }) +} + +func BenchmarkIndex(b *testing.B) { + frame := testimg.Hollow(3264, 4440, 100) + b.SetBytes(int64(len(frame.Pix))) + for b.Loop() { + Index(frame) + } +} diff --git a/canvas/canvas.go b/canvas/canvas.go index 6a0d285..d59c7fe 100644 --- a/canvas/canvas.go +++ b/canvas/canvas.go @@ -44,11 +44,23 @@ type Node interface { // nothing else uses, and masks and clipping write to it in place. Origin is not // used for a layer with a Load. A Load that returns a nil buffer contributes // nothing, and one that returns an error stops the render. Content, when set, -// is used instead and Load is not called +// is used instead and Load is not called. +// +// Image and LoadImage supply the content as an 8-bit sRGB image when Content and +// Load are both nil, with Image placed at Origin and LoadImage returning its own +// origin as Load does. An *image.NRGBA with no mask, effects or clip-to-below is +// blended straight into the document without a float buffer, which is cheaper in +// time and memory and gives the same pixels. A [blend.Indexed] image is blended +// the same way and skips the transparent stretches its index records. Any other +// image, or a layer that needs its content written to, is converted first. Canvas +// never writes to an image, so one decoded image can be shared by any number of +// layers and concurrent renders. A nil or empty image contributes nothing type Layer struct { Content *raster.Buffer Origin image.Point Load func() (*raster.Buffer, image.Point, error) + Image image.Image + LoadImage func() (image.Image, image.Point, error) Opacity float32 Mode blend.Mode Mask mask.Mask @@ -178,6 +190,37 @@ func renderLayer(l *Layer, backdrop *raster.Buffer, base *coverage, needBase boo } own = true } + if content == nil && l.Load == nil { + img := l.Image + if img == nil && l.LoadImage != nil { + var err error + if img, origin, err = l.LoadImage(); err != nil { + return nil, fmt.Errorf("load: %w", err) + } + } + if img != nil && !img.Bounds().Empty() { + // A layer that is only blended needs nothing from its image beyond + // reading it, so it skips the buffer that a mask, a clip or an + // effect would need to write to + blendOnly := l.Mask == nil && len(l.Effects) == 0 && !needBase && !(l.ClipToBelow && base != nil) + if ix, ok := img.(*blend.Indexed); ok { + if blendOnly { + blend.CompositeIndexed(backdrop, ix, origin, l.Mode, opacityOr(l.Opacity)) + return nil, nil + } + img = ix.NRGBA + } + if n, ok := img.(*image.NRGBA); ok && blendOnly { + blend.CompositeNRGBA(backdrop, n, origin, l.Mode, opacityOr(l.Opacity)) + return nil, nil + } + var err error + if content, err = raster.FromImage(img); err != nil { + return nil, fmt.Errorf("load: %w", err) + } + own = true + } + } if content == nil { return base, nil } diff --git a/canvas/example_test.go b/canvas/example_test.go index 14ba108..1333ae9 100644 --- a/canvas/example_test.go +++ b/canvas/example_test.go @@ -2,6 +2,7 @@ package canvas_test import ( "fmt" + "image" "image/color" "github.com/odevine/impasto/blend" @@ -61,3 +62,29 @@ func Example() { fmt.Printf("rendered %dx%d\n", img.Bounds().Dx(), img.Bounds().Dy()) // Output: rendered 200x120 } + +// A layer can take a decoded 8-bit image as it is, through Image or LoadImage. +// It is blended without being converted to a float buffer first, and the same +// image can be shared by any number of layers and renders +func ExampleLayer_image() { + frame := image.NewNRGBA(image.Rect(0, 0, 2, 2)) + for i := range frame.Pix { + frame.Pix[i] = 255 // opaque white + } + + doc := &canvas.Document{Width: 4, Height: 4, Root: canvas.Group{PassThrough: true, Layers: []canvas.Node{ + &canvas.Layer{Content: raster.MustNewBuffer(4, 4)}, + &canvas.Layer{Image: frame, Origin: image.Pt(1, 1)}, + &canvas.Layer{LoadImage: func() (image.Image, image.Point, error) { return frame, image.Pt(3, 3), nil }}, + }}} + out := canvas.MustRender(doc) + + for _, p := range [][2]int{{0, 0}, {1, 1}, {3, 3}} { + _, _, _, a := out.At(p[0], p[1]) + fmt.Printf("(%d,%d) alpha %.0f\n", p[0], p[1], a) + } + // Output: + // (0,0) alpha 0 + // (1,1) alpha 1 + // (3,3) alpha 1 +} diff --git a/canvas/image_test.go b/canvas/image_test.go new file mode 100644 index 0000000..40cae76 --- /dev/null +++ b/canvas/image_test.go @@ -0,0 +1,300 @@ +package canvas + +import ( + "errors" + "image" + "image/color" + "runtime" + "strings" + "testing" + + "github.com/odevine/impasto/blend" + "github.com/odevine/impasto/effects" + "github.com/odevine/impasto/internal/testimg" + "github.com/odevine/impasto/mask" + "github.com/odevine/impasto/raster" +) + +const imgDocW, imgDocH = 64, 48 + +// below is a layer under the one being tested, so a blend has a backdrop +func below() *Layer { return &Layer{Content: fill(imgDocW, imgDocH, 0.4, 0.5, 0.2, 0.7)} } + +func renderBoth(t *testing.T, mk func(viaImage bool) []Node) (viaBuffer, viaImage *raster.Buffer) { + t.Helper() + build := func(viaImage bool) *raster.Buffer { + out, err := Render(&Document{Width: imgDocW, Height: imgDocH, Root: Group{PassThrough: true, Layers: mk(viaImage)}}) + if err != nil { + t.Fatal(err) + } + return out + } + return build(false), build(true) +} + +func equalBits(t *testing.T, got, want *raster.Buffer, what string) { + t.Helper() + testimg.EqualBits(t, got.Pix, want.Pix, what) +} + +// imageLayer is one layer built either from the converted buffer or from the +// image itself, which must render the same +func imageLayerFor(img image.Image, at image.Point, viaImage bool, set func(*Layer)) *Layer { + l := &Layer{Origin: at} + if viaImage { + l.Image = img + } else { + buf, err := raster.FromImage(img) + if err != nil { + panic(err) + } + l.Content = buf + } + if set != nil { + set(l) + } + return l +} + +func TestImageLayerRendersLikeItsBuffer(t *testing.T) { + img := testimg.Patterned(37, 29) + for m := blend.Normal; m <= blend.Luminosity; m++ { + for _, op := range []float32{0, 0.5, 1} { + for _, at := range []image.Point{{0, 0}, {9, 6}, {-6, -4}, {50, 40}} { + a, b := renderBoth(t, func(viaImage bool) []Node { + return []Node{below(), imageLayerFor(img, at, viaImage, func(l *Layer) { l.Mode, l.Opacity = m, op })} + }) + equalBits(t, b, a, m.String()) + } + } + } +} + +// Anything that writes to the content, or needs its coverage, converts the image +// first and must still agree with the buffer path +func TestImageLayerFallbacksRenderLikeTheirBuffer(t *testing.T) { + img := testimg.Patterned(37, 29) + cases := map[string]func(viaImage bool) []Node{ + "mask": func(v bool) []Node { + return []Node{below(), imageLayerFor(img, image.Pt(8, 5), v, func(l *Layer) { + l.Mask = mask.FuncMask(func(x, y int) float32 { return float32((x+y)%4) / 3 }) + })} + }, + "effects": func(v bool) []Node { + return []Node{below(), imageLayerFor(img, image.Pt(8, 5), v, func(l *Layer) { + l.Effects = []effects.Effect{&effects.DropShadow{Color: color.Black, Opacity: 0.6, Angle: 0.8, Distance: 3}} + })} + }, + "clip base": func(v bool) []Node { + clipped := &Layer{Content: fill(imgDocW, imgDocH, 0.9, 0.1, 0.1, 0.9), ClipToBelow: true} + return []Node{below(), imageLayerFor(img, image.Pt(8, 5), v, nil), clipped} + }, + "clipped": func(v bool) []Node { + return []Node{below(), imageLayerFor(img, image.Pt(8, 5), v, func(l *Layer) { l.ClipToBelow = true })} + }, + } + for name, mk := range cases { + a, b := renderBoth(t, mk) + equalBits(t, b, a, name) + } +} + +func TestImageLayerOfAnotherTypeRendersLikeItsBuffer(t *testing.T) { + nrgba := testimg.Patterned(20, 16) + rgba := image.NewRGBA(nrgba.Rect) + gray := image.NewGray(nrgba.Rect) + for y := 0; y < 16; y++ { + for x := 0; x < 20; x++ { + rgba.Set(x, y, nrgba.At(x, y)) + gray.Set(x, y, nrgba.At(x, y)) + } + } + for name, img := range map[string]image.Image{"rgba": rgba, "gray": gray} { + a, b := renderBoth(t, func(viaImage bool) []Node { + return []Node{below(), imageLayerFor(img, image.Pt(5, 5), viaImage, nil)} + }) + equalBits(t, b, a, name) + } +} + +func TestLoadImageIsCalledOnceAndSuppliesItsOrigin(t *testing.T) { + img := testimg.Patterned(20, 16) + calls := 0 + lazy := &Layer{Origin: image.Pt(40, 40), LoadImage: func() (image.Image, image.Point, error) { + calls++ + return img, image.Pt(5, 5), nil + }} + got, err := Render(&Document{Width: imgDocW, Height: imgDocH, Root: Group{PassThrough: true, Layers: []Node{below(), lazy}}}) + if err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Errorf("LoadImage called %d times, want 1", calls) + } + want, _ := renderBoth(t, func(bool) []Node { return []Node{below(), imageLayerFor(img, image.Pt(5, 5), false, nil)} }) + equalBits(t, got, want, "lazy image") +} + +func TestLoadImageErrorStopsTheRender(t *testing.T) { + boom := errors.New("decode failed") + later := 0 + doc := &Document{Width: imgDocW, Height: imgDocH, Root: Group{PassThrough: true, Layers: []Node{ + below(), + &Layer{LoadImage: func() (image.Image, image.Point, error) { return nil, image.Point{}, boom }}, + &Layer{LoadImage: func() (image.Image, image.Point, error) { later++; return testimg.Patterned(4, 4), image.Point{}, nil }}, + }}} + out, err := Render(doc) + if out != nil || !errors.Is(err, boom) || !strings.Contains(err.Error(), "layer 1") || later != 0 { + t.Fatalf("Render = %v, %v with %d later loads, want no buffer, the wrapped error naming layer 1, and none", out, err, later) + } +} + +func TestEmptyImagesContributeNothing(t *testing.T) { + want, _ := renderBoth(t, func(bool) []Node { return []Node{below()} }) + for name, l := range map[string]*Layer{ + "empty image": {Image: image.NewNRGBA(image.Rect(0, 0, 0, 0))}, + "no content": {}, + "lazy nil": {LoadImage: func() (image.Image, image.Point, error) { return nil, image.Point{}, nil }}, + } { + got, err := Render(&Document{Width: imgDocW, Height: imgDocH, Root: Group{PassThrough: true, Layers: []Node{below(), l}}}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + equalBits(t, got, want, name) + } +} + +// Content and Load come first, so a layer that sets an image too draws its buffer +func TestBufferContentWinsOverAnImage(t *testing.T) { + img := testimg.Patterned(12, 12) + buf := fill(8, 8, 0.1, 0.9, 0.3, 1) + want, err := Render(&Document{Width: imgDocW, Height: imgDocH, Root: Group{PassThrough: true, Layers: []Node{below(), &Layer{Content: buf}}}}) + if err != nil { + t.Fatal(err) + } + for name, l := range map[string]*Layer{ + "content": {Content: buf, Image: img}, + "load": {Load: func() (*raster.Buffer, image.Point, error) { return buf.Clone(), image.Point{}, nil }, Image: img}, + } { + got, err := Render(&Document{Width: imgDocW, Height: imgDocH, Root: Group{PassThrough: true, Layers: []Node{below(), l}}}) + if err != nil { + t.Fatal(err) + } + equalBits(t, got, want, name) + } +} + +// An image is never written to, even by a layer that is masked or has effects +func TestRenderLeavesImagesAlone(t *testing.T) { + img := testimg.Patterned(30, 24) + pix := append([]uint8(nil), img.Pix...) + for _, set := range []func(*Layer){ + nil, + func(l *Layer) { l.Mask = mask.FuncMask(func(x, y int) float32 { return 0.5 }) }, + func(l *Layer) { + l.Effects = []effects.Effect{&effects.DropShadow{Color: color.Black, Opacity: 0.5, Distance: 2}} + }, + } { + l := &Layer{Image: img, Origin: image.Pt(3, 3)} + if set != nil { + set(l) + } + if _, err := Render(&Document{Width: imgDocW, Height: imgDocH, Root: Group{PassThrough: true, Layers: []Node{below(), l}}}); err != nil { + t.Fatal(err) + } + } + for i := range pix { + if img.Pix[i] != pix[i] { + t.Fatal("a render wrote to the image") + } + } +} + +// Banding must not change a pixel, so the result is the same however many +// goroutines share the work +func TestImageLayerIsIndependentOfParallelism(t *testing.T) { + img := testimg.Patterned(61, 45) + render := func(procs int) *raster.Buffer { + defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(procs)) + layers := []Node{&Layer{Content: fill(200, 400, 0.4, 0.5, 0.2, 0.7)}} + for _, m := range []blend.Mode{blend.Normal, blend.Multiply, blend.Overlay} { + layers = append(layers, &Layer{Image: img, Origin: image.Pt(int(m)*20, int(m)*60), Mode: m, Opacity: 0.8}) + } + out, err := Render(&Document{Width: 200, Height: 400, Root: Group{PassThrough: true, Layers: layers}}) + if err != nil { + t.Fatal(err) + } + return out + } + one := render(1) + for _, procs := range []int{2, 7, 16} { + equalBits(t, render(procs), one, "GOMAXPROCS") + } +} + +func TestIndexedLayerRendersLikeItsBuffer(t *testing.T) { + const big = 200 + img := testimg.Sparse(190, 150, 32) + ix := blend.Index(img) + for m := blend.Normal; m <= blend.Luminosity; m++ { + for _, at := range []image.Point{{0, 0}, {5, 9}, {-30, -20}, {120, 100}} { + render := func(l *Layer) *raster.Buffer { + out, err := Render(&Document{Width: big, Height: big, Root: Group{PassThrough: true, Layers: []Node{ + &Layer{Content: fill(big, big, 0.3, 0.6, 0.4, 0.8)}, l, + }}}) + if err != nil { + t.Fatal(err) + } + return out + } + buf, _ := raster.FromImage(img) + want := render(&Layer{Content: buf, Origin: at, Mode: m, Opacity: 0.7}) + equalBits(t, render(&Layer{Image: ix, Origin: at, Mode: m, Opacity: 0.7}), want, m.String()) + lazy := render(&Layer{LoadImage: func() (image.Image, image.Point, error) { return ix, at, nil }, Mode: m, Opacity: 0.7}) + equalBits(t, lazy, want, m.String()+" lazy") + } + } +} + +// A layer that has to write to its content converts the image, and an index +// does not change what it converts +func TestIndexedLayerFallbacksRenderLikeTheirBuffer(t *testing.T) { + img := testimg.Sparse(60, 40, 32) + ix := blend.Index(img) + for name, set := range map[string]func(*Layer){ + "mask": func(l *Layer) { l.Mask = mask.FuncMask(func(x, y int) float32 { return float32((x+y)%4) / 3 }) }, + "effects": func(l *Layer) { + l.Effects = []effects.Effect{&effects.DropShadow{Color: color.Black, Opacity: 0.6, Distance: 3}} + }, + "clipped": func(l *Layer) { l.ClipToBelow = true }, + } { + render := func(mk func() *Layer) *raster.Buffer { + l := mk() + set(l) + out, err := Render(&Document{Width: imgDocW, Height: imgDocH, Root: Group{PassThrough: true, Layers: []Node{below(), l}}}) + if err != nil { + t.Fatal(err) + } + return out + } + buf, _ := raster.FromImage(img) + want := render(func() *Layer { return &Layer{Content: buf, Origin: image.Pt(2, 3)} }) + got := render(func() *Layer { return &Layer{Image: ix, Origin: image.Pt(2, 3)} }) + equalBits(t, got, want, name) + } +} + +// An image too wide to convert is reported as a load error where a conversion is +// needed, and blended without one where it is not +func TestImageTooWideToConvert(t *testing.T) { + wide := image.NewNRGBA(image.Rect(0, 0, raster.MaxDimension+1, 1)) + masked := &Layer{Image: wide, Mask: mask.FuncMask(func(x, y int) float32 { return 1 })} + _, err := Render(&Document{Width: imgDocW, Height: imgDocH, Root: Group{PassThrough: true, Layers: []Node{masked}}}) + if err == nil || !strings.Contains(err.Error(), "load") || !errors.Is(err, raster.ErrDimensions) { + t.Fatalf("masked wide image error = %v, want a load error wrapping ErrDimensions", err) + } + // Blended directly it is only read, so its size does not matter + if _, err := Render(&Document{Width: imgDocW, Height: imgDocH, Root: Group{PassThrough: true, Layers: []Node{&Layer{Image: wide}}}}); err != nil { + t.Errorf("a wide image blended directly failed: %v", err) + } +} diff --git a/canvas/memory_bench_test.go b/canvas/memory_bench_test.go index 18d6dd6..3c7002c 100644 --- a/canvas/memory_bench_test.go +++ b/canvas/memory_bench_test.go @@ -14,15 +14,15 @@ import ( ) const ( - cardW, cardH = 3264, 4440 - textW, textH = 2600, 260 + sceneW, sceneH = 3264, 4440 + stripW, stripH = 2600, 260 ) -// sceneKind selects how cardScene supplies its layer content +// sceneKind selects how layeredScene supplies its layer content type sceneKind int const ( - // scenePlaced puts every text and icon layer in a document-sized buffer, the + // scenePlaced puts every strip and square layer in a document-sized buffer, the // only way to build the scene without Origin scenePlaced sceneKind = iota // sceneBounded gives them their own size and an Origin @@ -31,10 +31,10 @@ const ( sceneLazy ) -// cardScene builds a document shaped like a card render: eight document-sized -// layers, ten text-sized layers one of which has a hard offset shadow, and a -// small icon -func cardScene(kind sceneKind) *Document { +// layeredScene builds a print-size document of many layers: eight document-sized +// layers, ten strip-sized layers one of which has a hard offset shadow, and a +// small square +func layeredScene(kind sceneKind) *Document { // sized is a layer of w by h filled with a color at x,y, built up front or on // demand according to the kind sized := func(w, h, x, y int, r, g, b, a float32) *Layer { @@ -46,12 +46,12 @@ func cardScene(kind sceneKind) *Document { case sceneBounded: return &Layer{Content: fill(w, h, r, g, b, a), Origin: image.Pt(x, y)} default: - return &Layer{Content: Place(cardW, cardH, fill(w, h, r, g, b, a), x, y)} + return &Layer{Content: Place(sceneW, sceneH, fill(w, h, r, g, b, a), x, y)} } } var layers []Node for i := 0; i < 8; i++ { - l := sized(cardW, cardH, 0, 0, 0.1+0.05*float32(i), 0.2, 0.3, 0.3) + l := sized(sceneW, sceneH, 0, 0, 0.1+0.05*float32(i), 0.2, 0.3, 0.3) l.Opacity, l.Mode = 0.9, blend.Normal layers = append(layers, l) } @@ -63,10 +63,10 @@ func cardScene(kind sceneKind) *Document { return l } for i := 0; i < 10; i++ { - layers = append(layers, small(textW, textH, 300, 300+i*380, i == 3)) + layers = append(layers, small(stripW, stripH, 300, 300+i*380, i == 3)) } layers = append(layers, small(180, 180, 2900, 4000, false)) - return &Document{Width: cardW, Height: cardH, Root: Group{PassThrough: true, Layers: layers}} + return &Document{Width: sceneW, Height: sceneH, Root: Group{PassThrough: true, Layers: layers}} } // benchPeakHeap renders the document b.N times and reports the highest heap in @@ -112,13 +112,13 @@ func benchPeakHeap(b *testing.B, d *Document) { mu.Unlock() } -// BenchmarkMemoryCardPlaced is the card scene with document-sized text layers -func BenchmarkMemoryCardPlaced(b *testing.B) { benchPeakHeap(b, cardScene(scenePlaced)) } +// BenchmarkMemoryPlaced is the layered scene with document-sized strip layers +func BenchmarkMemoryPlaced(b *testing.B) { benchPeakHeap(b, layeredScene(scenePlaced)) } -// BenchmarkMemoryCardBounded is the card scene with the text and icon layers at -// their own size -func BenchmarkMemoryCardBounded(b *testing.B) { benchPeakHeap(b, cardScene(sceneBounded)) } +// BenchmarkMemoryBounded is the layered scene with the strip and square layers +// at their own size +func BenchmarkMemoryBounded(b *testing.B) { benchPeakHeap(b, layeredScene(sceneBounded)) } -// BenchmarkMemoryCardLazy is the bounded scene with every layer built by Load, so +// BenchmarkMemoryLazy is the bounded scene with every layer built by Load, so // only the layer being composited is alive -func BenchmarkMemoryCardLazy(b *testing.B) { benchPeakHeap(b, cardScene(sceneLazy)) } +func BenchmarkMemoryLazy(b *testing.B) { benchPeakHeap(b, layeredScene(sceneLazy)) } diff --git a/canvas/throughput_bench_test.go b/canvas/throughput_bench_test.go new file mode 100644 index 0000000..0068468 --- /dev/null +++ b/canvas/throughput_bench_test.go @@ -0,0 +1,154 @@ +package canvas + +import ( + "image" + "sync" + "testing" + + "github.com/odevine/impasto/blend" + "github.com/odevine/impasto/internal/testimg" + "github.com/odevine/impasto/raster" +) + +// The images below stand in for layers decoded from 8-bit files: some fill +// their rectangle, some are hollow frames, and each sits inside a print-size +// document at its own origin. They are built once and shared, as a decoder's +// cache would share them + +var ( + imageSceneOnce sync.Once + imageSceneImages []sceneImage +) + +// imageForm is how the image scene gives a layer its pixels +type imageForm int + +const ( + // formBuffer converts the image to a buffer each time it is composited, the + // way a layer backed by a decoded file had to before images were accepted + formBuffer imageForm = iota + // formImage hands the layer the image + formImage + // formIndexed hands it the image with its visible runs recorded + formIndexed +) + +// imageLayer places a scene image at (x, y) in the given form +func imageLayer(si sceneImage, form imageForm) *Layer { + x, y, img := si.x, si.y, si.img + switch form { + case formImage: + return &Layer{Mode: si.mode, Opacity: si.opacity, Image: img, Origin: image.Pt(x, y)} + case formIndexed: + return &Layer{Mode: si.mode, Opacity: si.opacity, Image: si.indexed, Origin: image.Pt(x, y)} + } + mode, opacity := si.mode, si.opacity + return &Layer{ + Mode: mode, + Opacity: opacity, + Load: func() (*raster.Buffer, image.Point, error) { + buf, err := raster.FromImage(img) + return buf, image.Pt(x, y), err + }, + } +} + +// imageScene is a print-size document of layers backed by 8-bit images, from a +// large opaque panel and hollow frames down to a strip and a small square, in +// Normal, Multiply and Overlay modes. The images are shared by both forms of it +func imageScene(form imageForm) *Document { + imageSceneOnce.Do(func() { + w, h := sceneW, sceneH + imageSceneImages = []sceneImage{ + {testimg.Solid(w*7/10, h*7/10, 255), w * 15 / 100, h * 15 / 100, blend.Normal, 1, nil}, + {testimg.Hollow(w, h*85/100, w/30), 0, 0, blend.Normal, 1, nil}, + {testimg.Solid(w*81/100, h*54/100, 200), w * 9 / 100, h * 8 / 100, blend.Normal, 1, nil}, + {testimg.Hollow(w*82/100, h*83/100, w/40), w * 9 / 100, h * 8 / 100, blend.Multiply, 1, nil}, + {testimg.Hollow(w*88/100, h*15/100, w/60), w * 6 / 100, h * 5 / 100, blend.Normal, 1, nil}, + {testimg.Solid(w*17/100, h*7/100, 255), w * 75 / 100, h * 87 / 100, blend.Normal, 1, nil}, + {testimg.Solid(w*77/100, h/130, 220), w * 11 / 100, h * 60 / 100, blend.Normal, 1, nil}, + {testimg.Solid(w, h, 90), 0, 0, blend.Overlay, 0.5, nil}, + } + for i := range imageSceneImages { + imageSceneImages[i].indexed = blend.Index(imageSceneImages[i].img) + } + }) + var layers []Node + for _, si := range imageSceneImages { + layers = append(layers, imageLayer(si, form)) + } + return &Document{Width: sceneW, Height: sceneH, Root: Group{PassThrough: true, Layers: layers}} +} + +// sceneImage is one layer of the image scene: its pixels, where it sits, and how +// it blends +type sceneImage struct { + img *image.NRGBA + x, y int + mode blend.Mode + opacity float32 + indexed *blend.Indexed +} + +// benchRenders reports how many documents a render loop finishes per second +// beside the usual time and allocation figures +func benchRenders(b *testing.B) { + b.ReportMetric(float64(b.N)/b.Elapsed().Seconds(), "renders/s") +} + +// renderImages renders the image scene from one goroutine, which measures one +// document's cost end to end +func renderImages(b *testing.B, form imageForm) { + if testing.Short() { + b.Skip("builds print-size images") + } + d := imageScene(form) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + MustRender(d) + } + benchRenders(b) +} + +// renderImagesParallel renders the same scene from GOMAXPROCS goroutines at +// once, which is how a batch uses the library, so it measures throughput when +// every core is busy and the renders compete for memory bandwidth. Run it with +// -cpu to vary the goroutine count +func renderImagesParallel(b *testing.B, form imageForm) { + if testing.Short() { + b.Skip("builds print-size images and holds one document per core") + } + d := imageScene(form) + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + MustRender(d) + } + }) + benchRenders(b) +} + +// BenchmarkRenderImages is the image scene with every layer converted to a +// buffer through Load +func BenchmarkRenderImages(b *testing.B) { renderImages(b, formBuffer) } + +// BenchmarkRenderImagesParallel is BenchmarkRenderImages from every core at once +func BenchmarkRenderImagesParallel(b *testing.B) { renderImagesParallel(b, formBuffer) } + +// BenchmarkRenderImagesDirect is the image scene with every layer given its +// image, which is blended without a buffer +func BenchmarkRenderImagesDirect(b *testing.B) { renderImages(b, formImage) } + +// BenchmarkRenderImagesDirectParallel is BenchmarkRenderImagesDirect from every +// core at once +func BenchmarkRenderImagesDirectParallel(b *testing.B) { renderImagesParallel(b, formImage) } + +// BenchmarkRenderImagesIndexed is the image scene with each image carrying its +// index of visible runs +func BenchmarkRenderImagesIndexed(b *testing.B) { renderImages(b, formIndexed) } + +// BenchmarkRenderImagesIndexedParallel is BenchmarkRenderImagesIndexed from every +// core at once +func BenchmarkRenderImagesIndexedParallel(b *testing.B) { renderImagesParallel(b, formIndexed) } diff --git a/docs/blend.md b/docs/blend.md index e1ee5c8..84e6e75 100644 --- a/docs/blend.md +++ b/docs/blend.md @@ -112,6 +112,54 @@ entirely outside it. Mode, opacity and banding work as in `Composite`, which is result for equal-size buffers. `canvas` uses it to blend a layer over only the region its content covers. +### CompositeNRGBA + +```go +blend.CompositeNRGBA(dst *raster.Buffer, src *image.NRGBA, origin image.Point, m Mode, opacity float32) +``` + +`CompositeNRGBA` blends an 8-bit straight-alpha sRGB image over `dst` as +`CompositeRect` blends a buffer, without building the buffer. It reads each pixel +through the same sRGB table `raster.FromImage` uses and then does the same +arithmetic, so the result is bit-identical to converting `src` and calling +`CompositeRect`, for every mode and opacity. In Normal mode a pixel with no alpha +is skipped, and a fully opaque pixel at full opacity is written without a +multiply, which is where most of the saving comes from for an image with large +transparent or opaque regions. Other modes skip only the transparent pixels and +blend the rest through `BlendPixel`. + +`src` is never written to, so one image can be composited by many goroutines at +once. Its bounds need not start at the origin, and a sub-image is read where it +sits in its parent. + +### Indexed and CompositeIndexed + +```go +ix := blend.Index(img) // once per decoded image +blend.CompositeIndexed(dst, ix, origin, m, opacity) // as often as you like +``` + +`Index` reads an `*image.NRGBA` once and records, for each row, the runs of +columns that have any alpha. `CompositeIndexed` then blends only those runs, so +its cost follows the visible area and not the rectangle. The result is +bit-identical to `CompositeNRGBA`, because a pixel with no alpha leaves the +backdrop alone in every mode. + +An image that is a frame, a hollow outline or a cut-out is mostly transparent, +and a single bounding box does not help it, since every middle row has pixels at +both ends. That is why the index holds runs and not one extent per row. Two +visible stretches separated by fewer than 32 transparent pixels are merged into +one run, which costs less to blend through than to skip. On a 2048 by 2048 frame +with a 60 pixel border, blending takes 0.94 ms indexed against 3.26 ms plain. + +`Indexed` embeds the `*image.NRGBA` and is one, so it can go anywhere an image +can, including `canvas.Layer.Image`. It belongs to the pixels as they were when +`Index` read them, so write to the image afterward and the index is wrong. Build +it once where the decoded image is kept, since one pass over a 3264 by 4440 image +takes about 7.5 ms, and share it freely, because nothing writes to it. An image +that is fully opaque or fully detailed gains nothing, and an index of it holds one +run per row. + ## The modes Values are a fixed `iota` ordering that is part of the API. You can persist the diff --git a/docs/canvas.md b/docs/canvas.md index 8aef2f0..73aabe4 100644 --- a/docs/canvas.md +++ b/docs/canvas.md @@ -156,6 +156,47 @@ group composites each one onto the output straight away, so the peak is the output plus the layer in flight. An isolated group still holds its own document-sized buffer for its children. +## Image layers + +A layer whose pixels already exist as an 8-bit `image.Image`, such as a decoded +PNG, can hand over the image itself. + +```go +&canvas.Layer{Image: frame, Origin: image.Pt(340, 120)} + +&canvas.Layer{ + LoadImage: func() (image.Image, image.Point, error) { + return cache.Get("frame.png"), image.Pt(340, 120), nil // image and its origin + }, +} +``` + +`Image` and `LoadImage` are used when `Content` and `Load` are both nil, in that +order of preference: `Image` first, then `LoadImage`. `Image` sits at +`Layer.Origin`, and the point `LoadImage` returns is the origin of a lazy layer, +as it is for `Load`. + +An `*image.NRGBA` layer with no mask, no effects, and no clip-to-below, whose +next layer is not clipped to it, is blended straight into the document by +[`blend.CompositeNRGBA`](blend.md#compositenrgba). That skips the document-sized +float buffer the same layer would otherwise cost, and a pixel with no alpha costs +almost nothing. The pixels are the same, bit for bit, as converting the image with +`raster.FromImage` and passing it as `Content`. Any other image type, and any +layer that has to write to its content, is converted first, and that is the only +difference. + +An image wrapped by [`blend.Index`](blend.md#indexed-and-compositeindexed) is +blended through the same path and skips the transparent stretches its index +records, which is where a mostly transparent frame spends its time. Any +other use of an indexed image, such as under a mask, converts it as a plain +`*image.NRGBA`. + +Canvas never writes to an image, so one decoded image can serve any number of +layers and any number of concurrent renders. That is the difference from a buffer +returned by `Load`, which canvas owns and writes to. A nil or empty image +contributes nothing, and a `LoadImage` error stops the render as a `Load` error +does. + ## Opacity `Opacity` is in `(0,1]`, and **a non-positive value is treated as fully opaque**, @@ -237,7 +278,8 @@ is a compositing-order rule: its coverage depends on the layer stack, which a Worth knowing in order, because it explains most ordering questions: 1. **Load** the content, for a lazy layer, then **crop** it to the document if it - extends past it. + extends past it. An NRGBA [image layer](#image-layers) with nothing to write to + skips the remaining steps except compositing and is blended directly. 2. **Copy** the content, only if it is about to be masked or clipped and it came from `Content`, so the caller's buffer is never mutated. A buffer from `Load` is already canvas's. @@ -313,7 +355,9 @@ other content. - **`Origin` is where the content's top-left sits.** A buffer that was already `Place`d at a position and also given that `Origin` is offset twice. - **A buffer returned by `Load` is written to in place.** Do not return one that another layer, another render or the caller still uses. -- **A lazy layer's position comes from `Load`**, not from `Layer.Origin`. +- **A lazy layer's position comes from `Load`**, not from `Layer.Origin`. The same + holds for `LoadImage`. +- **`Content` and `Load` win over `Image`.** A layer that sets both draws its buffer. - **Opacity 0 means opaque**, not hidden. Omit the layer instead. - **A blend mode inside an isolated group cannot see the backdrop.** See [groups](#groups). diff --git a/docs/raster.md b/docs/raster.md index df7320f..140713b 100644 --- a/docs/raster.md +++ b/docs/raster.md @@ -138,7 +138,8 @@ img := buf.ToImage(8) // *image.NRGBA, ordered dithering img := buf.ToImage(16) // *image.NRGBA64, no dithering ``` -Any depth other than 16 is treated as 8. +Any depth other than 16 is treated as 8. Rows convert in parallel bands, which +cannot change a pixel since the dither depends only on a pixel's position. Egress reverses ingest: unpremultiply, encode linear back to sRGB, quantize. Values are clamped to `[0,1]` at this point and not before, so an effect that @@ -159,6 +160,33 @@ so the float32 data stays clean for any further processing. 16-bit output skips it. At 65536 levels the step size is already well below the visible threshold, and the noise would be the only thing you gained. +### Getting JPEG planes out + +```go +planes := buf.ToYCbCr() // flattened over black +planes := buf.ToYCbCr(color.White) // flattened over white +jpeg.Encode(w, planes, &jpeg.Options{Quality: 95}) +``` + +`ToYCbCr` encodes the buffer as the 8-bit full-range BT.601 planes JPEG stores, +with each chroma sample averaged over its 2x2 block of pixels, and returns an +`*image.YCbCr` that `image/jpeg` writes without converting again. That skips the +RGB image `ToImage` builds and the encoder would convert back, which is most of +the cost of a JPEG whose pixels are already in a buffer. + +A JPEG has no alpha, so the buffer is flattened over a background first. The +optional argument is that background, and black when it is missing. A background +with alpha of its own is composited under the buffer and the result is then taken +over black, so an opaque one is the usual choice. + +Two things differ from `ToImage`. The conversion is not dithered, since the +quantization a JPEG applies would hide it at the cost of a larger file. And it +reads the sRGB code from a 64 KiB table indexed by the linear value at 16 bits, +which is several times faster than evaluating the transfer function and can put a +code one away from the exact encoding where the exact value sits near a rounding +boundary. Rows convert in parallel bands aligned to the 2x2 blocks, so the result +does not depend on how many goroutines share the work. + ## Color conversion ```go diff --git a/internal/parallel/parallel.go b/internal/parallel/parallel.go index ee9a8fc..00df9a0 100644 --- a/internal/parallel/parallel.go +++ b/internal/parallel/parallel.go @@ -50,3 +50,17 @@ func Rows(height int, fn func(lo, hi int)) { } wg.Wait() } + +// RowsAligned is Rows for work on groups of align rows that must not be split, +// such as 2x2 blocks of pixels. Every band it hands fn starts at a multiple of +// align, and only the last may end short of one, at height. +func RowsAligned(height, align int, fn func(lo, hi int)) { + if align <= 1 { + Rows(height, fn) + return + } + groups := (height + align - 1) / align + Rows(groups, func(lo, hi int) { + fn(lo*align, min(hi*align, height)) + }) +} diff --git a/internal/parallel/parallel_test.go b/internal/parallel/parallel_test.go new file mode 100644 index 0000000..14e1c14 --- /dev/null +++ b/internal/parallel/parallel_test.go @@ -0,0 +1,35 @@ +package parallel + +import ( + "runtime" + "sync" + "testing" +) + +func TestRowsAlignedCoversEveryRowOnceInAlignedBands(t *testing.T) { + defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(8)) + for _, height := range []int{0, 1, 2, 3, 31, 32, 33, 1000, 1001} { + for _, align := range []int{1, 2, 4} { + var mu sync.Mutex + seen := make([]int, height) + RowsAligned(height, align, func(lo, hi int) { + if lo%align != 0 { + t.Errorf("height %d align %d: band starts at %d", height, align, lo) + } + if hi != height && hi%align != 0 { + t.Errorf("height %d align %d: band ends at %d", height, align, hi) + } + mu.Lock() + for y := lo; y < hi; y++ { + seen[y]++ + } + mu.Unlock() + }) + for y, n := range seen { + if n != 1 { + t.Fatalf("height %d align %d: row %d visited %d times", height, align, y, n) + } + } + } + } +} diff --git a/internal/testimg/testimg.go b/internal/testimg/testimg.go new file mode 100644 index 0000000..c57ac0f --- /dev/null +++ b/internal/testimg/testimg.go @@ -0,0 +1,111 @@ +// Package testimg builds the 8-bit images and comparisons that tests in several +// packages share: images with transparent, opaque and partial regions to blend, +// and an exact comparison of pixel data. It imports nothing from the rest of the +// module, so any package's tests can use it, including raster's own +package testimg + +import ( + "image" + "image/color" + "math" + "testing" +) + +// Pixel is a deterministic color that varies across an image, with the alpha +// given +func Pixel(x, y int, alpha uint8) color.NRGBA { + return color.NRGBA{R: uint8(x*37 + y*5), G: uint8(y*23 + x), B: uint8(x*y + 9), A: alpha} +} + +// Patterned is an image whose alpha takes the values a blend branches on, +// transparent, opaque, one short of opaque, the faintest and partial, spread +// across its pixels +func Patterned(w, h int) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + alphas := []uint8{0, 255, 128, 1, 254, 77, 0, 255, 200} + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetNRGBA(x, y, Pixel(x, y, alphas[(x*3+y*7)%len(alphas)])) + } + } + return img +} + +// Solid is an image of w by h whose pixels all have the given alpha +func Solid(w, h int, alpha uint8) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetNRGBA(x, y, Pixel(x, y, alpha)) + } + } + return img +} + +// Hollow is an opaque frame of the given thickness around w by h, transparent +// inside +func Hollow(w, h, thickness int) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + if x < thickness || y < thickness || x >= w-thickness || y >= h-thickness { + img.SetNRGBA(x, y, Pixel(x, y, 255)) + } + } + } + return img +} + +// Sparse is an image whose rows hold stretches of visible pixels separated by +// gaps shorter than, equal to and longer than minGap, plus rows that are empty +// and rows that are full. minGap is the merge distance the test is probing +func Sparse(w, h, minGap int) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + set := func(x, y int, a uint8) { img.SetNRGBA(x, y, Pixel(x, y, a)) } + for y := 0; y < h; y++ { + switch y % 6 { + case 0: // empty + case 1: // full and opaque + for x := 0; x < w; x++ { + set(x, y, 255) + } + case 2: // two strips, a gap one short of minGap between them + for x := 3; x < 10; x++ { + set(x, y, 200) + } + for x := 10 + minGap - 1; x < 10+minGap+9; x++ { + set(x, y, 90) + } + case 3: // two strips, a gap of exactly minGap between them + for x := 3; x < 10; x++ { + set(x, y, 255) + } + for x := 10 + minGap; x < 10+minGap+9; x++ { + set(x, y, 255) + } + case 4: // a frame: a strip at each end and nothing between + for x := 0; x < 5; x++ { + set(x, y, 255) + set(w-1-x, y, 128) + } + case 5: // lone pixels, one with the faintest alpha + set(w/3, y, 1) + set(w-1, y, 255) + } + } + return img +} + +// EqualBits fails the test unless got and want hold the same float32 bit +// patterns, which is stricter than equal values since it tells -0 from 0 +func EqualBits(t testing.TB, got, want []float32, what string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s: %d values, want %d", what, len(got), len(want)) + } + for i := range want { + if math.Float32bits(got[i]) != math.Float32bits(want[i]) { + t.Fatalf("%s: value %d (pixel %d, channel %d) = %v, want %v", what, i, i/4, i%4, got[i], want[i]) + } + } +} diff --git a/raster/buffer_test.go b/raster/buffer_test.go new file mode 100644 index 0000000..ac8af71 --- /dev/null +++ b/raster/buffer_test.go @@ -0,0 +1,158 @@ +package raster + +import ( + "image" + "image/color" + "image/png" + "math" + "os" + "path/filepath" + "testing" +) + +// dirty sets every value of b to something other than zero +func dirty(b *Buffer) *Buffer { + for i := range b.Pix { + b.Pix[i] = float32(i%7) + 0.5 + } + return b +} + +func isClear(b *Buffer) bool { + for _, v := range b.Pix { + if v != 0 { + return false + } + } + return true +} + +func TestBufferBoundsSizeAndClone(t *testing.T) { + b := MustNewBuffer(5, 3) + if w, h := b.Bounds(); w != 5 || h != 3 { + t.Errorf("Bounds = %d, %d, want 5, 3", w, h) + } + if !b.SameSize(MustNewBuffer(5, 3)) || b.SameSize(MustNewBuffer(3, 5)) { + t.Error("SameSize gave the wrong answer") + } + b.Set(2, 1, 0.1, 0.2, 0.3, 0.4) + c := b.Clone() + b.Set(2, 1, 0.9, 0.9, 0.9, 0.9) + if r, g, bl, a := c.At(2, 1); r != 0.1 || g != 0.2 || bl != 0.3 || a != 0.4 { + t.Errorf("a clone shares storage with its original: %v %v %v %v", r, g, bl, a) + } +} + +func TestBufferClear(t *testing.T) { + b := dirty(MustNewBuffer(7, 9)) + b.Clear() + if !isClear(b) { + t.Error("Clear left a pixel set") + } +} + +func TestMustNewBufferPanicsOnABadSize(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("MustNewBuffer(0, 5) did not panic") + } + }() + MustNewBuffer(0, 5) +} + +func TestFromFileDecodesAPNG(t *testing.T) { + src := image.NewNRGBA(image.Rect(0, 0, 2, 1)) + src.SetNRGBA(0, 0, color.NRGBA{R: 255, A: 255}) + src.SetNRGBA(1, 0, color.NRGBA{G: 255, A: 128}) + path := filepath.Join(t.TempDir(), "in.png") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := png.Encode(f, src); err != nil { + t.Fatal(err) + } + f.Close() + + got, err := FromFile(path) + if err != nil { + t.Fatal(err) + } + want, _ := FromImage(src) + for i := range want.Pix { + if got.Pix[i] != want.Pix[i] { + t.Fatalf("value %d = %v, want %v", i, got.Pix[i], want.Pix[i]) + } + } +} + +func TestFromFileReportsAMissingOrUndecodableFile(t *testing.T) { + dir := t.TempDir() + if _, err := FromFile(filepath.Join(dir, "absent.png")); err == nil { + t.Error("a missing file decoded") + } + junk := filepath.Join(dir, "junk.png") + if err := os.WriteFile(junk, []byte("not an image"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := FromFile(junk); err == nil { + t.Error("a file that is not an image decoded") + } +} + +func TestFromImageRefusesAnImageTooWide(t *testing.T) { + if _, err := FromImage(image.NewNRGBA(image.Rect(0, 0, MaxDimension+1, 1))); err == nil { + t.Error("FromImage accepted an image past MaxDimension") + } +} + +// Every image type must reach the same linear values as the generic path, which +// asks for straight 16-bit sRGB through the color model +func TestFromImageMatchesTheGenericPathForEachType(t *testing.T) { + const w, h = 9, 7 + nrgba64 := image.NewNRGBA64(image.Rect(0, 0, w, h)) + gray16 := image.NewGray16(image.Rect(0, 0, w, h)) + gray := image.NewGray(image.Rect(0, 0, w, h)) + ycbcr := image.NewYCbCr(image.Rect(0, 0, w, h), image.YCbCrSubsampleRatio420) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + nrgba64.SetNRGBA64(x, y, color.NRGBA64{R: uint16(x * 7000), G: uint16(y * 9000), B: uint16(x*y*500 + 3), A: uint16(65535 - x*5000)}) + gray16.SetGray16(x, y, color.Gray16{Y: uint16(x*7000 + y*100)}) + gray.SetGray(x, y, color.Gray{Y: uint8(x*25 + y)}) + } + } + for i := range ycbcr.Y { + ycbcr.Y[i] = uint8(i * 5) + } + for name, img := range map[string]image.Image{"nrgba64": nrgba64, "gray16": gray16, "gray": gray, "ycbcr": ycbcr} { + got, err := FromImage(img) + if err != nil { + t.Fatal(err) + } + want := MustNewBuffer(w, h) + want.fromGeneric(img) + for i := range want.Pix { + if d := math.Abs(float64(got.Pix[i] - want.Pix[i])); d > 1e-6 { + t.Fatalf("%s: value %d (pixel %d, channel %d) = %v, generic path gives %v", name, i, i/4, i%4, got.Pix[i], want.Pix[i]) + } + } + } +} + +func TestToImage16ClampsOutOfRangeValues(t *testing.T) { + buf := MustNewBuffer(2, 1) + buf.Set(0, 0, 3, -2, 0.5, 1) + buf.Set(1, 0, 0.2, 0.2, 0.2, 1.7) + out := buf.ToImage(16).(*image.NRGBA64) + if got := out.NRGBA64At(0, 0); got.R != 65535 || got.G != 0 || got.A != 65535 { + t.Errorf("clamped pixel = %+v, want R 65535, G 0, A 65535", got) + } + if got := out.NRGBA64At(1, 0); got.A != 65535 { + t.Errorf("alpha 1.7 encoded as %d, want 65535", got.A) + } + // Alpha below zero clamps too, and a pixel with no alpha encodes as nothing + buf.Set(0, 0, 1, 1, 1, -3) + if got := buf.ToImage(16).(*image.NRGBA64).NRGBA64At(0, 0); got != (color.NRGBA64{}) { + t.Errorf("negative alpha encoded as %+v, want transparent black", got) + } +} diff --git a/raster/example_test.go b/raster/example_test.go index ede5e35..63d1081 100644 --- a/raster/example_test.go +++ b/raster/example_test.go @@ -76,6 +76,22 @@ func ExampleBuffer_ToImage() { // *image.NRGBA64 (0,0)-(2,2) } +// ToYCbCr encodes for a JPEG: the planes image/jpeg stores, with alpha flattened +// over a background that is black unless one is given +func ExampleBuffer_ToYCbCr() { + buf := raster.MustNewBuffer(2, 2) // fully transparent + + onBlack := buf.ToYCbCr() + onWhite := buf.ToYCbCr(color.White) + fmt.Printf("%T %v\n", onBlack, onBlack.Bounds()) + fmt.Printf("over black: Y=%d\n", onBlack.Y[0]) + fmt.Printf("over white: Y=%d\n", onWhite.Y[0]) + // Output: + // *image.YCbCr (0,0)-(2,2) + // over black: Y=0 + // over white: Y=255 +} + // The sRGB transfer functions are exported for callers that need to convert a // single value, such as when building a color constant by hand func ExampleSRGBToLinear() { diff --git a/raster/image.go b/raster/image.go index 3a8fe0b..1c83328 100644 --- a/raster/image.go +++ b/raster/image.go @@ -6,6 +6,8 @@ import ( "image/color" "os" + "github.com/odevine/impasto/internal/parallel" + // Register the standard decoders so FromFile handles the common formats _ "image/gif" _ "image/jpeg" @@ -199,42 +201,49 @@ func (b *Buffer) ToImage(bitDepth int) image.Image { return b.toNRGBA() } -// toNRGBA produces dithered 8-bit straight-alpha sRGB output +// toNRGBA produces dithered 8-bit straight-alpha sRGB output. Rows are +// converted in parallel bands, which cannot change a pixel since the dither +// depends only on its position func (b *Buffer) toNRGBA() *image.NRGBA { dst := image.NewNRGBA(image.Rect(0, 0, b.Width, b.Height)) - si := 0 - for y := 0; y < b.Height; y++ { - di := dst.PixOffset(0, y) - for x := 0; x < b.Width; x++ { - r, g, bl, a := unpremultiply(b.Pix[si], b.Pix[si+1], b.Pix[si+2], b.Pix[si+3]) - d := ditherOffset(x, y) - dst.Pix[di] = encode8(r, d) - dst.Pix[di+1] = encode8(g, d) - dst.Pix[di+2] = encode8(bl, d) - dst.Pix[di+3] = quantize8(a, d) - si += 4 - di += 4 + parallel.Rows(b.Height, func(lo, hi int) { + for y := lo; y < hi; y++ { + si := y * b.Width * 4 + di := dst.PixOffset(0, y) + for x := 0; x < b.Width; x++ { + r, g, bl, a := unpremultiply(b.Pix[si], b.Pix[si+1], b.Pix[si+2], b.Pix[si+3]) + d := ditherOffset(x, y) + dst.Pix[di] = encode8(r, d) + dst.Pix[di+1] = encode8(g, d) + dst.Pix[di+2] = encode8(bl, d) + dst.Pix[di+3] = quantize8(a, d) + si += 4 + di += 4 + } } - } + }) return dst } -// toNRGBA64 produces undithered 16-bit straight-alpha sRGB output +// toNRGBA64 produces undithered 16-bit straight-alpha sRGB output, in parallel +// bands like toNRGBA func (b *Buffer) toNRGBA64() *image.NRGBA64 { dst := image.NewNRGBA64(image.Rect(0, 0, b.Width, b.Height)) - si := 0 - for y := 0; y < b.Height; y++ { - di := dst.PixOffset(0, y) - for x := 0; x < b.Width; x++ { - r, g, bl, a := unpremultiply(b.Pix[si], b.Pix[si+1], b.Pix[si+2], b.Pix[si+3]) - put16(dst.Pix[di:], quantize16(LinearToSRGB(r))) - put16(dst.Pix[di+2:], quantize16(LinearToSRGB(g))) - put16(dst.Pix[di+4:], quantize16(LinearToSRGB(bl))) - put16(dst.Pix[di+6:], quantize16(a)) - si += 4 - di += 8 + parallel.Rows(b.Height, func(lo, hi int) { + for y := lo; y < hi; y++ { + si := y * b.Width * 4 + di := dst.PixOffset(0, y) + for x := 0; x < b.Width; x++ { + r, g, bl, a := unpremultiply(b.Pix[si], b.Pix[si+1], b.Pix[si+2], b.Pix[si+3]) + put16(dst.Pix[di:], quantize16(LinearToSRGB(r))) + put16(dst.Pix[di+2:], quantize16(LinearToSRGB(g))) + put16(dst.Pix[di+4:], quantize16(LinearToSRGB(bl))) + put16(dst.Pix[di+6:], quantize16(a)) + si += 4 + di += 8 + } } - } + }) return dst } diff --git a/raster/ycbcr.go b/raster/ycbcr.go new file mode 100644 index 0000000..07eb1c3 --- /dev/null +++ b/raster/ycbcr.go @@ -0,0 +1,118 @@ +package raster + +import ( + "image" + "image/color" + "math" + + "github.com/odevine/impasto/internal/parallel" +) + +// ToYCbCr encodes the buffer as 8-bit sRGB in the full-range BT.601 color space +// JPEG stores, with chroma subsampled 4:2:0 by averaging each 2x2 block of +// pixels. The planes are what image/jpeg's encoder takes without converting +// again, so a buffer headed for a JPEG skips the intermediate RGB image +// that ToImage and the encoder would otherwise build. +// +// Alpha is dropped by flattening the buffer over a background, which is the +// optional argument and black when it is missing. A background with alpha of its +// own is composited under the buffer and the result is then taken over black, so +// an opaque one is the usual choice. The conversion is not dithered, unlike +// ToImage, since the quantization a JPEG applies would hide it at the cost of a +// larger file. A pixel past the edge of an odd-sized buffer is read as its inside +// neighbor when averaging chroma. +// +// Rows are converted in parallel bands aligned to the 2x2 blocks, so the result +// does not depend on how many goroutines share the work +func (b *Buffer) ToYCbCr(background ...color.Color) *image.YCbCr { + w, h := b.Width, b.Height + dst := image.NewYCbCr(image.Rect(0, 0, w, h), image.YCbCrSubsampleRatio420) + bg, flatten := premultipliedLinear(background) + parallel.RowsAligned(h, 2, func(lo, hi int) { + for y := lo; y < hi; y += 2 { + y1 := min(y+1, h-1) + for x := 0; x < w; x += 2 { + x1 := min(x+1, w-1) + var sr, sg, sb int32 + for _, at := range [4][2]int{{x, y}, {x1, y}, {x, y1}, {x1, y1}} { + i := (at[1]*w + at[0]) * 4 + r, g, bl := b.Pix[i], b.Pix[i+1], b.Pix[i+2] + if flatten { + // The part of the background the pixel leaves showing + show := max(1-b.Pix[i+3], 0) + r, g, bl = r+show*bg[0], g+show*bg[1], bl+show*bg[2] + } + cr, cg, cb := code8(r), code8(g), code8(bl) + dst.Y[at[1]*dst.YStride+at[0]] = uint8((19595*cr + 38470*cg + 7471*cb + 1<<15) >> 16) + sr, sg, sb = sr+cr, sg+cg, sb+cb + } + c := y/2*dst.CStride + x/2 + dst.Cb[c] = chromaCode(-11059*sr - 21709*sg + 32768*sb) + dst.Cr[c] = chromaCode(32768*sr - 27439*sg - 5329*sb) + } + } + }) + return dst +} + +// premultipliedLinear is the background as premultiplied linear light, and +// whether there is any to flatten over. No argument, and a transparent or black +// one, flatten over nothing +func premultipliedLinear(background []color.Color) (bg [3]float32, flatten bool) { + if len(background) == 0 || background[0] == nil { + return bg, false + } + r, g, b, a := background[0].RGBA() + if a == 0 { + return bg, false + } + // RGBA returns premultiplied 16-bit values, and the transfer function acts + // on the straight color + for i, v := range [3]uint32{r, g, b} { + straight := float32(v) / float32(a) + bg[i] = SRGBToLinear(min(straight, 1)) * float32(a) / 65535 + if bg[i] != 0 { + flatten = true + } + } + return bg, flatten +} + +// srgbCodes maps a linear value quantized to 16 bits to its 8-bit sRGB code, so +// the conversion is a lookup and not a power function. The 64 KiB table stays +// resident in cache. A code can differ by one from the exact encoding where the +// exact value sits within a twentieth of a code of a rounding boundary, which the +// JPEG quantization that follows hides +var srgbCodes [65536]uint8 + +func init() { + for i := range srgbCodes { + srgbCodes[i] = uint8(math.Round(linearToSRGB64(float64(i)/65535) * 255)) + } +} + +// linearToSRGB64 is LinearToSRGB in double precision, so a table entry is the +// correctly rounded code +func linearToSRGB64(c float64) float64 { + if c <= 0.0031308 { + return c * 12.92 + } + return 1.055*math.Pow(c, 1.0/2.4) - 0.055 +} + +// code8 is the 8-bit sRGB code of a linear value, clamped to [0,1] +func code8(v float32) int32 { + if !(v > 0) { + return 0 + } + if v >= 1 { + return 255 + } + return int32(srgbCodes[int(v*65535+0.5)]) +} + +// chromaCode turns a fixed-point chroma sum over four pixels, scaled by 2^16, into +// a code centered on 128 +func chromaCode(sum int32) uint8 { + return uint8(min((128<<18+sum+1<<17)>>18, 255)) +} diff --git a/raster/ycbcr_test.go b/raster/ycbcr_test.go new file mode 100644 index 0000000..19bde16 --- /dev/null +++ b/raster/ycbcr_test.go @@ -0,0 +1,240 @@ +package raster + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "math" + "runtime" + "testing" +) + +// ramp fills a buffer with smooth color and an alpha that varies, premultiplied +func ramp(w, h int) *Buffer { + buf := MustNewBuffer(w, h) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + a := 0.2 + 0.8*float32((x*3+y*5)%11)/10 + buf.Set(x, y, a*float32(x)/float32(w), a*float32(y)/float32(h), a*float32(x+y)/float32(w+h), a) + } + } + return buf +} + +// refCode is the exact 8-bit sRGB code of a linear value +func refCode(v float32) int32 { + x := math.Min(math.Max(float64(v), 0), 1) + if x <= 0.0031308 { + return int32(math.Round(x * 12.92 * 255)) + } + return int32(math.Round((1.055*math.Pow(x, 1.0/2.4) - 0.055) * 255)) +} + +// refYCbCr converts with plain loops and no banding, from the same definition +func refYCbCr(b *Buffer, bg [3]float32) *image.YCbCr { + dst := image.NewYCbCr(image.Rect(0, 0, b.Width, b.Height), image.YCbCrSubsampleRatio420) + px := func(x, y int) (r, g, bl int32) { + x, y = min(x, b.Width-1), min(y, b.Height-1) + pr, pg, pb, pa := b.At(x, y) + show := max(1-pa, 0) + return refCode(pr + show*bg[0]), refCode(pg + show*bg[1]), refCode(pb + show*bg[2]) + } + for y := 0; y < b.Height; y += 2 { + for x := 0; x < b.Width; x += 2 { + var sr, sg, sb int32 + for _, d := range [4][2]int{{0, 0}, {1, 0}, {0, 1}, {1, 1}} { + r, g, bl := px(x+d[0], y+d[1]) + if x+d[0] < b.Width && y+d[1] < b.Height { + dst.Y[(y+d[1])*dst.YStride+x+d[0]] = uint8((19595*r + 38470*g + 7471*bl + 1<<15) >> 16) + } + sr, sg, sb = sr+r, sg+g, sb+bl + } + c := y/2*dst.CStride + x/2 + dst.Cb[c] = chromaCode(-11059*sr - 21709*sg + 32768*sb) + dst.Cr[c] = chromaCode(32768*sr - 27439*sg - 5329*sb) + } + } + return dst +} + +// closeYCbCr allows each sample to differ by one, which is what the lookup table +// does against the exact transfer function +func closeYCbCr(t *testing.T, got, want *image.YCbCr, what string) { + t.Helper() + if got.Rect != want.Rect || got.SubsampleRatio != want.SubsampleRatio { + t.Fatalf("%s: %v %v, want %v %v", what, got.Rect, got.SubsampleRatio, want.Rect, want.SubsampleRatio) + } + for _, p := range []struct { + name string + got, want []uint8 + }{{"Y", got.Y, want.Y}, {"Cb", got.Cb, want.Cb}, {"Cr", got.Cr, want.Cr}} { + for i := range p.want { + if d := int(p.got[i]) - int(p.want[i]); d < -1 || d > 1 { + t.Fatalf("%s: %s[%d] = %d, want %d within one", what, p.name, i, p.got[i], p.want[i]) + } + } + } +} + +func sameYCbCr(t *testing.T, got, want *image.YCbCr, what string) { + t.Helper() + if got.Rect != want.Rect || got.SubsampleRatio != want.SubsampleRatio { + t.Fatalf("%s: %v %v, want %v %v", what, got.Rect, got.SubsampleRatio, want.Rect, want.SubsampleRatio) + } + for _, p := range []struct { + name string + got, want []uint8 + }{{"Y", got.Y, want.Y}, {"Cb", got.Cb, want.Cb}, {"Cr", got.Cr, want.Cr}} { + if !bytes.Equal(p.got, p.want) { + t.Fatalf("%s: plane %s differs", what, p.name) + } + } +} + +func TestToYCbCrMatchesAReference(t *testing.T) { + // Even and odd sizes, since an edge block averages pixels that exist + for _, size := range [][2]int{{64, 48}, {63, 47}, {1, 1}, {2, 1}, {1, 2}, {3, 5}} { + buf := ramp(size[0], size[1]) + closeYCbCr(t, buf.ToYCbCr(), refYCbCr(buf, [3]float32{}), "default background") + } +} + +func TestToYCbCrIsCloseToToImage(t *testing.T) { + // ToImage dithers and unpremultiplies, so only the luma of an opaque buffer + // can be compared, to within the dither + buf := MustNewBuffer(40, 30) + for y := 0; y < 30; y++ { + for x := 0; x < 40; x++ { + buf.Set(x, y, float32(x)/40, float32(y)/30, 0.5, 1) + } + } + got, ref := buf.ToYCbCr(), buf.ToImage(8) + for y := 0; y < 30; y++ { + for x := 0; x < 40; x++ { + r, g, b, _ := ref.At(x, y).RGBA() + want, _, _ := color.RGBToYCbCr(uint8(r>>8), uint8(g>>8), uint8(b>>8)) + if d := int(got.Y[y*got.YStride+x]) - int(want); d < -2 || d > 2 { + t.Fatalf("Y at (%d,%d) = %d, ToImage gives %d", x, y, got.Y[y*got.YStride+x], want) + } + } + } +} + +func TestToYCbCrFlattensOverABackground(t *testing.T) { + buf := MustNewBuffer(4, 4) + buf.Set(0, 0, 1, 0, 0, 1) // opaque red, the rest transparent + white := buf.ToYCbCr(color.White) + // A transparent pixel shows the background in full + if y := white.Y[1*white.YStride+1]; y != 255 { + t.Errorf("transparent pixel over white has Y %d, want 255", y) + } + if cb, cr := white.Cb[1*white.CStride+1], white.Cr[1*white.CStride+1]; cb != 128 || cr != 128 { + t.Errorf("a block of white has chroma %d,%d, want 128,128", cb, cr) + } + // An opaque pixel hides it + black := buf.ToYCbCr() + if white.Y[0] != black.Y[0] { + t.Errorf("an opaque pixel changed with the background: %d against %d", white.Y[0], black.Y[0]) + } + // The default is black, and black says the same thing + sameYCbCr(t, buf.ToYCbCr(color.Black), black, "explicit black") + sameYCbCr(t, buf.ToYCbCr(nil), black, "nil background") + sameYCbCr(t, buf.ToYCbCr(color.Transparent), black, "transparent background") + // A mid-gray lands between + gray := buf.ToYCbCr(color.NRGBA{R: 128, G: 128, B: 128, A: 255}) + if y := gray.Y[1*gray.YStride+1]; y != 128 { + t.Errorf("transparent pixel over gray 128 has Y %d, want 128", y) + } +} + +func TestToYCbCrMatchesAReferenceOverABackground(t *testing.T) { + buf := ramp(37, 29) + c := color.NRGBA{R: 200, G: 90, B: 30, A: 255} + bg, _ := premultipliedLinear([]color.Color{c}) + closeYCbCr(t, buf.ToYCbCr(c), refYCbCr(buf, bg), "orange background") +} + +func TestToYCbCrOfAnOutOfRangeBufferStaysInRange(t *testing.T) { + buf := MustNewBuffer(3, 3) + buf.Set(0, 0, 5, -2, float32(math.NaN()), 1) + buf.Set(1, 1, float32(math.Inf(1)), float32(math.Inf(-1)), 0.5, float32(math.NaN())) + out := buf.ToYCbCr(color.White) + if len(out.Y) == 0 { + t.Fatal("no luma") + } +} + +func TestToYCbCrDoesNotDependOnParallelism(t *testing.T) { + buf := ramp(211, 997) + defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(1)) + one := buf.ToYCbCr(color.White) + for _, procs := range []int{2, 5, 16} { + runtime.GOMAXPROCS(procs) + sameYCbCr(t, buf.ToYCbCr(color.White), one, "GOMAXPROCS") + } +} + +func TestToYCbCrEncodesAsAJPEG(t *testing.T) { + buf := ramp(100, 70) + var out bytes.Buffer + if err := jpeg.Encode(&out, buf.ToYCbCr(color.White), &jpeg.Options{Quality: 95}); err != nil { + t.Fatal(err) + } + dec, err := jpeg.Decode(&out) + if err != nil { + t.Fatal(err) + } + if dec.Bounds() != image.Rect(0, 0, 100, 70) { + t.Errorf("decoded %v", dec.Bounds()) + } +} + +func TestToImageDoesNotDependOnParallelism(t *testing.T) { + buf := ramp(123, 1001) + defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(1)) + one8, one16 := buf.ToImage(8).(*image.NRGBA), buf.ToImage(16).(*image.NRGBA64) + for _, procs := range []int{2, 7, 16} { + runtime.GOMAXPROCS(procs) + if !bytes.Equal(buf.ToImage(8).(*image.NRGBA).Pix, one8.Pix) { + t.Fatalf("8-bit ToImage differs at GOMAXPROCS %d", procs) + } + if !bytes.Equal(buf.ToImage(16).(*image.NRGBA64).Pix, one16.Pix) { + t.Fatalf("16-bit ToImage differs at GOMAXPROCS %d", procs) + } + } +} + +func BenchmarkToYCbCr(b *testing.B) { + buf := benchBuffer(b) + b.SetBytes(int64(buf.Width * buf.Height * 4)) + for b.Loop() { + buf.ToYCbCr() + } +} + +// The table is the exact encoding of each quantized value, so its only error is +// the quantization itself +func TestSRGBCodeTableIsExactAtItsEntries(t *testing.T) { + for i := range srgbCodes { + if want := refCode(float32(i) / 65535); int32(srgbCodes[i]) != want { + t.Fatalf("entry %d = %d, want %d", i, srgbCodes[i], want) + } + } + if srgbCodes[0] != 0 || srgbCodes[65535] != 255 { + t.Errorf("ends are %d and %d, want 0 and 255", srgbCodes[0], srgbCodes[65535]) + } +} + +func TestCode8NeverLeavesItsRangeAndMapsNonNumbersToZero(t *testing.T) { + for _, v := range []float32{float32(math.NaN()), float32(math.Inf(-1)), -3, 0} { + if got := code8(v); got != 0 { + t.Errorf("code8(%v) = %d, want 0", v, got) + } + } + for _, v := range []float32{1, 1.5, float32(math.Inf(1))} { + if got := code8(v); got != 255 { + t.Errorf("code8(%v) = %d, want 255", v, got) + } + } +}