added tests, examples, and direct attribution

This commit is contained in:
2025-12-27 14:23:54 -08:00
parent a4ab096e00
commit be25fab6ab
3 changed files with 525 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
package filter
// boosted with gratitude from https://github.com/nobloat/blog
+63
View File
@@ -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)
}
}
+459
View File
@@ -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
}
}
}
})
}