added tests, examples, and direct attribution
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
package filter
|
||||
|
||||
// boosted with gratitude from https://github.com/nobloat/blog
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user