266 lines
6.8 KiB
Go
266 lines
6.8 KiB
Go
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)
|
|
}
|
|
}
|