From 820c9cb116c72a0f7c42790da2c1b9b036448668 Mon Sep 17 00:00:00 2001 From: naisu <47899783+naisuuuu@users.noreply.github.com> Date: Mon, 19 Apr 2021 23:21:59 +0200 Subject: [PATCH] Reduce memory usage in image processing pipeline (#14) * Implement a cached kernel scaler Optimized for scaling a lot of images in a few fixed destination and source sizes. * test: Add benchmark for entire conversion pipeline * Update processing pipeline to use new scaler * test: Avoid extra copying in Grayscale benchmarks * Remove unnecessary mutex pointer from cache scaler * Decouple grayscale func * Implement an image pool * Utilize image pool in processing pipeline * Prevent new allocations if decoded image is already gray --- cmd/mangaconv/main.go | 14 ++- imgutil/grayscale.go | 74 +++++------ imgutil/grayscale_test.go | 6 +- imgutil/imgutil.go | 8 -- imgutil/imgutil_test.go | 47 ------- imgutil/pool.go | 60 +++++++++ imgutil/scale.go | 36 ++++++ imgutil/scale_test.go | 117 ++++++++++++++---- ...tan-100x123-CacheCatmullRom-downscale.png} | Bin ...e-tan-100x123-CacheCatmullRom-upscale.png} | Bin ...ikipe-tan-100x123-CatmullRom-downscale.png | Bin 0 -> 4612 bytes .../wikipe-tan-100x123-CatmullRom-upscale.png | Bin 0 -> 7947 bytes imgutil/testdata/wikipe-tan-195x239.png | Bin 0 -> 14986 bytes imgutil/testdata/wikipe-tan-82x100.png | Bin 0 -> 3919 bytes imgutil/util_test.go | 32 ----- mangaconv.go | 52 ++++++-- mangaconv_test.go | 31 +++++ writer.go | 13 +- 18 files changed, 308 insertions(+), 182 deletions(-) create mode 100644 imgutil/pool.go rename imgutil/testdata/{wikipe-tan-100x123-downscale.png => wikipe-tan-100x123-CacheCatmullRom-downscale.png} (100%) rename imgutil/testdata/{wikipe-tan-100x123-upscale.png => wikipe-tan-100x123-CacheCatmullRom-upscale.png} (100%) create mode 100644 imgutil/testdata/wikipe-tan-100x123-CatmullRom-downscale.png create mode 100644 imgutil/testdata/wikipe-tan-100x123-CatmullRom-upscale.png create mode 100644 imgutil/testdata/wikipe-tan-195x239.png create mode 100644 imgutil/testdata/wikipe-tan-82x100.png create mode 100644 mangaconv_test.go diff --git a/cmd/mangaconv/main.go b/cmd/mangaconv/main.go index 0db164b..1daa3a1 100644 --- a/cmd/mangaconv/main.go +++ b/cmd/mangaconv/main.go @@ -56,18 +56,20 @@ If provided directory does not exist, mangaconv will attempt to create it. (defa } }() + converter := mangaconv.New(mangaconv.Params{ + Cutoff: *cutoff, + Gamma: *gamma, + Height: *height, + Width: *width, + }) + var wg sync.WaitGroup for i := 0; i < 2; i++ { wg.Add(1) go func() { defer wg.Done() for t := range targets { - if err := mangaconv.Convert(t.in, t.out, mangaconv.Params{ - Cutoff: *cutoff, - Gamma: *gamma, - Height: *height, - Width: *width, - }); err != nil { + if err := converter.Convert(t.in, t.out); err != nil { fmt.Println("Failed to convert", filepath.Base(t.in), err) return } diff --git a/imgutil/grayscale.go b/imgutil/grayscale.go index f391c90..5c8e20e 100644 --- a/imgutil/grayscale.go +++ b/imgutil/grayscale.go @@ -8,49 +8,50 @@ import ( // Grayscale returns an image converted to grayscale. // It always returns a copy. func Grayscale(img image.Image) *image.Gray { - switch i := img.(type) { + b := img.Bounds() + dst := image.NewGray(image.Rect(0, 0, b.Dx(), b.Dy())) + grayscale(dst, img) + return dst +} + +func grayscale(dst *image.Gray, src image.Image) { + switch i := src.(type) { case *image.Gray: - return clone(i) + clone(dst, i) case *image.RGBA: - return rgbaToGray(i) + rgbaToGray(dst, i) case *image.RGBA64: - return rgba64ToGray(i) + rgba64ToGray(dst, i) case *image.NRGBA: - return nrgbaToGray(i) + nrgbaToGray(dst, i) case *image.NRGBA64: - return nrgba64ToGray(i) + nrgba64ToGray(dst, i) case *image.YCbCr: - return ycbcrToGray(i) + ycbcrToGray(dst, i) default: - return drawGray(img) + drawGray(dst, src) } } // drawGray uses draw.Draw as a slow conversion fallback for unsupported image types. -func drawGray(img image.Image) *image.Gray { - b := img.Bounds() - dst := image.NewGray(image.Rect(0, 0, b.Dx(), b.Dy())) - draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src) - return dst +func drawGray(dst *image.Gray, src image.Image) { + draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src) } // clone returns a copy of a grayscale image. It additionally corrects negative bounds and removes // dangling bytes from the underlaying pixel slice, if any are present. -func clone(src *image.Gray) *image.Gray { - b := src.Bounds() - dst := image.NewGray(image.Rect(0, 0, b.Dx(), b.Dy())) +func clone(dst, src *image.Gray) { if dst.Stride == src.Stride { // no need to correct stride, simply copy pixels. copy(dst.Pix, src.Pix) - return dst + return } // need to correct stride. - for i := 0; i < b.Dy(); i++ { + for i := 0; i < src.Rect.Dy(); i++ { dstH := i * dst.Stride srcH := i * src.Stride copy(dst.Pix[dstH:dstH+dst.Stride], src.Pix[srcH:srcH+dst.Stride]) } - return dst } // rgbToGray returns a grayscale value from alpha premultiplied red, green and blue values. @@ -69,10 +70,8 @@ func rgbToGray(r, g, b uint32) uint8 { } // rgbaToGray converts an RGBA image to grayscale. -func rgbaToGray(src *image.RGBA) *image.Gray { - b := src.Bounds() - dst := image.NewGray(image.Rect(0, 0, b.Dx(), b.Dy())) - concurrentIterate(b.Dy(), func(y int) { +func rgbaToGray(dst *image.Gray, src *image.RGBA) { + concurrentIterate(src.Rect.Dy(), func(y int) { for x := 0; x < dst.Stride; x++ { i := y*src.Stride + x*4 s := src.Pix[i : i+3 : i+3] @@ -87,14 +86,11 @@ func rgbaToGray(src *image.RGBA) *image.Gray { dst.Pix[y*dst.Stride+x] = rgbToGray(r, g, b) } }) - return dst } // rgba64ToGray lossily converts a 64 bit RGBA image to grayscale. -func rgba64ToGray(src *image.RGBA64) *image.Gray { - b := src.Bounds() - dst := image.NewGray(image.Rect(0, 0, b.Dx(), b.Dy())) - concurrentIterate(b.Dy(), func(y int) { +func rgba64ToGray(dst *image.Gray, src *image.RGBA64) { + concurrentIterate(src.Rect.Dy(), func(y int) { for x := 0; x < dst.Stride; x++ { i := y*src.Stride + x*8 s := src.Pix[i : i+6 : i+6] @@ -106,14 +102,11 @@ func rgba64ToGray(src *image.RGBA64) *image.Gray { dst.Pix[y*dst.Stride+x] = rgbToGray(r, g, b) } }) - return dst } // nrgbaToGray converts an NRGBA image to grayscale. -func nrgbaToGray(src *image.NRGBA) *image.Gray { - b := src.Bounds() - dst := image.NewGray(image.Rect(0, 0, b.Dx(), b.Dy())) - concurrentIterate(b.Dy(), func(y int) { +func nrgbaToGray(dst *image.Gray, src *image.NRGBA) { + concurrentIterate(src.Rect.Dy(), func(y int) { for x := 0; x < dst.Stride; x++ { i := y*src.Stride + x*4 s := src.Pix[i : i+4 : i+4] @@ -133,14 +126,11 @@ func nrgbaToGray(src *image.NRGBA) *image.Gray { dst.Pix[y*dst.Stride+x] = rgbToGray(r, g, b) } }) - return dst } // nrgba64ToGray lossily converts an NRGBA64 image to grayscale. -func nrgba64ToGray(src *image.NRGBA64) *image.Gray { - b := src.Bounds() - dst := image.NewGray(image.Rect(0, 0, b.Dx(), b.Dy())) - concurrentIterate(b.Dy(), func(y int) { +func nrgba64ToGray(dst *image.Gray, src *image.NRGBA64) { + concurrentIterate(src.Rect.Dy(), func(y int) { for x := 0; x < dst.Stride; x++ { i := y*src.Stride + x*8 s := src.Pix[i : i+8 : i+8] @@ -156,17 +146,13 @@ func nrgba64ToGray(src *image.NRGBA64) *image.Gray { dst.Pix[y*dst.Stride+x] = rgbToGray(r, g, b) } }) - return dst } // ycbcrToGray converts a YCbCr image to grayscale. -func ycbcrToGray(src *image.YCbCr) *image.Gray { - b := src.Bounds() - dst := image.NewGray(image.Rect(0, 0, b.Dx(), b.Dy())) - for i := 0; i < b.Dy(); i++ { +func ycbcrToGray(dst *image.Gray, src *image.YCbCr) { + for i := 0; i < src.Rect.Dy(); i++ { dstH := i * dst.Stride srcH := i * src.YStride copy(dst.Pix[dstH:dstH+dst.Stride], src.Y[srcH:srcH+dst.Stride]) } - return dst } diff --git a/imgutil/grayscale_test.go b/imgutil/grayscale_test.go index f4f7e94..e50991a 100644 --- a/imgutil/grayscale_test.go +++ b/imgutil/grayscale_test.go @@ -190,11 +190,7 @@ func BenchmarkGrayscale(b *testing.B) { } b.Run(bb.name, func(b *testing.B) { for i := 0; i < b.N; i++ { - b.StopTimer() - img := cloneImg(bb.img) - b.StartTimer() - - imgutil.Grayscale(img) + imgutil.Grayscale(bb.img) } }) } diff --git a/imgutil/imgutil.go b/imgutil/imgutil.go index 66fc2d2..25b610d 100644 --- a/imgutil/imgutil.go +++ b/imgutil/imgutil.go @@ -131,11 +131,3 @@ func FitRect(rect image.Rectangle, x, y int) image.Rectangle { } return image.Rect(0, 0, int(math.Round(scale*width)), int(math.Round(scale*height))) } - -// Fit returns an image scaled to fit the specified bounding box without changing the aspect ratio. -// It returns a copy of the image. -func Fit(img *image.Gray, x, y int) *image.Gray { - dst := image.NewGray(FitRect(img.Rect, x, y)) - CatmullRom.Scale(dst, img) - return dst -} diff --git a/imgutil/imgutil_test.go b/imgutil/imgutil_test.go index 600f367..71ee70f 100644 --- a/imgutil/imgutil_test.go +++ b/imgutil/imgutil_test.go @@ -190,50 +190,3 @@ func BenchmarkAutoContrast(b *testing.B) { imgutil.AutoContrast(img, 1) } } - -func TestFit(t *testing.T) { - tests := []struct { - name string - image *image.Gray - x int - y int - want *image.Gray - }{ - { - name: "fit height", - image: mustBeGray(mustReadImg("testdata/wikipe-tan-Gray.png")), - x: 1000, - y: 100, - want: mustBeGray(mustReadImg("testdata/wikipe-tan-100h.png")), - }, - { - name: "fit width", - image: mustBeGray(mustReadImg("testdata/wikipe-tan-Gray.png")), - x: 100, - y: 1000, - want: mustBeGray(mustReadImg("testdata/wikipe-tan-100w.png")), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := imgutil.Fit(tt.image, tt.x, tt.y) - if got != tt.want { - if !isWithinDeltaDiff(tt.want.Pix, got.Pix, 1) { - t.Errorf("Fit() difference above acceptable delta") - } - } - }) - } -} - -func BenchmarkFit(b *testing.B) { - src := mustBeGray(mustReadImg("testdata/wikipe-tan-Gray.png")) - b.ResetTimer() - for i := 0; i < b.N; i++ { - b.StopTimer() - img := cloneGray(src) - b.StartTimer() - - imgutil.Fit(img, 150, 150) - } -} diff --git a/imgutil/pool.go b/imgutil/pool.go new file mode 100644 index 0000000..c3b9c3a --- /dev/null +++ b/imgutil/pool.go @@ -0,0 +1,60 @@ +package imgutil + +import ( + "image" + "sync" +) + +// NewImagePool creates an ImagePool. +func NewImagePool() *ImagePool { + return &ImagePool{ + cache: make(map[int]*sync.Pool), + } +} + +// ImagePool maintains a sync.Pool of pixel arrays for each image resolution gotten from it. +type ImagePool struct { + cache map[int]*sync.Pool + mu sync.Mutex +} + +// GetFromImage converts an image into a grayscale image with pixel slice taken from the pool. +func (p *ImagePool) GetFromImage(img image.Image) *image.Gray { + if i, ok := img.(*image.Gray); ok { + return i + } + dst := p.Get(img.Bounds().Dx(), img.Bounds().Dy()) + grayscale(dst, img) + return dst +} + +func (p *ImagePool) getPool(pixLen int) *sync.Pool { + p.mu.Lock() + pool, ok := p.cache[pixLen] + if !ok { + pool = &sync.Pool{ + New: func() interface{} { + tmp := make([]uint8, pixLen) + return &tmp + }, + } + p.cache[pixLen] = pool + } + p.mu.Unlock() + return pool +} + +// Get gets a grayscale image of specified width and height with pixel slice taken from the pool. +func (p *ImagePool) Get(width, height int) *image.Gray { + tmp := p.getPool(width * height).Get().(*[]uint8) + return &image.Gray{ + Pix: *tmp, + Stride: width, + Rect: image.Rect(0, 0, width, height), + } +} + +// Put puts an images pixel slice back into the pool. +func (p *ImagePool) Put(img *image.Gray) { + p.getPool(len(img.Pix)).Put(&img.Pix) +} diff --git a/imgutil/scale.go b/imgutil/scale.go index 6e548ce..af03249 100644 --- a/imgutil/scale.go +++ b/imgutil/scale.go @@ -9,6 +9,42 @@ import ( "sync" ) +type cacheScaler struct { + kernel *Kernel + cache map[cacheKey]Scaler + mu sync.Mutex +} + +type cacheKey struct { + dw, dh, sw, sh int +} + +// Scale implements the Scaler interface. +func (z *cacheScaler) Scale(dst, src *image.Gray) { + key := cacheKey{dst.Rect.Dx(), dst.Rect.Dy(), src.Rect.Dx(), src.Rect.Dy()} + + z.mu.Lock() + scaler, ok := z.cache[key] + if !ok { + scaler = z.kernel.NewScaler(key.dw, key.dh, key.sw, key.sh) + z.cache[key] = scaler + } + z.mu.Unlock() + + scaler.Scale(dst, src) +} + +// NewCacheScaler creates, caches and reuses kernel scalers optimized for each unique combination +// of destination and source width and height. It is mostly useful when scaling a large quantity of +// images in a few fixed sizes. +func NewCacheScaler(k *Kernel) Scaler { + return &cacheScaler{ + kernel: k, + cache: make(map[cacheKey]Scaler), + mu: sync.Mutex{}, + } +} + // Scaler scales the source image to the destination image. type Scaler interface { Scale(dst, src *image.Gray) diff --git a/imgutil/scale_test.go b/imgutil/scale_test.go index 2475f90..401ded2 100644 --- a/imgutil/scale_test.go +++ b/imgutil/scale_test.go @@ -12,24 +12,41 @@ import ( var genGoldenFiles = flag.Bool("gen_golden_files", false, "whether to generate the TestXxx golden files.") -func TestKernelScaler(t *testing.T) { +func TestScaler(t *testing.T) { tests := []struct { - name string - w int - h int - image string + name string + w int + h int + image string + scaler imgutil.Scaler }{ { - name: "downscale", - w: 100, - h: 100, - image: "wikipe-tan-100x123", + name: "CatmullRom-downscale", + w: 100, + h: 100, + image: "wikipe-tan-100x123", + scaler: imgutil.CatmullRom, }, { - name: "upscale", - w: 130, - h: 150, - image: "wikipe-tan-100x123", + name: "CatmullRom-upscale", + w: 130, + h: 150, + image: "wikipe-tan-100x123", + scaler: imgutil.CatmullRom, + }, + { + name: "CacheCatmullRom-downscale", + w: 100, + h: 100, + image: "wikipe-tan-100x123", + scaler: imgutil.NewCacheScaler(imgutil.CatmullRom), + }, + { + name: "CacheCatmullRom-upscale", + w: 130, + h: 150, + image: "wikipe-tan-100x123", + scaler: imgutil.NewCacheScaler(imgutil.CatmullRom), }, } for _, tt := range tests { @@ -38,7 +55,7 @@ func TestKernelScaler(t *testing.T) { goldenFname := fmt.Sprintf("testdata/%s-%s.png", tt.image, tt.name) got := image.NewGray(image.Rect(0, 0, tt.w, tt.h)) - imgutil.CatmullRom.Scale(got, src) + tt.scaler.Scale(got, src) if *genGoldenFiles { if err := writeImg(goldenFname, got); err != nil { @@ -55,13 +72,69 @@ func TestKernelScaler(t *testing.T) { } } -func BenchmarkKernelScaler(b *testing.B) { - src := mustBeGray(mustReadImg("testdata/wikipe-tan-Gray.png")) - dstRect := imgutil.FitRect(src.Bounds(), 150, 150) - scaler := imgutil.CatmullRom.NewScaler(dstRect.Dx(), dstRect.Dy(), src.Rect.Dx(), src.Rect.Dy()) - b.ResetTimer() - for i := 0; i < b.N; i++ { - dst := image.NewGray(dstRect) - scaler.Scale(dst, src) +// BenchmarkScaler benchmarks scalers against one or more images. The occurences of each image are +// evenly distributed among all benchmark iterations. +func BenchmarkScaler(b *testing.B) { + benchmarks := []struct { + name string + scaler imgutil.Scaler + images []string + }{ + { + name: "CatmullRom_singleImage", + scaler: imgutil.CatmullRom.NewScaler(122, 150, 195, 239), + images: []string{"wikipe-tan-195x239.png"}, + }, + { + name: "CatmullRom_threeImages", + scaler: imgutil.CatmullRom.NewScaler(122, 150, 195, 239), + images: []string{"wikipe-tan-195x239.png", "wikipe-tan-100x123.png", "wikipe-tan-82x100.png"}, + }, + { + name: "CacheCatmullRom_singleImage", + scaler: imgutil.NewCacheScaler(imgutil.CatmullRom), + images: []string{"wikipe-tan-195x239.png"}, + }, + { + name: "CacheCatmullRom_threeImages", + scaler: imgutil.NewCacheScaler(imgutil.CatmullRom), + images: []string{"wikipe-tan-195x239.png", "wikipe-tan-100x123.png", "wikipe-tan-82x100.png"}, + }, + } + for _, bb := range benchmarks { + b.Run(bb.name, func(b *testing.B) { + var images []*image.Gray + for _, i := range bb.images { + images = append(images, mustBeGray(mustReadImg("testdata/"+i))) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + b.StopTimer() + img := images[i%len(images)] + dstRect := imgutil.FitRect(img.Bounds(), 150, 150) + b.StartTimer() + dst := image.NewGray(dstRect) + bb.scaler.Scale(dst, img) + } + }) + } + for _, bb := range benchmarks { + b.Run("Pooled"+bb.name, func(b *testing.B) { + var images []*image.Gray + for _, i := range bb.images { + images = append(images, mustBeGray(mustReadImg("testdata/"+i))) + } + pool := imgutil.NewImagePool() + b.ResetTimer() + for i := 0; i < b.N; i++ { + b.StopTimer() + img := images[i%len(images)] + dstRect := imgutil.FitRect(img.Bounds(), 150, 150) + b.StartTimer() + dst := pool.Get(dstRect.Dx(), dstRect.Dy()) + bb.scaler.Scale(dst, img) + pool.Put(dst) + } + }) } } diff --git a/imgutil/testdata/wikipe-tan-100x123-downscale.png b/imgutil/testdata/wikipe-tan-100x123-CacheCatmullRom-downscale.png similarity index 100% rename from imgutil/testdata/wikipe-tan-100x123-downscale.png rename to imgutil/testdata/wikipe-tan-100x123-CacheCatmullRom-downscale.png diff --git a/imgutil/testdata/wikipe-tan-100x123-upscale.png b/imgutil/testdata/wikipe-tan-100x123-CacheCatmullRom-upscale.png similarity index 100% rename from imgutil/testdata/wikipe-tan-100x123-upscale.png rename to imgutil/testdata/wikipe-tan-100x123-CacheCatmullRom-upscale.png diff --git a/imgutil/testdata/wikipe-tan-100x123-CatmullRom-downscale.png b/imgutil/testdata/wikipe-tan-100x123-CatmullRom-downscale.png new file mode 100644 index 0000000000000000000000000000000000000000..1219c86504934883f4aa60c351fb5e1c323c651e GIT binary patch literal 4612 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 literal 0 HcmV?d00001 diff --git a/imgutil/testdata/wikipe-tan-100x123-CatmullRom-upscale.png b/imgutil/testdata/wikipe-tan-100x123-CatmullRom-upscale.png new file mode 100644 index 0000000000000000000000000000000000000000..5a81cc879de69a95e030ac43e20a8120911ef9b4 GIT binary patch literal 7947 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

qKurV~&oU0KRA*~bZk{(}3*Q8)>ew?ZWYk{u$D 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 Pl}x3?0sMiHP60u(`uAZeSY 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 literal 0 HcmV?d00001 diff --git a/imgutil/util_test.go b/imgutil/util_test.go index 5c63520..bbccc5c 100644 --- a/imgutil/util_test.go +++ b/imgutil/util_test.go @@ -83,38 +83,6 @@ func cloneSlice(b []uint8) []uint8 { return c } -func cloneImg(src image.Image) image.Image { - switch s := src.(type) { - case *image.Gray: - clone := *s - clone.Pix = cloneSlice(s.Pix) - return &clone - case *image.NRGBA: - clone := *s - clone.Pix = cloneSlice(s.Pix) - return &clone - case *image.NRGBA64: - clone := *s - clone.Pix = cloneSlice(s.Pix) - return &clone - case *image.RGBA: - clone := *s - clone.Pix = cloneSlice(s.Pix) - return &clone - case *image.RGBA64: - clone := *s - clone.Pix = cloneSlice(s.Pix) - return &clone - case *image.YCbCr: - clone := *s - clone.Y = cloneSlice(s.Y) - clone.Cb = cloneSlice(s.Cb) - clone.Cr = cloneSlice(s.Cr) - return &clone - } - return nil -} - func cloneGray(s *image.Gray) *image.Gray { c := *s c.Pix = cloneSlice(s.Pix) diff --git a/mangaconv.go b/mangaconv.go index 61a7219..26542d8 100644 --- a/mangaconv.go +++ b/mangaconv.go @@ -6,6 +6,8 @@ import ( "context" "fmt" "image" + "io" + "os" "runtime" "sync" @@ -27,8 +29,35 @@ type Params struct { Width int } -// Convert converts a manga for reading on an e-reader. -func Convert(in, out string, params Params) error { +// New creates a new Converter with the provided Params. +func New(p Params) *Converter { + return &Converter{ + params: p, + scaler: imgutil.NewCacheScaler(imgutil.CatmullRom), + pool: imgutil.NewImagePool(), + } +} + +// Converter converts manga for reading on an e-reader. It's safe to use concurrently. +type Converter struct { + params Params + scaler imgutil.Scaler + pool *imgutil.ImagePool +} + +// Convert reads a file from in, converts it, and writes to out. +func (c *Converter) Convert(in, out string) error { + f, err := os.Create(out) + if err != nil { + return err + } + defer f.Close() + + return c.ConvertToWriter(in, f) +} + +// Convert reads a file from in, converts it, and writes to an io.Writer. +func (c *Converter) ConvertToWriter(in string, out io.Writer) error { read, err := selectReader(in) if err != nil { return fmt.Errorf("cannot read %s: %w", in, err) @@ -44,12 +73,12 @@ func Convert(in, out string, params Params) error { converted := make(chan page) errg.Go(func() error { defer close(converted) - convert(ctx, converted, pages, params) + c.convert(ctx, converted, pages) return nil }) errg.Go(func() error { - return writeZip(out, converted) + return c.writeZip(out, converted) }) return errg.Wait() @@ -63,19 +92,22 @@ type page struct { // convert reads a channel of pages, applies modifications as adjusted by params and emits converted // pages. -func convert(ctx context.Context, converted chan<- page, pages <-chan page, p Params) { +func (c *Converter) convert(ctx context.Context, converted chan<- page, pages <-chan page) { var wg sync.WaitGroup wg.Add(runtime.NumCPU()) for i := 0; i < runtime.NumCPU(); i++ { go func() { defer wg.Done() for pg := range pages { - img := imgutil.Grayscale(pg.Image) - img = imgutil.Fit(img, p.Width, p.Height) - imgutil.AutoContrast(img, p.Cutoff) - imgutil.AdjustGamma(img, p.Gamma) + src := c.pool.GetFromImage(pg.Image) + r := imgutil.FitRect(src.Bounds(), c.params.Width, c.params.Height) + dst := c.pool.Get(r.Dx(), r.Dy()) + c.scaler.Scale(dst, src) + c.pool.Put(src) + imgutil.AutoContrast(dst, c.params.Cutoff) + imgutil.AdjustGamma(dst, c.params.Gamma) select { - case converted <- page{img, pg.Index}: + case converted <- page{dst, pg.Index}: case <-ctx.Done(): return } diff --git a/mangaconv_test.go b/mangaconv_test.go new file mode 100644 index 0000000..a767d7c --- /dev/null +++ b/mangaconv_test.go @@ -0,0 +1,31 @@ +package mangaconv_test + +import ( + "io" + "testing" + + "github.com/naisuuuu/mangaconv" +) + +func BenchmarkConverter(b *testing.B) { + benchmarks := []struct { + name string + file string + w, h int + }{ + {"testdata", "imgutil/testdata", 150, 150}, + } + for _, bb := range benchmarks { + b.Run(bb.name, func(b *testing.B) { + c := mangaconv.New(mangaconv.Params{ + Cutoff: 1, + Gamma: 0.75, + Height: bb.h, + Width: bb.w, + }) + for i := 0; i < b.N; i++ { + c.ConvertToWriter(bb.file, io.Discard) + } + }) + } +} diff --git a/writer.go b/writer.go index 88c7c70..6346372 100644 --- a/writer.go +++ b/writer.go @@ -9,16 +9,10 @@ import ( // for image decoding. _ "image/png" "io" - "os" ) -func writeZip(path string, pages <-chan page) error { - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - w := zip.NewWriter(f) +func (c *Converter) writeZip(writer io.Writer, pages <-chan page) error { + w := zip.NewWriter(writer) defer w.Close() for p := range pages { f, err := w.Create(fmt.Sprintf("%09d.jpg", p.Index)) @@ -26,6 +20,9 @@ func writeZip(path string, pages <-chan page) error { return err } err = saveImg(f, p.Image) + if v, ok := p.Image.(*image.Gray); ok { + c.pool.Put(v) + } if err != nil { return err }