package main import ( "image" "image/color" "math" "testing" ) func TestOtsuThreshold(t *testing.T) { // Bimodal image: left half dark (~50), right half light (~200) img := image.NewGray(image.Rect(0, 0, 200, 200)) for y := 0; y < 200; y++ { for x := 0; x < 100; x++ { v := uint8(40 + (x+y)%20) // dark cluster around 40-59 img.SetGray(x, y, color.Gray{Y: v}) } for x := 100; x < 200; x++ { v := uint8(190 + (x+y)%20) // light cluster around 190-209 img.SetGray(x, y, color.Gray{Y: v}) } } thresh := otsuThreshold(img) if thresh < 55 || thresh > 195 { t.Errorf("otsu threshold %d should separate dark cluster (~40-59) from light cluster (~190-209)", thresh) } } func TestEstimateDPI(t *testing.T) { // Letter paper at 300 DPI: 215.9mm = 8.5in -> 2550px, 279.4mm = 11in -> 3300px dpi := estimateDPI(2550, 3300, 215.9, 279.4) if math.Abs(dpi-300) > 1 { t.Errorf("expected ~300 DPI, got %.1f", dpi) } } func TestMmPxConversion(t *testing.T) { px := mmToPx(25.4, 300) if math.Abs(px-300) > 0.1 { t.Errorf("25.4mm at 300DPI should be 300px, got %.1f", px) } mm := pxToMM(300, 300) if math.Abs(mm-25.4) > 0.1 { t.Errorf("300px at 300DPI should be 25.4mm, got %.1f", mm) } } func TestAffineIdentity(t *testing.T) { src := [4][2]float64{{0, 0}, {100, 0}, {0, 100}, {100, 100}} dst := [4][2]float64{{0, 0}, {100, 0}, {0, 100}, {100, 100}} xform := computeAffine(src, dst) for _, pt := range src { ox, oy := xform.transform(pt[0], pt[1]) if math.Abs(ox-pt[0]) > 0.01 || math.Abs(oy-pt[1]) > 0.01 { t.Errorf("identity transform failed: (%.1f,%.1f) -> (%.4f,%.4f)", pt[0], pt[1], ox, oy) } } } func TestAffineScaleTranslate(t *testing.T) { // Pixels at corners of a 1000x1000 image -> mm positions src := [4][2]float64{{100, 100}, {900, 100}, {100, 900}, {900, 900}} dst := [4][2]float64{{10, 10}, {90, 10}, {10, 90}, {90, 90}} xform := computeAffine(src, dst) ox, oy := xform.transform(500, 500) if math.Abs(ox-50) > 0.1 || math.Abs(oy-50) > 0.1 { t.Errorf("center should map to (50,50), got (%.2f,%.2f)", ox, oy) } } func TestAffineInverse(t *testing.T) { src := [4][2]float64{{50, 50}, {500, 50}, {50, 700}, {500, 700}} dst := [4][2]float64{{15, 15}, {200, 15}, {15, 280}, {200, 280}} xform := computeAffine(src, dst) inv := invertAffine(xform) for i, pt := range src { mx, my := xform.transform(pt[0], pt[1]) rx, ry := inv.transform(mx, my) if math.Abs(rx-pt[0]) > 0.1 || math.Abs(ry-pt[1]) > 0.1 { t.Errorf("roundtrip %d: (%.1f,%.1f) -> (%.2f,%.2f) -> (%.2f,%.2f)", i, pt[0], pt[1], mx, my, rx, ry) } } } func TestDouglasPeucker(t *testing.T) { // Square with many intermediate points on each edge var pts [][2]float64 for i := 0; i <= 10; i++ { pts = append(pts, [2]float64{float64(i) * 10, 0}) } for i := 1; i <= 10; i++ { pts = append(pts, [2]float64{100, float64(i) * 10}) } for i := 9; i >= 0; i-- { pts = append(pts, [2]float64{float64(i) * 10, 100}) } for i := 9; i >= 1; i-- { pts = append(pts, [2]float64{0, float64(i) * 10}) } simplified := douglasPeucker(pts, 1.0) if len(simplified) > 6 { t.Errorf("square should simplify to ~4-5 points, got %d", len(simplified)) } if len(simplified) < 4 { t.Errorf("square should have at least 4 points, got %d", len(simplified)) } } func TestMorphOpen(t *testing.T) { // Create a binary mask with a thin line (1px) and a thick blob (10px diameter) w, h := 100, 100 mask := make([]bool, w*h) // Thin horizontal line at y=20 for x := 10; x < 90; x++ { mask[20*w+x] = true } // Thick filled circle at (50,60) r=8 for y := 0; y < h; y++ { for x := 0; x < w; x++ { dx := float64(x) - 50 dy := float64(y) - 60 if dx*dx+dy*dy <= 64 { mask[y*w+x] = true } } } opened := morphOpen(mask, w, h, 3) // Thin line should be removed thinRemoved := true for x := 20; x < 80; x++ { if opened[20*w+x] { thinRemoved = false break } } if !thinRemoved { t.Error("morphological opening should remove 1px thin line with radius 3") } // Thick circle should survive (at least partially) thickSurvived := false for y := 55; y < 65; y++ { for x := 45; x < 55; x++ { if opened[y*w+x] { thickSurvived = true break } } } if !thickSurvived { t.Error("morphological opening should preserve thick circle") } } func TestFindBlobs(t *testing.T) { w, h := 50, 50 mask := make([]bool, w*h) // Blob 1: small circle at (10,10) for y := 0; y < h; y++ { for x := 0; x < w; x++ { if (x-10)*(x-10)+(y-10)*(y-10) <= 9 { mask[y*w+x] = true } } } // Blob 2: larger circle at (35,35) for y := 0; y < h; y++ { for x := 0; x < w; x++ { if (x-35)*(x-35)+(y-35)*(y-35) <= 36 { mask[y*w+x] = true } } } blobs := findBlobs(mask, w, h) if len(blobs) != 2 { t.Fatalf("expected 2 blobs, got %d", len(blobs)) } // Verify centroids are near expected positions for _, b := range blobs { near10 := math.Abs(b.cx-10) < 2 && math.Abs(b.cy-10) < 2 near35 := math.Abs(b.cx-35) < 2 && math.Abs(b.cy-35) < 2 if !near10 && !near35 { t.Errorf("blob centroid (%.1f,%.1f) not near expected positions", b.cx, b.cy) } } } func TestContourTracing(t *testing.T) { // Filled rectangle w, h := 50, 50 mask := make([]bool, w*h) for y := 10; y < 40; y++ { for x := 10; x < 40; x++ { mask[y*w+x] = true } } contour := traceLargestContour(mask, w, h) if len(contour) < 4 { t.Errorf("rectangle contour should have many boundary points, got %d", len(contour)) } // All contour points should be on the boundary for _, pt := range contour { x, y := pt[0], pt[1] if x < 10 || x >= 40 || y < 10 || y >= 40 { t.Errorf("contour point (%d,%d) outside rectangle bounds", x, y) break } } } func TestAdaptiveThreshold(t *testing.T) { // Dark lines on light background (mimics pen strokes on paper) sz := 200 img := image.NewGray(image.Rect(0, 0, sz, sz)) for y := 0; y < sz; y++ { for x := 0; x < sz; x++ { img.SetGray(x, y, color.Gray{Y: 220}) } } // Thin dark line (5px wide) — like a traced outline for y := 95; y < 105; y++ { for x := 40; x < 160; x++ { img.SetGray(x, y, color.Gray{Y: 30}) } } mask := adaptiveThreshold(img, 51, 15) // Center of dark line should be detected if !mask[100*sz+100] { t.Error("center of dark line should be detected") } // Corner (light area, far from line) should not be detected if mask[10*sz+10] { t.Error("corner (light area) should not be detected") } } func TestPointToSegmentDist(t *testing.T) { // Point above horizontal segment d := pointToSegmentDist([2]float64{5, 3}, [2]float64{0, 0}, [2]float64{10, 0}) if math.Abs(d-3) > 0.01 { t.Errorf("expected distance 3, got %.4f", d) } // Point beyond segment end d = pointToSegmentDist([2]float64{15, 0}, [2]float64{0, 0}, [2]float64{10, 0}) if math.Abs(d-5) > 0.01 { t.Errorf("expected distance 5, got %.4f", d) } }