From be25fab6ab2b4c45d0bf3def6689ee748aed2b56 Mon Sep 17 00:00:00 2001 From: Colin Henry Date: Sat, 27 Dec 2025 14:23:54 -0800 Subject: [PATCH] added tests, examples, and direct attribution --- image/filter/doc.go | 3 + image/filter/example_test.go | 63 +++++ image/filter/filter_test.go | 459 +++++++++++++++++++++++++++++++++++ 3 files changed, 525 insertions(+) create mode 100644 image/filter/doc.go create mode 100644 image/filter/example_test.go create mode 100644 image/filter/filter_test.go diff --git a/image/filter/doc.go b/image/filter/doc.go new file mode 100644 index 0000000..a74f080 --- /dev/null +++ b/image/filter/doc.go @@ -0,0 +1,3 @@ +package filter + +// boosted with gratitude from https://github.com/nobloat/blog diff --git a/image/filter/example_test.go b/image/filter/example_test.go new file mode 100644 index 0000000..a475966 --- /dev/null +++ b/image/filter/example_test.go @@ -0,0 +1,63 @@ +package filter_test + +import ( + "image" + "image/png" + "log" + "os" + "path" + "path/filepath" + "strings" + + "git.sdf.org/jchenry/x/image/filter" +) + +func Example_pipeline() { + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + + gray := filter.Grayscale(img) + gray = filter.Unsharp(gray, 2, 1.2) + gray = filter.Stretch(gray, 0.05, 0.95) + + bw := filter.Dither(gray, 127) + + _ = bw +} + +func Example_fullcode() { + maxLongEdge := 400 + + in := "" + out := path.Join( + "public", + "images", + strings.TrimSuffix(filepath.Base(in), filepath.Ext(in))+".png", + ) + + f, err := os.Open(in) + if err != nil { + log.Fatal(err) + } + defer f.Close() + + img, _, err := image.Decode(f) + if err != nil { + log.Fatal(err) + } + + img = filter.ResizeLongEdge(img, maxLongEdge) + gray := filter.Grayscale(img) + bw := filter.Dither(gray, 127) + + o, err := os.Create(out) + if err != nil { + log.Fatal(err) + } + defer o.Close() + + enc := png.Encoder{CompressionLevel: png.BestCompression} + if err := enc.Encode(o, bw); err != nil { + log.Fatal(err) + } + +} diff --git a/image/filter/filter_test.go b/image/filter/filter_test.go new file mode 100644 index 0000000..2c9c00a --- /dev/null +++ b/image/filter/filter_test.go @@ -0,0 +1,459 @@ +package filter + +import ( + "image" + "image/color" + "testing" +) + +// Helper function to create a test grayscale image +func createTestGray(w, h int, fill uint8) *image.Gray { + img := image.NewGray(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetGray(x, y, color.Gray{Y: fill}) + } + } + return img +} + +// Helper function to create a gradient test grayscale image +func createGradientGray(w, h int) *image.Gray { + img := image.NewGray(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + v := uint8(float64(x) / float64(w) * 255) + img.SetGray(x, y, color.Gray{Y: v}) + } + } + return img +} + +// Helper function to create a test RGBA image +func createTestRGBA(w, h int, c color.RGBA) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, c) + } + } + return img +} + +func TestClamp(t *testing.T) { + tests := []struct { + name string + input float64 + want uint8 + }{ + {"negative value", -10.0, 0}, + {"zero", 0.0, 0}, + {"normal value", 127.5, 127}, + {"max value", 255.0, 255}, + {"over max", 300.0, 255}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := clamp(tt.input) + if got != tt.want { + t.Errorf("clamp(%v) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestBoxBlur(t *testing.T) { + t.Run("blur uniform image", func(t *testing.T) { + src := createTestGray(10, 10, 128) + result := BoxBlur(src, 1) + + if result == nil { + t.Fatal("BoxBlur returned nil") + } + + // Uniform image should remain uniform after blur + for y := 0; y < 10; y++ { + for x := 0; x < 10; x++ { + if result.GrayAt(x, y).Y != 128 { + t.Errorf("Expected uniform value 128, got %d at (%d,%d)", + result.GrayAt(x, y).Y, x, y) + return + } + } + } + }) + + t.Run("blur with radius 0", func(t *testing.T) { + src := createGradientGray(10, 10) + result := BoxBlur(src, 0) + + if result == nil { + t.Fatal("BoxBlur returned nil") + } + + // Radius 0 should return similar image + for y := 0; y < 10; y++ { + for x := 0; x < 10; x++ { + if result.GrayAt(x, y).Y != src.GrayAt(x, y).Y { + t.Errorf("Radius 0 changed pixel at (%d,%d)", x, y) + return + } + } + } + }) + + t.Run("blur preserves bounds", func(t *testing.T) { + src := createTestGray(20, 15, 100) + result := BoxBlur(src, 2) + + if !result.Bounds().Eq(src.Bounds()) { + t.Errorf("Bounds changed: got %v, want %v", result.Bounds(), src.Bounds()) + } + }) +} + +func TestDither(t *testing.T) { + t.Run("dither produces binary output", func(t *testing.T) { + src := createGradientGray(10, 10) + result := Dither(src, 128) + + if result == nil { + t.Fatal("Dither returned nil") + } + + // All pixels should be either 0 or 255 + for y := 0; y < 10; y++ { + for x := 0; x < 10; x++ { + v := result.GrayAt(x, y).Y + if v != 0 && v != 255 { + t.Errorf("Expected binary value (0 or 255), got %d at (%d,%d)", v, x, y) + } + } + } + }) + + t.Run("dither white image stays white", func(t *testing.T) { + src := createTestGray(10, 10, 255) + result := Dither(src, 128) + + // All pixels should be white + for y := 0; y < 10; y++ { + for x := 0; x < 10; x++ { + if result.GrayAt(x, y).Y != 255 { + t.Errorf("Expected white (255), got %d at (%d,%d)", + result.GrayAt(x, y).Y, x, y) + return + } + } + } + }) + + t.Run("dither black image stays black", func(t *testing.T) { + src := createTestGray(10, 10, 0) + result := Dither(src, 128) + + // All pixels should be black + for y := 0; y < 10; y++ { + for x := 0; x < 10; x++ { + if result.GrayAt(x, y).Y != 0 { + t.Errorf("Expected black (0), got %d at (%d,%d)", + result.GrayAt(x, y).Y, x, y) + return + } + } + } + }) + + t.Run("dither preserves bounds", func(t *testing.T) { + src := createTestGray(20, 15, 100) + result := Dither(src, 128) + + if !result.Bounds().Eq(src.Bounds()) { + t.Errorf("Bounds changed: got %v, want %v", result.Bounds(), src.Bounds()) + } + }) +} + +func TestGrayscale(t *testing.T) { + t.Run("convert RGBA to grayscale", func(t *testing.T) { + src := createTestRGBA(10, 10, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + result := Grayscale(src) + + if result == nil { + t.Fatal("Grayscale returned nil") + } + + // Red should convert to specific luma value (based on 0.2126 weight) + // RGBA returns values in 0-65535 range, so R=255 becomes 65535 + // Just verify it's in a reasonable range instead of exact value + v := result.GrayAt(0, 0).Y + if v < 50 || v > 60 { + t.Errorf("Red conversion out of expected range: got %d, want ~54", v) + } + }) + + t.Run("convert white to grayscale", func(t *testing.T) { + src := createTestRGBA(5, 5, color.RGBA{R: 255, G: 255, B: 255, A: 255}) + result := Grayscale(src) + + // White should stay white + for y := 0; y < 5; y++ { + for x := 0; x < 5; x++ { + if result.GrayAt(x, y).Y != 255 { + t.Errorf("Expected 255, got %d at (%d,%d)", + result.GrayAt(x, y).Y, x, y) + return + } + } + } + }) + + t.Run("convert black to grayscale", func(t *testing.T) { + src := createTestRGBA(5, 5, color.RGBA{R: 0, G: 0, B: 0, A: 255}) + result := Grayscale(src) + + // Black should stay black + for y := 0; y < 5; y++ { + for x := 0; x < 5; x++ { + if result.GrayAt(x, y).Y != 0 { + t.Errorf("Expected 0, got %d at (%d,%d)", + result.GrayAt(x, y).Y, x, y) + return + } + } + } + }) + + t.Run("grayscale preserves bounds", func(t *testing.T) { + src := createTestRGBA(20, 15, color.RGBA{R: 100, G: 100, B: 100, A: 255}) + result := Grayscale(src) + + if !result.Bounds().Eq(src.Bounds()) { + t.Errorf("Bounds changed: got %v, want %v", result.Bounds(), src.Bounds()) + } + }) +} + +func TestResizeLongEdge(t *testing.T) { + t.Run("resize landscape image", func(t *testing.T) { + src := createTestRGBA(100, 50, color.RGBA{R: 128, G: 128, B: 128, A: 255}) + result := ResizeLongEdge(src, 50) + + if result == nil { + t.Fatal("ResizeLongEdge returned nil") + } + + // Width should be 50, height should be 25 + bounds := result.Bounds() + if bounds.Dx() != 50 || bounds.Dy() != 25 { + t.Errorf("Expected 50x25, got %dx%d", bounds.Dx(), bounds.Dy()) + } + }) + + t.Run("resize portrait image", func(t *testing.T) { + src := createTestRGBA(50, 100, color.RGBA{R: 128, G: 128, B: 128, A: 255}) + result := ResizeLongEdge(src, 50) + + if result == nil { + t.Fatal("ResizeLongEdge returned nil") + } + + // Height should be 50, width should be 25 + bounds := result.Bounds() + if bounds.Dx() != 25 || bounds.Dy() != 50 { + t.Errorf("Expected 25x50, got %dx%d", bounds.Dx(), bounds.Dy()) + } + }) + + t.Run("resize square image", func(t *testing.T) { + src := createTestRGBA(100, 100, color.RGBA{R: 128, G: 128, B: 128, A: 255}) + result := ResizeLongEdge(src, 50) + + if result == nil { + t.Fatal("ResizeLongEdge returned nil") + } + + // Both dimensions should be 50 + bounds := result.Bounds() + if bounds.Dx() != 50 || bounds.Dy() != 50 { + t.Errorf("Expected 50x50, got %dx%d", bounds.Dx(), bounds.Dy()) + } + }) + + t.Run("resize bounds start at origin", func(t *testing.T) { + src := createTestRGBA(100, 80, color.RGBA{R: 128, G: 128, B: 128, A: 255}) + result := ResizeLongEdge(src, 40) + + bounds := result.Bounds() + if bounds.Min.X != 0 || bounds.Min.Y != 0 { + t.Errorf("Expected bounds to start at (0,0), got (%d,%d)", + bounds.Min.X, bounds.Min.Y) + } + }) +} + +func TestSigmoid(t *testing.T) { + t.Run("sigmoid with zero contrast", func(t *testing.T) { + src := createGradientGray(10, 10) + result := Sigmoid(src, 0, 0.5) + + if result == nil { + t.Fatal("Sigmoid returned nil") + } + + // With zero contrast, all values should map to near midpoint + midValue := result.GrayAt(5, 5).Y + if midValue < 125 || midValue > 130 { + t.Errorf("Expected value near 127, got %d", midValue) + } + }) + + t.Run("sigmoid preserves bounds", func(t *testing.T) { + src := createTestGray(20, 15, 100) + result := Sigmoid(src, 5, 0.5) + + if !result.Bounds().Eq(src.Bounds()) { + t.Errorf("Bounds changed: got %v, want %v", result.Bounds(), src.Bounds()) + } + }) + + t.Run("sigmoid produces valid range", func(t *testing.T) { + src := createGradientGray(20, 20) + result := Sigmoid(src, 10, 0.5) + + // All values should be in valid range + for y := 0; y < 20; y++ { + for x := 0; x < 20; x++ { + v := result.GrayAt(x, y).Y + if v > 255 { + t.Errorf("Value out of range: %d at (%d,%d)", v, x, y) + return + } + } + } + }) +} + +func TestStretch(t *testing.T) { + t.Run("stretch with zero range returns source", func(t *testing.T) { + src := createTestGray(10, 10, 128) + result := Stretch(src, 0, 1) + + if result != src { + t.Error("Expected source image to be returned for zero range") + } + }) + + t.Run("stretch full range", func(t *testing.T) { + // Create image with limited range (50-200) + img := image.NewGray(image.Rect(0, 0, 10, 10)) + for y := 0; y < 10; y++ { + for x := 0; x < 10; x++ { + v := uint8(50 + float64(x)/9.0*150) + img.SetGray(x, y, color.Gray{Y: v}) + } + } + + result := Stretch(img, 0, 1) + + if result == nil { + t.Fatal("Stretch returned nil") + } + + // First pixel should be near 0, last should be near 255 + first := result.GrayAt(0, 0).Y + last := result.GrayAt(9, 0).Y + + if first > 5 { + t.Errorf("Expected first pixel near 0, got %d", first) + } + if last < 250 { + t.Errorf("Expected last pixel near 255, got %d", last) + } + }) + + t.Run("stretch preserves bounds", func(t *testing.T) { + src := createGradientGray(20, 15) + result := Stretch(src, 0, 1) + + if !result.Bounds().Eq(src.Bounds()) { + t.Errorf("Bounds changed: got %v, want %v", result.Bounds(), src.Bounds()) + } + }) + + t.Run("stretch with partial range", func(t *testing.T) { + src := createGradientGray(20, 20) + result := Stretch(src, 0.25, 0.75) + + if result == nil { + t.Fatal("Stretch returned nil") + } + + // Result should have values across full range + hasLow := false + hasHigh := false + for y := 0; y < 20; y++ { + for x := 0; x < 20; x++ { + v := result.GrayAt(x, y).Y + if v < 50 { + hasLow = true + } + if v > 200 { + hasHigh = true + } + } + } + + if !hasLow || !hasHigh { + t.Error("Expected stretched values across full range") + } + }) +} + +func TestUnsharp(t *testing.T) { + t.Run("unsharp with zero amount returns original", func(t *testing.T) { + src := createGradientGray(10, 10) + result := Unsharp(src, 1, 0) + + if result == nil { + t.Fatal("Unsharp returned nil") + } + + // With zero amount, formula is: orig + 0*(orig-blur) = orig + for y := 0; y < 10; y++ { + for x := 0; x < 10; x++ { + if result.GrayAt(x, y).Y != src.GrayAt(x, y).Y { + t.Errorf("Expected original value at (%d,%d): got %d, want %d", + x, y, result.GrayAt(x, y).Y, src.GrayAt(x, y).Y) + return + } + } + } + }) + + t.Run("unsharp preserves bounds", func(t *testing.T) { + src := createTestGray(20, 15, 100) + result := Unsharp(src, 2, 1.0) + + if !result.Bounds().Eq(src.Bounds()) { + t.Errorf("Bounds changed: got %v, want %v", result.Bounds(), src.Bounds()) + } + }) + + t.Run("unsharp clamps values", func(t *testing.T) { + src := createGradientGray(20, 20) + result := Unsharp(src, 1, 5.0) + + // All values should be in valid range despite high amount + for y := 0; y < 20; y++ { + for x := 0; x < 20; x++ { + v := result.GrayAt(x, y).Y + if v > 255 { + t.Errorf("Value out of range: %d at (%d,%d)", v, x, y) + return + } + } + } + }) +}