Skip to content

Commit

Permalink
feat(image): implement processor options
Browse files Browse the repository at this point in the history
  • Loading branch information
bounoable committed Sep 4, 2022
1 parent b664849 commit a1406c8
Show file tree
Hide file tree
Showing 9 changed files with 212 additions and 41 deletions.
29 changes: 25 additions & 4 deletions image/compress.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,48 @@ import "image"

// Compressor compresses images.
type Compressor struct {
compressor CompressionFunc
compressor CompressionFunc
compressOriginal bool
}

// CompressionFunc is the actual implementation of a compressor.
// Available implementations:
// - [github.com/modernice/media-tools/image/compressor.JPEG]
type CompressionFunc func(image.Image) (*image.NRGBA, error)

// CompressorOption is an option for a [*Compressor].
type CompressorOption func(*Compressor)

// CompressOriginal returns a CompressorOption that enables compression of
// the original image of a [Pipeline]. By default, the original image will not
// be compressed and instead returned as is to preserve the original image
// quality.
func CompressOriginal(v bool) CompressorOption {
return func(c *Compressor) {
c.compressOriginal = v
}
}

// Compress returns a [*Compressor] that compresses images using the provided
// [CompressionFunc].
func Compress(compressor CompressionFunc) *Compressor {
return &Compressor{compressor: compressor}
func Compress(compressor CompressionFunc, opts ...CompressorOption) *Compressor {
c := &Compressor{compressor: compressor}
for _, opt := range opts {
opt(c)
}
return c
}

// Compress compresses an image using the configured [CompressionFunc].
func (c *Compressor) Compress(img image.Image) (*image.NRGBA, error) {
return c.compressor(img)
}

// Process implements [Processor]. By default, the original image will not be
// compressed and returned as is to preserve quality. To also compress the
// original image, pass the [CompressOriginal] option to [Compress].
func (c *Compressor) Process(ctx ProcessorContext) ([]*image.NRGBA, error) {
if ctx.Original() {
if !c.compressOriginal && ctx.Original() {
return []*image.NRGBA{ctx.Image()}, nil
}

Expand Down
60 changes: 60 additions & 0 deletions image/compress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ package image_test

import (
"bytes"
"context"
"fmt"
stdimage "image"
"image/jpeg"
"testing"

"github.com/modernice/media-tools/image"
"github.com/modernice/media-tools/image/compressor"
"github.com/modernice/media-tools/image/internal"
)

func TestCompressor_Compress(t *testing.T) {
Expand Down Expand Up @@ -42,6 +44,64 @@ func TestCompressor_Compress(t *testing.T) {
}
}

func TestCompressor_Process_original(t *testing.T) {
compressor := image.Compress(compressor.JPEG(50))

original := newExample()
ctx := image.NewProcessorContext(context.Background(), original, true)

compressed, err := compressor.Process(ctx)
if err != nil {
t.Fatalf("run processor: %v", err)
}

if len(compressed) != 1 {
t.Fatalf("expected 1 compressed image; got %d", len(compressed))
}

orgSize, err := internal.SizeOf(original)
if err != nil {
t.Fatalf("get original size: %v", err)
}
compressedSize, err := internal.SizeOf(compressed[0])
if err != nil {
t.Fatalf("get compressed size: %v", err)
}

if orgSize != compressedSize {
t.Fatalf("original image should not be compressed")
}
}

func TestCompressor_Process_CompressOriginal(t *testing.T) {
compressor := image.Compress(compressor.JPEG(50), image.CompressOriginal(true))

original := newExample()
ctx := image.NewProcessorContext(context.Background(), original, true)

compressed, err := compressor.Process(ctx)
if err != nil {
t.Fatalf("run processor: %v", err)
}

if len(compressed) != 1 {
t.Fatalf("expected 1 compressed image; got %d", len(compressed))
}

orgSize, err := internal.SizeOf(original)
if err != nil {
t.Fatalf("get original size: %v", err)
}
compressedSize, err := internal.SizeOf(compressed[0])
if err != nil {
t.Fatalf("get compressed size: %v", err)
}

if orgSize == compressedSize {
t.Fatalf("original image should be compressed when providing the CompressOriginal(true) option")
}
}

func getImageSize(t *testing.T, img stdimage.Image) int {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 100}); err != nil {
Expand Down
13 changes: 0 additions & 13 deletions image/internal/convert.go

This file was deleted.

40 changes: 40 additions & 0 deletions image/internal/internal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package internal

import (
"bytes"
"fmt"
"image"
"image/draw"
"image/jpeg"

"github.com/vitali-fedulov/images4"
)

// ToNRGBA converts an image.Image to an [*image.NRGBA].
func ToNRGBA(img image.Image) *image.NRGBA {
out := image.NewNRGBA(img.Bounds())
draw.Draw(out, out.Bounds(), img, image.Point{}, draw.Src)
return out
}

// SameImages returns whether two images are identical. The images are allowed
// to have different bounds and still be the same.
func SameImages(a, b image.Image) bool {
iconA := images4.Icon(a)
iconB := images4.Icon(b)
return images4.Similar(iconA, iconB)
}

// EqualImages returns whether two images are identical, including theirs bounds.
func EqualImages(a, b image.Image) bool {
return a.Bounds() == b.Bounds() && SameImages(a, b)
}

// SizeOf returns the JPEG size of an image in bytes.
func SizeOf(img image.Image) (int, error) {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 100}); err != nil {
return 0, fmt.Errorf("encode as JPEG: %w", err)
}
return buf.Len(), nil
}
18 changes: 12 additions & 6 deletions image/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ type ProcessorContext interface {
Original() bool
}

// NewProcessorContext returns a new [ProcessorContext] for a [Processor].
func NewProcessorContext(ctx context.Context, img *image.NRGBA, original bool) ProcessorContext {
return &processorContext{
Context: ctx,
image: img,
original: original,
}
}

type processorContext struct {
context.Context

Expand Down Expand Up @@ -73,17 +82,14 @@ func (pipeline Pipeline) Run(ctx context.Context, img image.Image) (PipelineResu

previous := []*image.NRGBA{nimg}

for _, processor := range pipeline {
for i, processor := range pipeline {
_previous := previous
previous = previous[:0]

for _, img := range _previous {
pctx := processorContext{
Context: ctx,
image: img,
}
pctx := NewProcessorContext(ctx, img, i == 0)

processed, err := processor.Process(&pctx)
processed, err := processor.Process(pctx)
if err != nil {
return PipelineResult{}, fmt.Errorf("%T processor: %w", processor, err)
}
Expand Down
11 changes: 2 additions & 9 deletions image/pipeline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ package image_test

import (
"context"
stdimage "image"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/modernice/media-tools/image"
"github.com/modernice/media-tools/image/compressor"
"github.com/vitali-fedulov/images4"
"github.com/modernice/media-tools/image/internal"
)

func TestPipeline(t *testing.T) {
Expand Down Expand Up @@ -37,13 +36,7 @@ func TestPipeline(t *testing.T) {
}
}

if !equalImages(original, result.Images[0].Image) {
if !internal.SameImages(original, result.Images[0].Image) {
t.Fatalf("first image should be the original\n%s", cmp.Diff(original, result.Images[0].Image))
}
}

func equalImages(a, b stdimage.Image) bool {
iconA := images4.Icon(a)
iconB := images4.Icon(b)
return images4.Similar(iconA, iconB)
}
31 changes: 24 additions & 7 deletions image/resize.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@ import (

// Resizer resizes images to a set of dimensions.
type Resizer struct {
dimensions []Dimensions
filter imaging.ResampleFilter
dimensions []Dimensions
filter imaging.ResampleFilter
discardOriginal bool
}

// DimensionProvider provides image dimensions to Resizer. DimensionProvider is
// implemented by [DimensionList] and [DimensionMap].
type DimensionProvider interface {
Dimensions() []Dimensions
}

// ResizerOption is an option for a Resizer.
Expand All @@ -23,10 +30,12 @@ func ResampleFilter(filter imaging.ResampleFilter) ResizerOption {
}
}

// DimensionProvider provides image dimensions to Resizer. DimensionProvider is
// implemented by [DimensionList] and [DimensionMap].
type DimensionProvider interface {
Dimensions() []Dimensions
// DiscardOriginal returns a ResizerOption that discards the original image when
// executed in a [Pipeline].
func DiscardOriginal(v bool) ResizerOption {
return func(r *Resizer) {
r.discardOriginal = v
}
}

// Resize returns a Resizer that resizes images to the given dimensions.
Expand All @@ -39,7 +48,8 @@ func Resize(dimensions DimensionProvider, opts ...ResizerOption) *Resizer {
return r
}

// Resize resizes an image to the configured dimensinos.
// Resize resizes an image to the configured dimensinos. The input image is not
// returned in the result.
func (r *Resizer) Resize(img image.Image) ([]*image.NRGBA, error) {
resized := make([]*image.NRGBA, len(r.dimensions))
for i, dim := range r.dimensions {
Expand All @@ -52,10 +62,17 @@ func (r *Resizer) resize(img image.Image, dim Dimensions) *image.NRGBA {
return imaging.Resize(img, dim.Width(), dim.Height(), r.filter)
}

// Process implements [Processor]. The input image is returned in the result as
// the first element.
func (r *Resizer) Process(ctx ProcessorContext) ([]*image.NRGBA, error) {
resized, err := r.Resize(ctx.Image())
if err != nil {
return nil, err
}

if r.discardOriginal {
return resized, nil
}

return append([]*image.NRGBA{ctx.Image()}, resized...), nil
}
46 changes: 46 additions & 0 deletions image/resize_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package image_test

import (
"context"
_ "embed"
"fmt"
stdimage "image"
"testing"

"github.com/modernice/media-tools/image"
"github.com/modernice/media-tools/image/internal"
)

func TestResizer_Resize_DimensionList(t *testing.T) {
Expand Down Expand Up @@ -47,6 +49,50 @@ func TestResizer_Resize_DimensionList(t *testing.T) {
}
}

func TestResizer_Process(t *testing.T) {
dimensions := image.DimensionList{{360}, {640}, {960}}

resizer := image.Resize(dimensions)

original := newExample()
ctx := image.NewProcessorContext(context.Background(), original, true)

resized, err := resizer.Process(ctx)
if err != nil {
t.Fatalf("run processor: %v", err)
}

if len(resized) != len(dimensions)+1 {
t.Fatalf("expected %d resized images (including the original); got %d", len(dimensions)+1, len(resized))
}

if !internal.EqualImages(original, resized[0]) {
t.Fatalf("first returned image should be the original")
}
}

func TestResizer_Process_DiscardOriginal(t *testing.T) {
dimensions := image.DimensionList{{360}, {640}, {960}}

resizer := image.Resize(dimensions, image.DiscardOriginal(true))

original := newExample()
ctx := image.NewProcessorContext(context.Background(), original, true)

resized, err := resizer.Process(ctx)
if err != nil {
t.Fatalf("run processor: %v", err)
}

if len(resized) != len(dimensions) {
t.Fatalf("expected %d resized images (excluding the original); got %d", len(dimensions), len(resized))
}

if internal.EqualImages(original, resized[0]) {
t.Fatalf("original image should not be returned")
}
}

func saveResized(t *testing.T, dim image.Dimensions, img *stdimage.NRGBA) {
saveOutImage(t, fmt.Sprintf("resized-%dx%d.jpg", dim.Width(), dim.Height()), dim, img)
}
Loading

0 comments on commit a1406c8

Please sign in to comment.