Former/face_scan_integration_test.go

556 lines
14 KiB
Go

package main
import (
"image"
"image/color"
"image/png"
"math"
"math/rand"
"os"
"path/filepath"
"testing"
)
// --- synthetic scan image builder ---
type syntheticScan struct {
img *image.Gray
dpi float64
cfg FaceTemplateConfig
layout pageLayout
}
func newSyntheticScan(dpi float64, cfg FaceTemplateConfig) *syntheticScan {
if cfg.PageWidth <= 0 {
cfg.PageWidth = 215.9
}
if cfg.PageHeight <= 0 {
cfg.PageHeight = 279.4
}
if cfg.NumFaces < 1 {
cfg.NumFaces = 6
}
if cfg.LongestSide <= 0 {
cfg.LongestSide = 50
}
if cfg.GridSpacing <= 0 {
cfg.GridSpacing = pickGridSpacing(cfg.LongestSide)
}
w := int(mmToPx(cfg.PageWidth, dpi))
h := int(mmToPx(cfg.PageHeight, dpi))
img := image.NewGray(image.Rect(0, 0, w, h))
// White background
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetGray(x, y, color.Gray{Y: 240})
}
}
layouts := computePageLayout(cfg)
var layout pageLayout
if len(layouts) > 0 {
layout = layouts[0]
}
return &syntheticScan{img: img, dpi: dpi, cfg: cfg, layout: layout}
}
func (s *syntheticScan) drawRegistrationMarks() {
margin := 15.0
corners := [4][2]float64{
{margin, margin},
{s.cfg.PageWidth - margin, margin},
{margin, s.cfg.PageHeight - margin},
{s.cfg.PageWidth - margin, s.cfg.PageHeight - margin},
}
circR := mmToPx(0.8, s.dpi)
for _, c := range corners {
cx := mmToPx(c[0], s.dpi)
cy := mmToPx(c[1], s.dpi)
s.fillCircle(int(cx), int(cy), int(circR)+1, 0)
// L-shaped arms
armLen := int(mmToPx(5, s.dpi))
s.drawHLine(int(cx), int(cy), armLen, 0)
s.drawVLine(int(cx), int(cy), armLen, 0)
}
}
func (s *syntheticScan) drawGridLinesInCell(cell faceCell) {
grid := s.cfg.GridSpacing
x0 := int(mmToPx(cell.X, s.dpi))
y0 := int(mmToPx(cell.Y, s.dpi))
x1 := int(mmToPx(cell.X+cell.W, s.dpi))
y1 := int(mmToPx(cell.Y+cell.H, s.dpi))
// Thin grid lines (very light, 1px)
for gx := cell.X + grid; gx < cell.X+cell.W; gx += grid {
px := int(mmToPx(gx, s.dpi))
for py := y0; py < y1; py++ {
s.setPixelSafe(px, py, 210)
}
}
for gy := cell.Y + grid; gy < cell.Y+cell.H; gy += grid {
py := int(mmToPx(gy, s.dpi))
for px := x0; px < x1; px++ {
s.setPixelSafe(px, py, 210)
}
}
// Dashed cell border (medium darkness, thin)
for px := x0; px < x1; px++ {
if (px/6)%2 == 0 {
s.setPixelSafe(px, y0, 80)
s.setPixelSafe(px, y1, 80)
}
}
for py := y0; py < y1; py++ {
if (py/6)%2 == 0 {
s.setPixelSafe(x0, py, 80)
s.setPixelSafe(x1, py, 80)
}
}
}
// drawTracedPolygon draws a thick polygon in a cell (simulates pen tracing).
// Points are in mm relative to cell center.
func (s *syntheticScan) drawTracedPolygon(cell faceCell, outlineMM [][2]float64, thickness int) {
cx := mmToPx(cell.X+cell.W/2, s.dpi)
cy := mmToPx(cell.Y+cell.H/2, s.dpi)
for i := 0; i < len(outlineMM); i++ {
p0 := outlineMM[i]
p1 := outlineMM[(i+1)%len(outlineMM)]
x0 := cx + mmToPx(p0[0], s.dpi)
y0 := cy + mmToPx(p0[1], s.dpi)
x1 := cx + mmToPx(p1[0], s.dpi)
y1 := cy + mmToPx(p1[1], s.dpi)
s.drawThickLine(int(x0), int(y0), int(x1), int(y1), thickness, 20)
}
}
func (s *syntheticScan) drawThickLine(x0, y0, x1, y1, thickness int, value uint8) {
dx := float64(x1 - x0)
dy := float64(y1 - y0)
length := math.Sqrt(dx*dx + dy*dy)
if length < 1 {
return
}
steps := int(length * 2)
for i := 0; i <= steps; i++ {
t := float64(i) / float64(steps)
px := float64(x0) + dx*t
py := float64(y0) + dy*t
for oy := -thickness; oy <= thickness; oy++ {
for ox := -thickness; ox <= thickness; ox++ {
if ox*ox+oy*oy <= thickness*thickness {
s.setPixelSafe(int(px)+ox, int(py)+oy, value)
}
}
}
}
}
func (s *syntheticScan) fillCircle(cx, cy, r int, value uint8) {
for dy := -r; dy <= r; dy++ {
for dx := -r; dx <= r; dx++ {
if dx*dx+dy*dy <= r*r {
s.setPixelSafe(cx+dx, cy+dy, value)
}
}
}
}
func (s *syntheticScan) drawHLine(x, y, length int, value uint8) {
for dx := 0; dx < length; dx++ {
s.setPixelSafe(x+dx, y, value)
}
}
func (s *syntheticScan) drawVLine(x, y, length int, value uint8) {
for dy := 0; dy < length; dy++ {
s.setPixelSafe(x, y+dy, value)
}
}
func (s *syntheticScan) setPixelSafe(x, y int, value uint8) {
b := s.img.Bounds()
if x >= b.Min.X && x < b.Max.X && y >= b.Min.Y && y < b.Max.Y {
s.img.SetGray(x, y, color.Gray{Y: value})
}
}
func (s *syntheticScan) addSaltPepperNoise(density float64, rng *rand.Rand) {
b := s.img.Bounds()
total := b.Dx() * b.Dy()
nPixels := int(float64(total) * density)
for i := 0; i < nPixels; i++ {
x := rng.Intn(b.Dx()) + b.Min.X
y := rng.Intn(b.Dy()) + b.Min.Y
if rng.Intn(2) == 0 {
s.img.SetGray(x, y, color.Gray{Y: 0})
} else {
s.img.SetGray(x, y, color.Gray{Y: 255})
}
}
}
func (s *syntheticScan) addExtraWhitespace(topPx, bottomPx, leftPx, rightPx int) *image.Gray {
ob := s.img.Bounds()
nw := ob.Dx() + leftPx + rightPx
nh := ob.Dy() + topPx + bottomPx
out := image.NewGray(image.Rect(0, 0, nw, nh))
for y := 0; y < nh; y++ {
for x := 0; x < nw; x++ {
out.SetGray(x, y, color.Gray{Y: 255})
}
}
for y := ob.Min.Y; y < ob.Max.Y; y++ {
for x := ob.Min.X; x < ob.Max.X; x++ {
out.SetGray(x+leftPx, y+topPx, s.img.GrayAt(x, y))
}
}
return out
}
func (s *syntheticScan) rotate180() *image.Gray {
b := s.img.Bounds()
w, h := b.Dx(), b.Dy()
out := image.NewGray(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
out.SetGray(w-1-x, h-1-y, s.img.GrayAt(x+b.Min.X, y+b.Min.Y))
}
}
return out
}
func (s *syntheticScan) rotateSmall(angleDeg float64) *image.Gray {
b := s.img.Bounds()
w, h := b.Dx(), b.Dy()
out := image.NewGray(image.Rect(0, 0, w, h))
// Fill with white
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
out.SetGray(x, y, color.Gray{Y: 240})
}
}
rad := angleDeg * math.Pi / 180
cx, cy := float64(w)/2, float64(h)/2
cosA, sinA := math.Cos(rad), math.Sin(rad)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
// inverse rotation to find source pixel
dx := float64(x) - cx
dy := float64(y) - cy
srcX := int(dx*cosA+dy*sinA+cx+0.5) + b.Min.X
srcY := int(-dx*sinA+dy*cosA+cy+0.5) + b.Min.Y
if srcX >= b.Min.X && srcX < b.Max.X && srcY >= b.Min.Y && srcY < b.Max.Y {
out.SetGray(x, y, s.img.GrayAt(srcX, srcY))
}
}
}
return out
}
func (s *syntheticScan) cropEdges(topMM, bottomMM, leftMM, rightMM float64) *image.Gray {
b := s.img.Bounds()
x0 := b.Min.X + int(mmToPx(leftMM, s.dpi))
y0 := b.Min.Y + int(mmToPx(topMM, s.dpi))
x1 := b.Max.X - int(mmToPx(rightMM, s.dpi))
y1 := b.Max.Y - int(mmToPx(bottomMM, s.dpi))
w, h := x1-x0, y1-y0
out := image.NewGray(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
out.SetGray(x, y, s.img.GrayAt(x0+x, y0+y))
}
}
return out
}
func saveTestImage(t *testing.T, img *image.Gray, dir, name string) string {
t.Helper()
path := filepath.Join(dir, name)
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
t.Fatal(err)
}
return path
}
// reference polygon: a diamond shape, 20mm across
var diamondMM = [][2]float64{
{0, -10}, {10, 0}, {0, 10}, {-10, 0},
}
func makeCfg() FaceTemplateConfig {
return FaceTemplateConfig{
NumFaces: 4, LongestSide: 50,
PageWidth: 215.9, PageHeight: 279.4,
}
}
func buildCleanScan(t *testing.T, dpi float64) (*syntheticScan, string) {
t.Helper()
cfg := makeCfg()
s := newSyntheticScan(dpi, cfg)
s.drawRegistrationMarks()
for _, cell := range s.layout.Cells {
s.drawGridLinesInCell(cell)
}
// Draw diamond in face 1
s.drawTracedPolygon(s.layout.Cells[0], diamondMM, int(mmToPx(1.0, dpi)))
dir := t.TempDir()
path := saveTestImage(t, s.img, dir, "scan.png")
return s, path
}
func verifyDiamondExtracted(t *testing.T, faces []TracedFace) {
t.Helper()
if len(faces) == 0 {
t.Fatal("no faces extracted")
}
// Find face 1
var face1 *TracedFace
for i := range faces {
if faces[i].FaceNum == 1 {
face1 = &faces[i]
break
}
}
if face1 == nil {
t.Fatal("face 1 not found in extracted faces")
}
if len(face1.Outline) < 3 {
t.Fatalf("face 1 has too few vertices: %d", len(face1.Outline))
}
// Check that the extracted polygon roughly fits a 20mm diamond
var maxDim float64
for _, pt := range face1.Outline {
d := math.Sqrt(pt[0]*pt[0] + pt[1]*pt[1])
if d > maxDim {
maxDim = d
}
}
// Diamond spans 10mm from center to tip; allow generous tolerance
if maxDim < 5 || maxDim > 20 {
t.Errorf("face 1 max dimension from center = %.1fmm, expected ~10mm", maxDim)
}
}
// --- integration tests ---
func TestScanClean(t *testing.T) {
cfg := makeCfg()
_, path := buildCleanScan(t, 300)
result, err := ProcessFaceScans([]string{path}, cfg)
if err != nil {
t.Fatal(err)
}
if math.Abs(result.DPI-300) > 5 {
t.Errorf("DPI should be ~300, got %.0f", result.DPI)
}
verifyDiamondExtracted(t, result.Faces)
}
func TestScanWithNoise(t *testing.T) {
cfg := makeCfg()
s, _ := buildCleanScan(t, 300)
rng := rand.New(rand.NewSource(42))
s.addSaltPepperNoise(0.005, rng) // 0.5% noise
dir := t.TempDir()
path := saveTestImage(t, s.img, dir, "noisy.png")
result, err := ProcessFaceScans([]string{path}, cfg)
if err != nil {
t.Fatal(err)
}
verifyDiamondExtracted(t, result.Faces)
}
func TestScanAskew(t *testing.T) {
cfg := makeCfg()
s, _ := buildCleanScan(t, 300)
rotated := s.rotateSmall(1.5) // 1.5 degrees
dir := t.TempDir()
path := saveTestImage(t, rotated, dir, "askew.png")
result, err := ProcessFaceScans([]string{path}, cfg)
if err != nil {
t.Fatal(err)
}
// Registration mark detection should compensate via affine transform
verifyDiamondExtracted(t, result.Faces)
}
func TestScanExtraWhitespace(t *testing.T) {
cfg := makeCfg()
s, _ := buildCleanScan(t, 300)
// Add 1 inch of whitespace on all sides
padPx := int(mmToPx(25.4, 300))
padded := s.addExtraWhitespace(padPx, padPx, padPx, padPx)
dir := t.TempDir()
path := saveTestImage(t, padded, dir, "padded.png")
// DPI estimation will be off because image is larger than expected page,
// but registration marks should still be found by searching near expected positions
result, err := ProcessFaceScans([]string{path}, cfg)
if err != nil {
t.Fatal(err)
}
if len(result.Errors) == 0 {
verifyDiamondExtracted(t, result.Faces)
}
}
func TestScanCroppedEdges(t *testing.T) {
cfg := makeCfg()
s, _ := buildCleanScan(t, 300)
// Crop 5mm off top and left — this removes two registration marks
cropped := s.cropEdges(5, 0, 5, 0)
dir := t.TempDir()
path := saveTestImage(t, cropped, dir, "cropped.png")
result, err := ProcessFaceScans([]string{path}, cfg)
if err != nil {
t.Fatal(err)
}
// Should still produce output (fallback to expected positions for missing marks)
// May have errors logged but shouldn't crash
if result == nil {
t.Fatal("result should not be nil even with cropped edges")
}
}
func TestScanUpsideDown(t *testing.T) {
cfg := makeCfg()
s, _ := buildCleanScan(t, 300)
flipped := s.rotate180()
dir := t.TempDir()
path := saveTestImage(t, flipped, dir, "upsidedown.png")
result, err := ProcessFaceScans([]string{path}, cfg)
if err != nil {
t.Fatal(err)
}
// The registration marks are symmetric (all 4 corners have identical marks)
// so the affine transform should still find them, but the cell positions
// will be mirrored. The system should either detect the flip or still extract
// something (even if coordinates are mirrored).
if result == nil {
t.Fatal("result should not be nil for upside-down image")
}
}
func TestScanHeavyNoise(t *testing.T) {
cfg := makeCfg()
s, _ := buildCleanScan(t, 300)
rng := rand.New(rand.NewSource(99))
s.addSaltPepperNoise(0.02, rng) // 2% noise — aggressive
dir := t.TempDir()
path := saveTestImage(t, s.img, dir, "heavy_noise.png")
result, err := ProcessFaceScans([]string{path}, cfg)
if err != nil {
t.Fatal(err)
}
// May not extract perfect outlines but should not crash
if result == nil {
t.Fatal("result should not be nil with heavy noise")
}
}
func TestScanLowDPI(t *testing.T) {
cfg := makeCfg()
s := newSyntheticScan(150, cfg) // low DPI
s.drawRegistrationMarks()
for _, cell := range s.layout.Cells {
s.drawGridLinesInCell(cell)
}
s.drawTracedPolygon(s.layout.Cells[0], diamondMM, int(mmToPx(1.0, 150)))
dir := t.TempDir()
path := saveTestImage(t, s.img, dir, "lowdpi.png")
result, err := ProcessFaceScans([]string{path}, cfg)
if err != nil {
t.Fatal(err)
}
if math.Abs(result.DPI-150) > 5 {
t.Errorf("DPI should be ~150, got %.0f", result.DPI)
}
verifyDiamondExtracted(t, result.Faces)
}
func TestScanHighDPI(t *testing.T) {
cfg := makeCfg()
s := newSyntheticScan(600, cfg)
s.drawRegistrationMarks()
for _, cell := range s.layout.Cells {
s.drawGridLinesInCell(cell)
}
s.drawTracedPolygon(s.layout.Cells[0], diamondMM, int(mmToPx(1.0, 600)))
dir := t.TempDir()
path := saveTestImage(t, s.img, dir, "highdpi.png")
result, err := ProcessFaceScans([]string{path}, cfg)
if err != nil {
t.Fatal(err)
}
if math.Abs(result.DPI-600) > 10 {
t.Errorf("DPI should be ~600, got %.0f", result.DPI)
}
verifyDiamondExtracted(t, result.Faces)
}
func TestScanMultiplePages(t *testing.T) {
cfg := FaceTemplateConfig{
NumFaces: 6, LongestSide: 120,
PageWidth: 215.9, PageHeight: 279.4,
}
layouts := computePageLayout(cfg)
if len(layouts) < 2 {
t.Skip("config doesn't produce multiple pages")
}
var paths []string
dir := t.TempDir()
for pi, page := range layouts {
s := newSyntheticScan(300, cfg)
s.layout = page
s.drawRegistrationMarks()
for _, cell := range page.Cells {
s.drawGridLinesInCell(cell)
s.drawTracedPolygon(cell, diamondMM, int(mmToPx(1.0, 300)))
}
path := saveTestImage(t, s.img, dir, filepath.Base(t.Name())+string(rune('a'+pi))+".png")
paths = append(paths, path)
}
result, err := ProcessFaceScans(paths, cfg)
if err != nil {
t.Fatal(err)
}
if len(result.Faces) < 2 {
t.Errorf("expected faces from multiple pages, got %d", len(result.Faces))
}
}