From 08404024e5e93542a1e33dd3b3b164373bd8c925 Mon Sep 17 00:00:00 2001 From: odevine Date: Wed, 7 Oct 2026 15:38:15 -0400 Subject: [PATCH 1/5] test(canvas): add image-backed render throughput benchmarks Add BenchmarkRenderImages and BenchmarkRenderImagesParallel, which render a print-size scene of eight 8-bit image layers (opaque panels, hollow frames and small strips in Normal, Multiply and Overlay modes) and report renders per second and allocations. They give the layer ingest and buffer reuse work a baseline to measure against. Rename the layered memory benchmarks and their scene to describe the scene rather than a card. --- canvas/memory_bench_test.go | 38 +++++----- canvas/throughput_bench_test.go | 125 ++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 19 deletions(-) create mode 100644 canvas/throughput_bench_test.go 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..9551dfd --- /dev/null +++ b/canvas/throughput_bench_test.go @@ -0,0 +1,125 @@ +package canvas + +import ( + "image" + "image/color" + "sync" + "testing" + + "github.com/odevine/impasto/blend" + "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 + imageSceneDoc *Document +) + +// hollow is an opaque ring of the given thickness filling r, transparent inside +// and out +func hollow(r image.Rectangle, thickness int) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, r.Dx(), r.Dy())) + for y := 0; y < r.Dy(); y++ { + for x := 0; x < r.Dx(); x++ { + if x < thickness || y < thickness || x >= r.Dx()-thickness || y >= r.Dy()-thickness { + setPattern(img, x, y, 255) + } + } + } + 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++ { + setPattern(img, x, y, alpha) + } + } + return img +} + +// setPattern writes a deterministic color that varies across the image +func setPattern(img *image.NRGBA, x, y int, alpha uint8) { + img.SetNRGBA(x, y, color.NRGBA{R: uint8(x * 7), G: uint8(y * 3), B: uint8(x + y), A: alpha}) +} + +// imageLayer places img at (x, y) and builds its content from the image each time +// it is composited, the way a layer backed by a decoded file does +func imageLayer(img *image.NRGBA, x, y int, mode blend.Mode, opacity float32) *Layer { + 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 +func imageScene() *Document { + imageSceneOnce.Do(func() { + w, h := sceneW, sceneH + layers := []Node{ + imageLayer(solid(w*7/10, h*7/10, 255), w*15/100, h*15/100, blend.Normal, 1), + imageLayer(hollow(image.Rect(0, 0, w, h*85/100), w/30), 0, 0, blend.Normal, 1), + imageLayer(solid(w*81/100, h*54/100, 200), w*9/100, h*8/100, blend.Normal, 1), + imageLayer(hollow(image.Rect(0, 0, w*82/100, h*83/100), w/40), w*9/100, h*8/100, blend.Multiply, 1), + imageLayer(hollow(image.Rect(0, 0, w*88/100, h*15/100), w/60), w*6/100, h*5/100, blend.Normal, 1), + imageLayer(solid(w*17/100, h*7/100, 255), w*75/100, h*87/100, blend.Normal, 1), + imageLayer(solid(w*77/100, h/130, 220), w*11/100, h*60/100, blend.Normal, 1), + imageLayer(solid(w, h, 90), 0, 0, blend.Overlay, 0.5), + } + imageSceneDoc = &Document{Width: w, Height: h, Root: Group{PassThrough: true, Layers: layers}} + }) + return imageSceneDoc +} + +// 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") +} + +// BenchmarkRenderImages renders the image-backed scene with one goroutine +// issuing renders, so it measures one document's cost end to end +func BenchmarkRenderImages(b *testing.B) { + if testing.Short() { + b.Skip("builds print-size images") + } + d := imageScene() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + MustRender(d) + } + benchRenders(b) +} + +// BenchmarkRenderImagesParallel 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 BenchmarkRenderImagesParallel(b *testing.B) { + if testing.Short() { + b.Skip("builds print-size images and holds one document per core") + } + d := imageScene() + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + MustRender(d) + } + }) + benchRenders(b) +} From 88daeaac4a0d036123dca0a67ed16e5c4190f544 Mon Sep 17 00:00:00 2001 From: odevine Date: Wed, 7 Oct 2026 15:56:58 -0400 Subject: [PATCH 2/5] perf(canvas): blend 8-bit image layers without a float buffer Add Layer.Image and Layer.LoadImage, which take an 8-bit image as a layer's content. An *image.NRGBA layer with no mask, effects or clip-to-below is blended straight into the document by the new blend.CompositeNRGBA, which reads each pixel through the sRGB table and skips transparent pixels, and in Normal mode writes opaque pixels without a multiply. Other image types, and layers that write to their content, are converted first. The result is bit-identical to converting the image with raster.FromImage and compositing the buffer, for every mode and opacity. Canvas never writes to an image, so one decoded image can be shared across layers and concurrent renders. On a print-size scene of eight image layers, one render goes from 7.0 to 29.4 per second and sixteen at once from 24.1 to 41.6, and allocation per render falls from 1.07 GB to 232 MB. --- README.md | 2 +- blend/image.go | 104 +++++++++++++ blend/image_test.go | 147 +++++++++++++++++++ canvas/canvas.go | 36 ++++- canvas/image_test.go | 249 ++++++++++++++++++++++++++++++++ canvas/throughput_bench_test.go | 83 +++++++---- docs/blend.md | 20 +++ docs/canvas.md | 42 +++++- 8 files changed, 653 insertions(+), 30 deletions(-) create mode 100644 blend/image.go create mode 100644 blend/image_test.go create mode 100644 canvas/image_test.go 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/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..a8b6a27 --- /dev/null +++ b/blend/image_test.go @@ -0,0 +1,147 @@ +package blend + +import ( + "image" + "image/color" + "math" + "testing" + + "github.com/odevine/impasto/raster" +) + +// patterned is an image whose alpha takes the values the fast paths branch on, +// transparent, opaque 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, color.NRGBA{ + R: uint8(x*37 + y*5), + G: uint8(y*23 + x), + B: uint8(x*y + 9), + A: alphas[(x*3+y*7)%len(alphas)], + }) + } + } + return img +} + +// 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() + for i := range want.Pix { + if math.Float32bits(got.Pix[i]) != math.Float32bits(want.Pix[i]) { + t.Fatalf("%s: value %d (pixel %d, channel %d) = %v, want %v", what, i, i/4, i%4, got.Pix[i], want.Pix[i]) + } + } +} + +func TestCompositeNRGBAMatchesBuffer(t *testing.T) { + const w, h = 48, 36 + src := 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 := 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 := 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 := 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 := 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 := 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 := 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/canvas/canvas.go b/canvas/canvas.go index 6a0d285..a3dd9b4 100644 --- a/canvas/canvas.go +++ b/canvas/canvas.go @@ -44,11 +44,22 @@ 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. 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 +189,29 @@ 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 + if n, ok := img.(*image.NRGBA); ok && l.Mask == nil && len(l.Effects) == 0 && !needBase && !(l.ClipToBelow && base != nil) { + 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/image_test.go b/canvas/image_test.go new file mode 100644 index 0000000..7adf751 --- /dev/null +++ b/canvas/image_test.go @@ -0,0 +1,249 @@ +package canvas + +import ( + "errors" + "image" + "image/color" + "math" + "runtime" + "strings" + "testing" + + "github.com/odevine/impasto/blend" + "github.com/odevine/impasto/effects" + "github.com/odevine/impasto/mask" + "github.com/odevine/impasto/raster" +) + +const imgDocW, imgDocH = 64, 48 + +// imagePattern is an image with transparent, opaque and partial pixels +func imagePattern(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, color.NRGBA{R: uint8(x*37 + y*5), G: uint8(y*23 + x), B: uint8(x*y + 9), A: alphas[(x*3+y*7)%len(alphas)]}) + } + } + return img +} + +// 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() + for i := range want.Pix { + if math.Float32bits(got.Pix[i]) != math.Float32bits(want.Pix[i]) { + t.Fatalf("%s: value %d (pixel %d, channel %d) = %v, want %v", what, i, i/4, i%4, got.Pix[i], want.Pix[i]) + } + } +} + +// 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 := imagePattern(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 := imagePattern(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 := imagePattern(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 := imagePattern(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 imagePattern(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 := imagePattern(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 := imagePattern(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 := imagePattern(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") + } +} diff --git a/canvas/throughput_bench_test.go b/canvas/throughput_bench_test.go index 9551dfd..c7ebce7 100644 --- a/canvas/throughput_bench_test.go +++ b/canvas/throughput_bench_test.go @@ -16,8 +16,8 @@ import ( // cache would share them var ( - imageSceneOnce sync.Once - imageSceneDoc *Document + imageSceneOnce sync.Once + imageSceneImages []sceneImage ) // hollow is an opaque ring of the given thickness filling r, transparent inside @@ -50,9 +50,13 @@ func setPattern(img *image.NRGBA, x, y int, alpha uint8) { img.SetNRGBA(x, y, color.NRGBA{R: uint8(x * 7), G: uint8(y * 3), B: uint8(x + y), A: alpha}) } -// imageLayer places img at (x, y) and builds its content from the image each time -// it is composited, the way a layer backed by a decoded file does -func imageLayer(img *image.NRGBA, x, y int, mode blend.Mode, opacity float32) *Layer { +// imageLayer places img at (x, y). Direct hands the image to the layer, and +// otherwise the layer converts it to a buffer each time it is composited, the +// way a layer backed by a decoded file had to before images were accepted +func imageLayer(img *image.NRGBA, x, y int, mode blend.Mode, opacity float32, direct bool) *Layer { + if direct { + return &Layer{Mode: mode, Opacity: opacity, Image: img, Origin: image.Pt(x, y)} + } return &Layer{ Mode: mode, Opacity: opacity, @@ -65,23 +69,35 @@ func imageLayer(img *image.NRGBA, x, y int, mode blend.Mode, opacity float32) *L // 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 -func imageScene() *Document { +// Normal, Multiply and Overlay modes. The images are shared by both forms of it +func imageScene(direct bool) *Document { imageSceneOnce.Do(func() { w, h := sceneW, sceneH - layers := []Node{ - imageLayer(solid(w*7/10, h*7/10, 255), w*15/100, h*15/100, blend.Normal, 1), - imageLayer(hollow(image.Rect(0, 0, w, h*85/100), w/30), 0, 0, blend.Normal, 1), - imageLayer(solid(w*81/100, h*54/100, 200), w*9/100, h*8/100, blend.Normal, 1), - imageLayer(hollow(image.Rect(0, 0, w*82/100, h*83/100), w/40), w*9/100, h*8/100, blend.Multiply, 1), - imageLayer(hollow(image.Rect(0, 0, w*88/100, h*15/100), w/60), w*6/100, h*5/100, blend.Normal, 1), - imageLayer(solid(w*17/100, h*7/100, 255), w*75/100, h*87/100, blend.Normal, 1), - imageLayer(solid(w*77/100, h/130, 220), w*11/100, h*60/100, blend.Normal, 1), - imageLayer(solid(w, h, 90), 0, 0, blend.Overlay, 0.5), + imageSceneImages = []sceneImage{ + {solid(w*7/10, h*7/10, 255), w * 15 / 100, h * 15 / 100, blend.Normal, 1}, + {hollow(image.Rect(0, 0, w, h*85/100), w/30), 0, 0, blend.Normal, 1}, + {solid(w*81/100, h*54/100, 200), w * 9 / 100, h * 8 / 100, blend.Normal, 1}, + {hollow(image.Rect(0, 0, w*82/100, h*83/100), w/40), w * 9 / 100, h * 8 / 100, blend.Multiply, 1}, + {hollow(image.Rect(0, 0, w*88/100, h*15/100), w/60), w * 6 / 100, h * 5 / 100, blend.Normal, 1}, + {solid(w*17/100, h*7/100, 255), w * 75 / 100, h * 87 / 100, blend.Normal, 1}, + {solid(w*77/100, h/130, 220), w * 11 / 100, h * 60 / 100, blend.Normal, 1}, + {solid(w, h, 90), 0, 0, blend.Overlay, 0.5}, } - imageSceneDoc = &Document{Width: w, Height: h, Root: Group{PassThrough: true, Layers: layers}} }) - return imageSceneDoc + var layers []Node + for _, si := range imageSceneImages { + layers = append(layers, imageLayer(si.img, si.x, si.y, si.mode, si.opacity, direct)) + } + 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 } // benchRenders reports how many documents a render loop finishes per second @@ -90,13 +106,13 @@ func benchRenders(b *testing.B) { b.ReportMetric(float64(b.N)/b.Elapsed().Seconds(), "renders/s") } -// BenchmarkRenderImages renders the image-backed scene with one goroutine -// issuing renders, so it measures one document's cost end to end -func BenchmarkRenderImages(b *testing.B) { +// renderImages renders the image scene from one goroutine, which measures one +// document's cost end to end +func renderImages(b *testing.B, direct bool) { if testing.Short() { b.Skip("builds print-size images") } - d := imageScene() + d := imageScene(direct) b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { @@ -105,15 +121,15 @@ func BenchmarkRenderImages(b *testing.B) { benchRenders(b) } -// BenchmarkRenderImagesParallel renders the same scene from GOMAXPROCS goroutines -// at once, which is how a batch uses the library, so it measures throughput when +// 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 BenchmarkRenderImagesParallel(b *testing.B) { +func renderImagesParallel(b *testing.B, direct bool) { if testing.Short() { b.Skip("builds print-size images and holds one document per core") } - d := imageScene() + d := imageScene(direct) b.ReportAllocs() b.ResetTimer() b.RunParallel(func(pb *testing.PB) { @@ -123,3 +139,18 @@ func BenchmarkRenderImagesParallel(b *testing.B) { }) benchRenders(b) } + +// BenchmarkRenderImages is the image scene with every layer converted to a +// buffer through Load +func BenchmarkRenderImages(b *testing.B) { renderImages(b, false) } + +// BenchmarkRenderImagesParallel is BenchmarkRenderImages from every core at once +func BenchmarkRenderImagesParallel(b *testing.B) { renderImagesParallel(b, false) } + +// BenchmarkRenderImagesDirect is the image scene with every layer given its +// image, which is blended without a buffer +func BenchmarkRenderImagesDirect(b *testing.B) { renderImages(b, true) } + +// BenchmarkRenderImagesDirectParallel is BenchmarkRenderImagesDirect from every +// core at once +func BenchmarkRenderImagesDirectParallel(b *testing.B) { renderImagesParallel(b, true) } diff --git a/docs/blend.md b/docs/blend.md index e1ee5c8..dd3302e 100644 --- a/docs/blend.md +++ b/docs/blend.md @@ -112,6 +112,26 @@ 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. + ## 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..e2a7a27 100644 --- a/docs/canvas.md +++ b/docs/canvas.md @@ -156,6 +156,41 @@ 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. + +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 +272,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 +349,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). From b9a8ae042ac5bd61d6fc8501c28a5a7ec97254ac Mon Sep 17 00:00:00 2001 From: odevine Date: Wed, 7 Oct 2026 16:11:20 -0400 Subject: [PATCH 3/5] feat(raster): add ToYCbCr for JPEG output and convert in parallel Add Buffer.ToYCbCr, which encodes a buffer as the 8-bit full-range BT.601 planes a JPEG stores, with chroma averaged over 2x2 blocks, so image/jpeg can write it without converting again. Alpha is dropped by flattening over an optional background color, black when it is missing. The conversion reads the sRGB code from a 64 KiB table, which is several times faster than the transfer function and can differ by one code from the exact encoding near a rounding boundary. It is not dithered. Convert ToImage's 8-bit and 16-bit output in parallel row bands, and add internal/parallel.RowsAligned for bands that keep 2x2 blocks whole. Output does not depend on GOMAXPROCS. On a 3264x4440 buffer, ToYCbCr takes 0.9 ms on 16 cores, and ToImage(8) goes from 34.7 ms on one core to 4.2 ms on 16. --- docs/raster.md | 30 +++- internal/parallel/parallel.go | 14 ++ internal/parallel/parallel_test.go | 35 +++++ raster/image.go | 63 ++++---- raster/ycbcr.go | 118 ++++++++++++++ raster/ycbcr_test.go | 239 +++++++++++++++++++++++++++++ 6 files changed, 471 insertions(+), 28 deletions(-) create mode 100644 internal/parallel/parallel_test.go create mode 100644 raster/ycbcr.go create mode 100644 raster/ycbcr_test.go 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/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..b1d29fb --- /dev/null +++ b/raster/ycbcr_test.go @@ -0,0 +1,239 @@ +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") + // 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) + } + } +} From adc206351329290139d0e85934862c893b560280 Mon Sep 17 00:00:00 2001 From: odevine Date: Wed, 7 Oct 2026 16:18:05 -0400 Subject: [PATCH 4/5] perf(blend): index an image's visible runs to skip transparent pixels Add blend.Index, which reads an *image.NRGBA once and records the runs of columns with any alpha in each row, merging runs less than 32 transparent pixels apart, and blend.CompositeIndexed, which blends only those runs. The result is bit-identical to CompositeNRGBA, since a pixel with no alpha leaves the backdrop alone in every mode. Indexed embeds the image, so a canvas layer takes one through Image or LoadImage unchanged and blends it directly. A layer that needs its content written to converts it as a plain image. On a 2048 by 2048 frame with a 60 pixel border, blending takes 0.94 ms against 3.26 ms. Building the index for a 3264 by 4440 image takes 7.5 ms once. --- blend/indexed.go | 107 +++++++++++++++++++ blend/indexed_test.go | 178 ++++++++++++++++++++++++++++++++ canvas/canvas.go | 19 +++- canvas/image_test.go | 66 ++++++++++++ canvas/throughput_bench_test.go | 76 +++++++++----- docs/blend.md | 28 +++++ docs/canvas.md | 6 ++ 7 files changed, 451 insertions(+), 29 deletions(-) create mode 100644 blend/indexed.go create mode 100644 blend/indexed_test.go diff --git a/blend/indexed.go b/blend/indexed.go new file mode 100644 index 0000000..15ff741 --- /dev/null +++ b/blend/indexed.go @@ -0,0 +1,107 @@ +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 +} + +// Runs reports how many runs of visible pixels the index holds, summed over +// every row +func (ix *Indexed) Runs() int { return len(ix.runs) / 2 } + +// 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..8831487 --- /dev/null +++ b/blend/indexed_test.go @@ -0,0 +1,178 @@ +package blend + +import ( + "image" + "image/color" + "slices" + "testing" + + "github.com/odevine/impasto/raster" +) + +// sparse is an image whose rows hold stretches of visible pixels separated by +// gaps that are shorter than, equal to and longer than the merge distance, plus +// rows that are empty and rows that are full +func sparse(w, h int) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + set := func(x, y int, a uint8) { + img.SetNRGBA(x, y, color.NRGBA{R: uint8(x*13 + y), G: uint8(y * 7), B: uint8(x + 40), A: 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 of minGap-1 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 ring: 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 +} + +func TestIndexListsTheVisibleRuns(t *testing.T) { + img := sparse(120, 12) + 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) + } + } + if ix.Runs() == 0 { + t.Error("Runs is zero") + } +} + +func TestIndexReadsASubImageAtItsOwnBounds(t *testing.T) { + full := sparse(120, 12) + 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 := sparse(131, 29) + 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 := sparse(120, 24) + 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 ix.Runs() != 0 { + t.Fatalf("Runs = %d for a transparent image", ix.Runs()) + } + want, got := backdrop(60, 60), backdrop(60, 60) + CompositeIndexed(got, ix, image.Pt(3, 3), Normal, 1) + sameBits(t, got, want, "transparent image") +} + +func TestCompositeIndexedIgnoresNonPositiveOpacity(t *testing.T) { + ix := Index(sparse(60, 30)) + want, got := backdrop(70, 40), backdrop(70, 40) + CompositeIndexed(got, ix, image.Point{}, Normal, 0) + sameBits(t, got, want, "opacity 0") +} + +// hollowImage is a frame of the given thickness, transparent inside +func hollowImage(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, color.NRGBA{R: uint8(x), G: uint8(y), B: 90, A: 255}) + } + } + } + return img +} + +func BenchmarkCompositeHollowFrame(b *testing.B) { + frame := hollowImage(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 := hollowImage(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 a3dd9b4..d59c7fe 100644 --- a/canvas/canvas.go +++ b/canvas/canvas.go @@ -50,10 +50,11 @@ type Node interface { // 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. 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 +// 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 @@ -201,7 +202,15 @@ func renderLayer(l *Layer, backdrop *raster.Buffer, base *coverage, needBase boo // 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 - if n, ok := img.(*image.NRGBA); ok && l.Mask == nil && len(l.Effects) == 0 && !needBase && !(l.ClipToBelow && base != nil) { + 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 } diff --git a/canvas/image_test.go b/canvas/image_test.go index 7adf751..2299d77 100644 --- a/canvas/image_test.go +++ b/canvas/image_test.go @@ -247,3 +247,69 @@ func TestImageLayerIsIndependentOfParallelism(t *testing.T) { equalBits(t, render(procs), one, "GOMAXPROCS") } } + +// sparsePattern is an image with wide transparent stretches in its rows, so an +// index has runs to skip +func sparsePattern(w, h 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 y%5 == 0 || x < 4 || x >= w-4 || (x > w/2-3 && x < w/2+3) { + img.SetNRGBA(x, y, color.NRGBA{R: uint8(x * 9), G: uint8(y * 5), B: 77, A: uint8(40 + (x+y)%200)}) + } + } + } + return img +} + +func TestIndexedLayerRendersLikeItsBuffer(t *testing.T) { + const big = 200 + img := sparsePattern(190, 150) + 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 := sparsePattern(60, 40) + 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) + } +} diff --git a/canvas/throughput_bench_test.go b/canvas/throughput_bench_test.go index c7ebce7..7736ec8 100644 --- a/canvas/throughput_bench_test.go +++ b/canvas/throughput_bench_test.go @@ -50,13 +50,29 @@ func setPattern(img *image.NRGBA, x, y int, alpha uint8) { img.SetNRGBA(x, y, color.NRGBA{R: uint8(x * 7), G: uint8(y * 3), B: uint8(x + y), A: alpha}) } -// imageLayer places img at (x, y). Direct hands the image to the layer, and -// otherwise the layer converts it to a buffer each time it is composited, the -// way a layer backed by a decoded file had to before images were accepted -func imageLayer(img *image.NRGBA, x, y int, mode blend.Mode, opacity float32, direct bool) *Layer { - if direct { - return &Layer{Mode: mode, Opacity: opacity, Image: img, Origin: image.Pt(x, y)} +// 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, @@ -70,23 +86,26 @@ func imageLayer(img *image.NRGBA, x, y int, mode blend.Mode, opacity float32, di // 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(direct bool) *Document { +func imageScene(form imageForm) *Document { imageSceneOnce.Do(func() { w, h := sceneW, sceneH imageSceneImages = []sceneImage{ - {solid(w*7/10, h*7/10, 255), w * 15 / 100, h * 15 / 100, blend.Normal, 1}, - {hollow(image.Rect(0, 0, w, h*85/100), w/30), 0, 0, blend.Normal, 1}, - {solid(w*81/100, h*54/100, 200), w * 9 / 100, h * 8 / 100, blend.Normal, 1}, - {hollow(image.Rect(0, 0, w*82/100, h*83/100), w/40), w * 9 / 100, h * 8 / 100, blend.Multiply, 1}, - {hollow(image.Rect(0, 0, w*88/100, h*15/100), w/60), w * 6 / 100, h * 5 / 100, blend.Normal, 1}, - {solid(w*17/100, h*7/100, 255), w * 75 / 100, h * 87 / 100, blend.Normal, 1}, - {solid(w*77/100, h/130, 220), w * 11 / 100, h * 60 / 100, blend.Normal, 1}, - {solid(w, h, 90), 0, 0, blend.Overlay, 0.5}, + {solid(w*7/10, h*7/10, 255), w * 15 / 100, h * 15 / 100, blend.Normal, 1, nil}, + {hollow(image.Rect(0, 0, w, h*85/100), w/30), 0, 0, blend.Normal, 1, nil}, + {solid(w*81/100, h*54/100, 200), w * 9 / 100, h * 8 / 100, blend.Normal, 1, nil}, + {hollow(image.Rect(0, 0, w*82/100, h*83/100), w/40), w * 9 / 100, h * 8 / 100, blend.Multiply, 1, nil}, + {hollow(image.Rect(0, 0, w*88/100, h*15/100), w/60), w * 6 / 100, h * 5 / 100, blend.Normal, 1, nil}, + {solid(w*17/100, h*7/100, 255), w * 75 / 100, h * 87 / 100, blend.Normal, 1, nil}, + {solid(w*77/100, h/130, 220), w * 11 / 100, h * 60 / 100, blend.Normal, 1, nil}, + {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.img, si.x, si.y, si.mode, si.opacity, direct)) + layers = append(layers, imageLayer(si, form)) } return &Document{Width: sceneW, Height: sceneH, Root: Group{PassThrough: true, Layers: layers}} } @@ -98,6 +117,7 @@ type sceneImage struct { x, y int mode blend.Mode opacity float32 + indexed *blend.Indexed } // benchRenders reports how many documents a render loop finishes per second @@ -108,11 +128,11 @@ func benchRenders(b *testing.B) { // renderImages renders the image scene from one goroutine, which measures one // document's cost end to end -func renderImages(b *testing.B, direct bool) { +func renderImages(b *testing.B, form imageForm) { if testing.Short() { b.Skip("builds print-size images") } - d := imageScene(direct) + d := imageScene(form) b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { @@ -125,11 +145,11 @@ func renderImages(b *testing.B, direct bool) { // 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, direct bool) { +func renderImagesParallel(b *testing.B, form imageForm) { if testing.Short() { b.Skip("builds print-size images and holds one document per core") } - d := imageScene(direct) + d := imageScene(form) b.ReportAllocs() b.ResetTimer() b.RunParallel(func(pb *testing.PB) { @@ -142,15 +162,23 @@ func renderImagesParallel(b *testing.B, direct bool) { // BenchmarkRenderImages is the image scene with every layer converted to a // buffer through Load -func BenchmarkRenderImages(b *testing.B) { renderImages(b, false) } +func BenchmarkRenderImages(b *testing.B) { renderImages(b, formBuffer) } // BenchmarkRenderImagesParallel is BenchmarkRenderImages from every core at once -func BenchmarkRenderImagesParallel(b *testing.B) { renderImagesParallel(b, false) } +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, true) } +func BenchmarkRenderImagesDirect(b *testing.B) { renderImages(b, formImage) } // BenchmarkRenderImagesDirectParallel is BenchmarkRenderImagesDirect from every // core at once -func BenchmarkRenderImagesDirectParallel(b *testing.B) { renderImagesParallel(b, true) } +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 dd3302e..84e6e75 100644 --- a/docs/blend.md +++ b/docs/blend.md @@ -132,6 +132,34 @@ blend the rest through `BlendPixel`. 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 e2a7a27..73aabe4 100644 --- a/docs/canvas.md +++ b/docs/canvas.md @@ -185,6 +185,12 @@ almost nothing. The pixels are the same, bit for bit, as converting the image wi 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 From 74b2f64e245bb6eed39d6567e1d0f4f3c47208ce Mon Sep 17 00:00:00 2001 From: odevine Date: Wed, 7 Oct 2026 16:31:09 -0400 Subject: [PATCH 5/5] test: share image helpers and cover the new image paths Move the image generators and the exact float comparison that the blend, canvas and raster tests each wrote their own copy of into internal/testimg. Cover the branches the image paths left open (opacity above one for an indexed image, an image too wide to convert, a transparent background for ToYCbCr, every sRGB code in blend's table), add tests for the raster buffer and ingest functions that had none, and add godoc examples for Index, CompositeNRGBA, ToYCbCr and Layer.Image. Drop Indexed.Runs, which nothing used. --- blend/example_test.go | 51 +++++++++++ blend/image_test.go | 41 ++------- blend/indexed.go | 4 - blend/indexed_test.go | 109 +++++++++------------- canvas/example_test.go | 27 ++++++ canvas/image_test.go | 69 ++++++-------- canvas/throughput_bench_test.go | 48 ++-------- internal/testimg/testimg.go | 111 ++++++++++++++++++++++ raster/buffer_test.go | 158 ++++++++++++++++++++++++++++++++ raster/example_test.go | 16 ++++ raster/ycbcr_test.go | 1 + 11 files changed, 452 insertions(+), 183 deletions(-) create mode 100644 internal/testimg/testimg.go create mode 100644 raster/buffer_test.go 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_test.go b/blend/image_test.go index a8b6a27..5c0609d 100644 --- a/blend/image_test.go +++ b/blend/image_test.go @@ -2,31 +2,12 @@ package blend import ( "image" - "image/color" - "math" "testing" + "github.com/odevine/impasto/internal/testimg" "github.com/odevine/impasto/raster" ) -// patterned is an image whose alpha takes the values the fast paths branch on, -// transparent, opaque 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, color.NRGBA{ - R: uint8(x*37 + y*5), - G: uint8(y*23 + x), - B: uint8(x*y + 9), - A: alphas[(x*3+y*7)%len(alphas)], - }) - } - } - return img -} - // 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 { @@ -42,16 +23,12 @@ func backdrop(w, h int) *raster.Buffer { func sameBits(t *testing.T, got, want *raster.Buffer, what string) { t.Helper() - for i := range want.Pix { - if math.Float32bits(got.Pix[i]) != math.Float32bits(want.Pix[i]) { - t.Fatalf("%s: value %d (pixel %d, channel %d) = %v, want %v", what, i, i/4, i%4, got.Pix[i], want.Pix[i]) - } - } + testimg.EqualBits(t, got.Pix, want.Pix, what) } func TestCompositeNRGBAMatchesBuffer(t *testing.T) { const w, h = 48, 36 - src := patterned(31, 23) + src := testimg.Patterned(31, 23) buf, err := raster.FromImage(src) if err != nil { t.Fatal(err) @@ -70,7 +47,7 @@ func TestCompositeNRGBAMatchesBuffer(t *testing.T) { } func TestCompositeNRGBAReadsASubImageAtItsOwnBounds(t *testing.T) { - full := patterned(40, 30) + full := testimg.Patterned(40, 30) sub := full.SubImage(image.Rect(7, 5, 29, 24)).(*image.NRGBA) buf, err := raster.FromImage(sub) if err != nil { @@ -85,7 +62,7 @@ func TestCompositeNRGBAReadsASubImageAtItsOwnBounds(t *testing.T) { } func TestCompositeNRGBATouchesOnlyItsRect(t *testing.T) { - src := patterned(10, 8) + 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++ { @@ -103,7 +80,7 @@ func TestCompositeNRGBATouchesOnlyItsRect(t *testing.T) { } func TestCompositeNRGBAIgnoresNonPositiveOpacityAndClampsAboveOne(t *testing.T) { - src := patterned(10, 8) + src := testimg.Patterned(10, 8) before := backdrop(20, 20) for _, o := range []float32{0, -1} { got := backdrop(20, 20) @@ -117,7 +94,7 @@ func TestCompositeNRGBAIgnoresNonPositiveOpacityAndClampsAboveOne(t *testing.T) } func TestCompositeNRGBANeverWritesToItsSource(t *testing.T) { - src := patterned(16, 16) + 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 { @@ -128,7 +105,7 @@ func TestCompositeNRGBANeverWritesToItsSource(t *testing.T) { } func BenchmarkCompositeNRGBA(b *testing.B) { - src := patterned(2048, 2048) + src := testimg.Patterned(2048, 2048) dst := raster.MustNewBuffer(2048, 2048) b.SetBytes(2048 * 2048 * 4) for i := 0; i < b.N; i++ { @@ -137,7 +114,7 @@ func BenchmarkCompositeNRGBA(b *testing.B) { } func BenchmarkCompositeRectFromImage(b *testing.B) { - src := patterned(2048, 2048) + src := testimg.Patterned(2048, 2048) dst := raster.MustNewBuffer(2048, 2048) b.SetBytes(2048 * 2048 * 4) for i := 0; i < b.N; i++ { diff --git a/blend/indexed.go b/blend/indexed.go index 15ff741..6545561 100644 --- a/blend/indexed.go +++ b/blend/indexed.go @@ -61,10 +61,6 @@ func Index(img *image.NRGBA) *Indexed { return ix } -// Runs reports how many runs of visible pixels the index holds, summed over -// every row -func (ix *Indexed) Runs() int { return len(ix.runs) / 2 } - // 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 diff --git a/blend/indexed_test.go b/blend/indexed_test.go index 8831487..6389ca1 100644 --- a/blend/indexed_test.go +++ b/blend/indexed_test.go @@ -6,53 +6,12 @@ import ( "slices" "testing" + "github.com/odevine/impasto/internal/testimg" "github.com/odevine/impasto/raster" ) -// sparse is an image whose rows hold stretches of visible pixels separated by -// gaps that are shorter than, equal to and longer than the merge distance, plus -// rows that are empty and rows that are full -func sparse(w, h int) *image.NRGBA { - img := image.NewNRGBA(image.Rect(0, 0, w, h)) - set := func(x, y int, a uint8) { - img.SetNRGBA(x, y, color.NRGBA{R: uint8(x*13 + y), G: uint8(y * 7), B: uint8(x + 40), A: 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 of minGap-1 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 ring: 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 -} - func TestIndexListsTheVisibleRuns(t *testing.T) { - img := sparse(120, 12) + 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{ @@ -69,13 +28,10 @@ func TestIndexListsTheVisibleRuns(t *testing.T) { t.Errorf("row %d runs = %v, want %v", y, got, want) } } - if ix.Runs() == 0 { - t.Error("Runs is zero") - } } func TestIndexReadsASubImageAtItsOwnBounds(t *testing.T) { - full := sparse(120, 12) + 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 { @@ -89,7 +45,7 @@ func TestIndexReadsASubImageAtItsOwnBounds(t *testing.T) { func TestCompositeIndexedMatchesTheBufferPath(t *testing.T) { const w, h = 150, 40 - src := sparse(131, 29) + src := testimg.Sparse(131, 29, minGap) ix := Index(src) buf, err := raster.FromImage(src) if err != nil { @@ -111,7 +67,7 @@ func TestCompositeIndexedMatchesTheBufferPath(t *testing.T) { } func TestCompositeIndexedReadsASubImageWhereItSits(t *testing.T) { - full := sparse(120, 24) + full := testimg.Sparse(120, 24, minGap) sub := full.SubImage(image.Rect(7, 3, 100, 20)).(*image.NRGBA) buf, _ := raster.FromImage(sub) ix := Index(sub) @@ -125,36 +81,57 @@ func TestCompositeIndexedReadsASubImageWhereItSits(t *testing.T) { func TestCompositeIndexedSkipsAnImageWithNothingVisible(t *testing.T) { ix := Index(image.NewNRGBA(image.Rect(0, 0, 50, 50))) - if ix.Runs() != 0 { - t.Fatalf("Runs = %d for a transparent image", ix.Runs()) + 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 TestCompositeIndexedIgnoresNonPositiveOpacity(t *testing.T) { - ix := Index(sparse(60, 30)) +func TestCompositeIndexedIgnoresNonPositiveOpacityAndClampsAboveOne(t *testing.T) { + src := testimg.Sparse(60, 30, minGap) + ix := Index(src) want, got := backdrop(70, 40), backdrop(70, 40) - CompositeIndexed(got, ix, image.Point{}, Normal, 0) - sameBits(t, got, want, "opacity 0") + 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") } -// hollowImage is a frame of the given thickness, transparent inside -func hollowImage(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, color.NRGBA{R: uint8(x), G: uint8(y), B: 90, A: 255}) - } +// 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]) } } - return img } func BenchmarkCompositeHollowFrame(b *testing.B) { - frame := hollowImage(2048, 2048, 60) + frame := testimg.Hollow(2048, 2048, 60) ix := Index(frame) dst := raster.MustNewBuffer(2048, 2048) b.Run("nrgba", func(b *testing.B) { @@ -170,7 +147,7 @@ func BenchmarkCompositeHollowFrame(b *testing.B) { } func BenchmarkIndex(b *testing.B) { - frame := hollowImage(3264, 4440, 100) + frame := testimg.Hollow(3264, 4440, 100) b.SetBytes(int64(len(frame.Pix))) for b.Loop() { Index(frame) 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 index 2299d77..40cae76 100644 --- a/canvas/image_test.go +++ b/canvas/image_test.go @@ -4,31 +4,19 @@ import ( "errors" "image" "image/color" - "math" "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 -// imagePattern is an image with transparent, opaque and partial pixels -func imagePattern(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, color.NRGBA{R: uint8(x*37 + y*5), G: uint8(y*23 + x), B: uint8(x*y + 9), A: alphas[(x*3+y*7)%len(alphas)]}) - } - } - return img -} - // 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)} } @@ -46,11 +34,7 @@ func renderBoth(t *testing.T, mk func(viaImage bool) []Node) (viaBuffer, viaImag func equalBits(t *testing.T, got, want *raster.Buffer, what string) { t.Helper() - for i := range want.Pix { - if math.Float32bits(got.Pix[i]) != math.Float32bits(want.Pix[i]) { - t.Fatalf("%s: value %d (pixel %d, channel %d) = %v, want %v", what, i, i/4, i%4, got.Pix[i], want.Pix[i]) - } - } + testimg.EqualBits(t, got.Pix, want.Pix, what) } // imageLayer is one layer built either from the converted buffer or from the @@ -73,7 +57,7 @@ func imageLayerFor(img image.Image, at image.Point, viaImage bool, set func(*Lay } func TestImageLayerRendersLikeItsBuffer(t *testing.T) { - img := imagePattern(37, 29) + 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}} { @@ -89,7 +73,7 @@ func TestImageLayerRendersLikeItsBuffer(t *testing.T) { // 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 := imagePattern(37, 29) + 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) { @@ -116,7 +100,7 @@ func TestImageLayerFallbacksRenderLikeTheirBuffer(t *testing.T) { } func TestImageLayerOfAnotherTypeRendersLikeItsBuffer(t *testing.T) { - nrgba := imagePattern(20, 16) + nrgba := testimg.Patterned(20, 16) rgba := image.NewRGBA(nrgba.Rect) gray := image.NewGray(nrgba.Rect) for y := 0; y < 16; y++ { @@ -134,7 +118,7 @@ func TestImageLayerOfAnotherTypeRendersLikeItsBuffer(t *testing.T) { } func TestLoadImageIsCalledOnceAndSuppliesItsOrigin(t *testing.T) { - img := imagePattern(20, 16) + img := testimg.Patterned(20, 16) calls := 0 lazy := &Layer{Origin: image.Pt(40, 40), LoadImage: func() (image.Image, image.Point, error) { calls++ @@ -157,7 +141,7 @@ func TestLoadImageErrorStopsTheRender(t *testing.T) { 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 imagePattern(4, 4), image.Point{}, nil }}, + &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 { @@ -182,7 +166,7 @@ func TestEmptyImagesContributeNothing(t *testing.T) { // Content and Load come first, so a layer that sets an image too draws its buffer func TestBufferContentWinsOverAnImage(t *testing.T) { - img := imagePattern(12, 12) + 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 { @@ -202,7 +186,7 @@ func TestBufferContentWinsOverAnImage(t *testing.T) { // An image is never written to, even by a layer that is masked or has effects func TestRenderLeavesImagesAlone(t *testing.T) { - img := imagePattern(30, 24) + img := testimg.Patterned(30, 24) pix := append([]uint8(nil), img.Pix...) for _, set := range []func(*Layer){ nil, @@ -229,7 +213,7 @@ func TestRenderLeavesImagesAlone(t *testing.T) { // Banding must not change a pixel, so the result is the same however many // goroutines share the work func TestImageLayerIsIndependentOfParallelism(t *testing.T) { - img := imagePattern(61, 45) + 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)}} @@ -248,23 +232,9 @@ func TestImageLayerIsIndependentOfParallelism(t *testing.T) { } } -// sparsePattern is an image with wide transparent stretches in its rows, so an -// index has runs to skip -func sparsePattern(w, h 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 y%5 == 0 || x < 4 || x >= w-4 || (x > w/2-3 && x < w/2+3) { - img.SetNRGBA(x, y, color.NRGBA{R: uint8(x * 9), G: uint8(y * 5), B: 77, A: uint8(40 + (x+y)%200)}) - } - } - } - return img -} - func TestIndexedLayerRendersLikeItsBuffer(t *testing.T) { const big = 200 - img := sparsePattern(190, 150) + 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}} { @@ -289,7 +259,7 @@ func TestIndexedLayerRendersLikeItsBuffer(t *testing.T) { // 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 := sparsePattern(60, 40) + 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 }) }, @@ -313,3 +283,18 @@ func TestIndexedLayerFallbacksRenderLikeTheirBuffer(t *testing.T) { 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/throughput_bench_test.go b/canvas/throughput_bench_test.go index 7736ec8..0068468 100644 --- a/canvas/throughput_bench_test.go +++ b/canvas/throughput_bench_test.go @@ -2,11 +2,11 @@ package canvas import ( "image" - "image/color" "sync" "testing" "github.com/odevine/impasto/blend" + "github.com/odevine/impasto/internal/testimg" "github.com/odevine/impasto/raster" ) @@ -20,36 +20,6 @@ var ( imageSceneImages []sceneImage ) -// hollow is an opaque ring of the given thickness filling r, transparent inside -// and out -func hollow(r image.Rectangle, thickness int) *image.NRGBA { - img := image.NewNRGBA(image.Rect(0, 0, r.Dx(), r.Dy())) - for y := 0; y < r.Dy(); y++ { - for x := 0; x < r.Dx(); x++ { - if x < thickness || y < thickness || x >= r.Dx()-thickness || y >= r.Dy()-thickness { - setPattern(img, x, y, 255) - } - } - } - 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++ { - setPattern(img, x, y, alpha) - } - } - return img -} - -// setPattern writes a deterministic color that varies across the image -func setPattern(img *image.NRGBA, x, y int, alpha uint8) { - img.SetNRGBA(x, y, color.NRGBA{R: uint8(x * 7), G: uint8(y * 3), B: uint8(x + y), A: alpha}) -} - // imageForm is how the image scene gives a layer its pixels type imageForm int @@ -90,14 +60,14 @@ func imageScene(form imageForm) *Document { imageSceneOnce.Do(func() { w, h := sceneW, sceneH imageSceneImages = []sceneImage{ - {solid(w*7/10, h*7/10, 255), w * 15 / 100, h * 15 / 100, blend.Normal, 1, nil}, - {hollow(image.Rect(0, 0, w, h*85/100), w/30), 0, 0, blend.Normal, 1, nil}, - {solid(w*81/100, h*54/100, 200), w * 9 / 100, h * 8 / 100, blend.Normal, 1, nil}, - {hollow(image.Rect(0, 0, w*82/100, h*83/100), w/40), w * 9 / 100, h * 8 / 100, blend.Multiply, 1, nil}, - {hollow(image.Rect(0, 0, w*88/100, h*15/100), w/60), w * 6 / 100, h * 5 / 100, blend.Normal, 1, nil}, - {solid(w*17/100, h*7/100, 255), w * 75 / 100, h * 87 / 100, blend.Normal, 1, nil}, - {solid(w*77/100, h/130, 220), w * 11 / 100, h * 60 / 100, blend.Normal, 1, nil}, - {solid(w, h, 90), 0, 0, blend.Overlay, 0.5, nil}, + {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) 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/ycbcr_test.go b/raster/ycbcr_test.go index b1d29fb..19bde16 100644 --- a/raster/ycbcr_test.go +++ b/raster/ycbcr_test.go @@ -140,6 +140,7 @@ func TestToYCbCrFlattensOverABackground(t *testing.T) { // 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 {