Former/face_scan.go

875 lines
23 KiB
Go

package main
import (
"fmt"
"image"
"image/color"
_ "image/jpeg"
_ "image/png"
"math"
"os"
"sort"
)
type ScanProcessResult struct {
Faces []TracedFace
DPI float64
Errors []string
}
// ProcessFaceScans processes scanned face template images and extracts traced outlines.
// Each imagePath corresponds to a page (1-indexed by order).
func ProcessFaceScans(imagePaths []string, cfg FaceTemplateConfig) (*ScanProcessResult, error) {
if len(imagePaths) == 0 {
return nil, fmt.Errorf("no images provided")
}
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)
}
layout := computePageLayout(cfg)
result := &ScanProcessResult{}
for i, imgPath := range imagePaths {
pageNum := i + 1
if pageNum > len(layout) {
result.Errors = append(result.Errors, fmt.Sprintf("page %d: no layout (only %d pages expected)", pageNum, len(layout)))
continue
}
debugLog("ProcessFaceScans: page %d from %s", pageNum, imgPath)
faces, dpi, err := processOnePage(imgPath, cfg, layout[i])
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("page %d: %v", pageNum, err))
continue
}
if result.DPI == 0 {
result.DPI = dpi
}
result.Faces = append(result.Faces, faces...)
}
debugLog("ProcessFaceScans: extracted %d faces total", len(result.Faces))
return result, nil
}
func processOnePage(imgPath string, cfg FaceTemplateConfig, page pageLayout) ([]TracedFace, float64, error) {
img, err := loadScanImage(imgPath)
if err != nil {
return nil, 0, err
}
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
dpi := estimateDPI(w, h, cfg.PageWidth, cfg.PageHeight)
debugLog(" image: %dx%d, estimated DPI: %.0f", w, h, dpi)
// Phase 1: detect bullseye fiducial markers
markers := DetectMarkers(img, dpi)
debugLog(" detected %d markers", len(markers))
expectedPos := markerPositionsMM(cfg.PageWidth, cfg.PageHeight)
// Build alignment from detected markers → expected mm positions
var srcPts, dstPts [4][2]float64
nAlignPts := 0
// Match detected markers to expected positions by corner ID
for _, m := range markers {
if m.Data.CornerID >= 0 && m.Data.CornerID <= 3 && nAlignPts < 4 {
srcPts[nAlignPts] = m.PixelCenter
dstPts[nAlignPts] = expectedPos[m.Data.CornerID]
nAlignPts++
}
}
// Fallback: if insufficient markers detected, use the old registration mark approach
if nAlignPts < 3 {
debugLog(" WARNING: only %d markers detected, falling back to registration circles", nAlignPts)
margin := 15.0
oldExpected := [4][2]float64{
{margin, margin},
{cfg.PageWidth - margin, margin},
{margin, cfg.PageHeight - margin},
{cfg.PageWidth - margin, cfg.PageHeight - margin},
}
detectedCorners, err := detectRegistrationMarks(img, dpi, oldExpected)
if err != nil {
return nil, dpi, fmt.Errorf("marker detection failed and registration fallback failed: %w", err)
}
srcPts = detectedCorners
dstPts = oldExpected
nAlignPts = 4
}
xform := computeAffine(srcPts, dstPts)
debugLog(" affine: scale~%.4f, residual~%.2fpx", xform.scale(), xform.residual(srcPts, dstPts, dpi))
// Phase 2: calibrate DPI from data bars (if markers were detected)
if len(markers) >= 3 {
barX := expectedPos[0][0] + float64(markerN)*markerCellMM/2 + 3
barDPI := MeasureCalibBarDPI(img, otsuThreshold(img), xform, barX, cfg.PageHeight-15.0, calibBarSpecs())
if barDPI > 100 {
debugLog(" data bar calibrated DPI: %.1f (was %.1f)", barDPI, dpi)
dpi = barDPI
}
}
// Phase 3: extract face outlines using template-aware erasure
isTemplate := TemplateElementMap(cfg, page)
var faces []TracedFace
for _, cell := range page.Cells {
outline, err := extractFaceOutlineClean(img, xform, cell, dpi, isTemplate)
if err != nil {
debugLog(" face %d: %v", cell.FaceNum, err)
continue
}
if len(outline) >= 3 {
faces = append(faces, TracedFace{FaceNum: cell.FaceNum, Outline: outline})
debugLog(" face %d: %d vertices", cell.FaceNum, len(outline))
}
}
return faces, dpi, nil
}
// --- image loading ---
func loadScanImage(path string) (*image.Gray, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
return toGrayscale(img), nil
}
func toGrayscale(img image.Image) *image.Gray {
bounds := img.Bounds()
gray := image.NewGray(bounds)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
r, g, b, _ := img.At(x, y).RGBA()
lum := uint8((0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b)) / 256)
gray.SetGray(x, y, color.Gray{Y: lum})
}
}
return gray
}
// --- DPI estimation ---
func estimateDPI(pixW, pixH int, pageWmm, pageHmm float64) float64 {
dpiW := float64(pixW) / (pageWmm / 25.4)
dpiH := float64(pixH) / (pageHmm / 25.4)
return (dpiW + dpiH) / 2
}
func mmToPx(mm, dpi float64) float64 { return mm * dpi / 25.4 }
func pxToMM(px, dpi float64) float64 { return px * 25.4 / dpi }
// --- thresholding ---
func otsuThreshold(img *image.Gray) uint8 {
bounds := img.Bounds()
var hist [256]int
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
hist[img.GrayAt(x, y).Y]++
}
}
total := bounds.Dx() * bounds.Dy()
var sumAll float64
for i := 0; i < 256; i++ {
sumAll += float64(i) * float64(hist[i])
}
var sumBg float64
var wBg int
maxVariance := 0.0
bestT := uint8(0)
for t := 0; t < 256; t++ {
wBg += hist[t]
if wBg == 0 {
continue
}
wFg := total - wBg
if wFg == 0 {
break
}
sumBg += float64(t) * float64(hist[t])
meanBg := sumBg / float64(wBg)
meanFg := (sumAll - sumBg) / float64(wFg)
variance := float64(wBg) * float64(wFg) * (meanBg - meanFg) * (meanBg - meanFg)
if variance > maxVariance {
maxVariance = variance
bestT = uint8(t)
}
}
return bestT
}
// adaptiveThreshold applies block-mean adaptive thresholding.
// Returns a binary mask: true = dark (ink), false = light (paper).
func adaptiveThreshold(img *image.Gray, blockSize, offset int) []bool {
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
mask := make([]bool, w*h)
// integral image for fast block mean
integral := make([]int64, (w+1)*(h+1))
stride := w + 1
for y := 0; y < h; y++ {
var rowSum int64
for x := 0; x < w; x++ {
rowSum += int64(img.GrayAt(bounds.Min.X+x, bounds.Min.Y+y).Y)
integral[(y+1)*stride+(x+1)] = integral[y*stride+(x+1)] + rowSum
}
}
half := blockSize / 2
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
x0 := max(0, x-half)
y0 := max(0, y-half)
x1 := min(w, x+half+1)
y1 := min(h, y+half+1)
count := (x1 - x0) * (y1 - y0)
sum := integral[y1*stride+x1] - integral[y0*stride+x1] - integral[y1*stride+x0] + integral[y0*stride+x0]
mean := int(sum / int64(count))
val := int(img.GrayAt(bounds.Min.X+x, bounds.Min.Y+y).Y)
mask[y*w+x] = val < mean-offset
}
}
return mask
}
// --- morphological operations ---
func circularKernel(radius int) []bool {
size := 2*radius + 1
k := make([]bool, size*size)
r2 := float64(radius * radius)
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
dy := float64(y - radius)
dx := float64(x - radius)
k[y*size+x] = dx*dx+dy*dy <= r2
}
}
return k
}
func erode(mask []bool, w, h, radius int) []bool {
kernel := circularKernel(radius)
ksize := 2*radius + 1
out := make([]bool, w*h)
for y := radius; y < h-radius; y++ {
for x := radius; x < w-radius; x++ {
allSet := true
for ky := 0; ky < ksize && allSet; ky++ {
for kx := 0; kx < ksize && allSet; kx++ {
if kernel[ky*ksize+kx] && !mask[(y-radius+ky)*w+(x-radius+kx)] {
allSet = false
}
}
}
out[y*w+x] = allSet
}
}
return out
}
func dilate(mask []bool, w, h, radius int) []bool {
kernel := circularKernel(radius)
ksize := 2*radius + 1
out := make([]bool, w*h)
for y := radius; y < h-radius; y++ {
for x := radius; x < w-radius; x++ {
if !mask[y*w+x] {
continue
}
for ky := 0; ky < ksize; ky++ {
for kx := 0; kx < ksize; kx++ {
if kernel[ky*ksize+kx] {
out[(y-radius+ky)*w+(x-radius+kx)] = true
}
}
}
}
}
return out
}
func morphOpen(mask []bool, w, h, radius int) []bool {
return dilate(erode(mask, w, h, radius), w, h, radius)
}
// --- blob detection (connected components) ---
type blob struct {
cx, cy float64
area int
}
func findBlobs(mask []bool, w, h int) []blob {
labels := make([]int, w*h)
nextLabel := 1
blobPixels := map[int][][2]int{}
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
if !mask[y*w+x] || labels[y*w+x] != 0 {
continue
}
// BFS flood fill
label := nextLabel
nextLabel++
queue := [][2]int{{x, y}}
labels[y*w+x] = label
var pixels [][2]int
for len(queue) > 0 {
p := queue[0]
queue = queue[1:]
pixels = append(pixels, p)
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
nx, ny := p[0]+d[0], p[1]+d[1]
if nx >= 0 && nx < w && ny >= 0 && ny < h && mask[ny*w+nx] && labels[ny*w+nx] == 0 {
labels[ny*w+nx] = label
queue = append(queue, [2]int{nx, ny})
}
}
}
blobPixels[label] = pixels
}
}
var blobs []blob
for _, pixels := range blobPixels {
var sx, sy float64
for _, p := range pixels {
sx += float64(p[0])
sy += float64(p[1])
}
n := float64(len(pixels))
blobs = append(blobs, blob{cx: sx / n, cy: sy / n, area: len(pixels)})
}
return blobs
}
// --- registration mark detection ---
func detectRegistrationMarks(img *image.Gray, dpi float64, expectedMM [4][2]float64) ([4][2]float64, error) {
threshold := otsuThreshold(img)
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
// Expected circle area: pi * r^2 where r = 0.8mm
circRadPx := mmToPx(0.8, dpi)
expectedArea := math.Pi * circRadPx * circRadPx
minArea := int(expectedArea * 0.3)
maxArea := int(expectedArea * 3.0)
searchRadPx := int(mmToPx(20, dpi)) // 20mm search window
var detected [4][2]float64
for ci, mmPt := range expectedMM {
ex := int(mmToPx(mmPt[0], dpi))
ey := int(mmToPx(mmPt[1], dpi))
x0 := max(0, ex-searchRadPx)
y0 := max(0, ey-searchRadPx)
x1 := min(w, ex+searchRadPx)
y1 := min(h, ey+searchRadPx)
roiW, roiH := x1-x0, y1-y0
roiMask := make([]bool, roiW*roiH)
for ry := 0; ry < roiH; ry++ {
for rx := 0; rx < roiW; rx++ {
roiMask[ry*roiW+rx] = img.GrayAt(bounds.Min.X+x0+rx, bounds.Min.Y+y0+ry).Y < threshold
}
}
blobs := findBlobs(roiMask, roiW, roiH)
// Find blob closest to expected position with area in range
bestDist := math.MaxFloat64
bestIdx := -1
for bi, b := range blobs {
if b.area < minArea || b.area > maxArea {
continue
}
// blob coords are relative to ROI
absX := float64(x0) + b.cx
absY := float64(y0) + b.cy
dist := math.Hypot(absX-float64(ex), absY-float64(ey))
if dist < bestDist {
bestDist = dist
bestIdx = bi
}
}
if bestIdx < 0 {
// Fallback: use expected position
debugLog(" WARNING: registration mark %d not found, using expected position", ci)
detected[ci] = [2]float64{float64(ex), float64(ey)}
} else {
b := blobs[bestIdx]
detected[ci] = [2]float64{float64(x0) + b.cx, float64(y0) + b.cy}
debugLog(" mark %d: detected at (%.1f,%.1f), expected (%d,%d), dist=%.1fpx, area=%d",
ci, detected[ci][0], detected[ci][1], ex, ey, bestDist, b.area)
}
}
return detected, nil
}
// --- affine transform ---
type affineTransform struct {
a, b, c float64 // xOut = a*xIn + b*yIn + c
d, e, f float64 // yOut = d*xIn + e*yIn + f
}
// computeAffine computes the least-squares affine transform mapping srcPts (pixels) to dstPts (mm).
func computeAffine(srcPts [4][2]float64, dstPts [4][2]float64) affineTransform {
// Solve for [a,b,c] and [d,e,f] independently via normal equations.
// For each: A * params = b where A is Nx3, params is 3x1, b is Nx1.
// A = [[x0,y0,1],[x1,y1,1],...], bx = [dx0,dx1,...], by = [dy0,dy1,...]
var ata [3][3]float64
var atbx, atby [3]float64
for i := 0; i < 4; i++ {
sx, sy := srcPts[i][0], srcPts[i][1]
dx, dy := dstPts[i][0], dstPts[i][1]
row := [3]float64{sx, sy, 1}
for r := 0; r < 3; r++ {
for c := 0; c < 3; c++ {
ata[r][c] += row[r] * row[c]
}
atbx[r] += row[r] * dx
atby[r] += row[r] * dy
}
}
inv := invert3x3(ata)
var xform affineTransform
for i := 0; i < 3; i++ {
xform.a += inv[0][i] * atbx[i]
xform.b += inv[1][i] * atbx[i]
xform.c += inv[2][i] * atbx[i]
xform.d += inv[0][i] * atby[i]
xform.e += inv[1][i] * atby[i]
xform.f += inv[2][i] * atby[i]
}
return xform
}
func invert3x3(m [3][3]float64) [3][3]float64 {
det := m[0][0]*(m[1][1]*m[2][2]-m[1][2]*m[2][1]) -
m[0][1]*(m[1][0]*m[2][2]-m[1][2]*m[2][0]) +
m[0][2]*(m[1][0]*m[2][1]-m[1][1]*m[2][0])
if math.Abs(det) < 1e-12 {
return [3][3]float64{{1, 0, 0}, {0, 1, 0}, {0, 0, 1}}
}
invDet := 1.0 / det
return [3][3]float64{
{(m[1][1]*m[2][2] - m[1][2]*m[2][1]) * invDet, (m[0][2]*m[2][1] - m[0][1]*m[2][2]) * invDet, (m[0][1]*m[1][2] - m[0][2]*m[1][1]) * invDet},
{(m[1][2]*m[2][0] - m[1][0]*m[2][2]) * invDet, (m[0][0]*m[2][2] - m[0][2]*m[2][0]) * invDet, (m[0][2]*m[1][0] - m[0][0]*m[1][2]) * invDet},
{(m[1][0]*m[2][1] - m[1][1]*m[2][0]) * invDet, (m[0][1]*m[2][0] - m[0][0]*m[2][1]) * invDet, (m[0][0]*m[1][1] - m[0][1]*m[1][0]) * invDet},
}
}
func (t affineTransform) transform(px, py float64) (float64, float64) {
return t.a*px + t.b*py + t.c, t.d*px + t.e*py + t.f
}
func (t affineTransform) scale() float64 {
return math.Sqrt(t.a*t.a + t.d*t.d)
}
func (t affineTransform) residual(srcPts [4][2]float64, dstPts [4][2]float64, dpi float64) float64 {
var sum float64
for i := 0; i < 4; i++ {
ox, oy := t.transform(srcPts[i][0], srcPts[i][1])
dx := ox - dstPts[i][0]
dy := oy - dstPts[i][1]
sum += mmToPx(math.Sqrt(dx*dx+dy*dy), dpi)
}
return sum / 4
}
// invertAffine returns the inverse transform (mm -> pixels).
func invertAffine(t affineTransform) affineTransform {
det := t.a*t.e - t.b*t.d
if math.Abs(det) < 1e-12 {
return t
}
invDet := 1.0 / det
return affineTransform{
a: t.e * invDet,
b: -t.b * invDet,
c: (t.b*t.f - t.e*t.c) * invDet,
d: -t.d * invDet,
e: t.a * invDet,
f: (t.d*t.c - t.a*t.f) * invDet,
}
}
// --- face outline extraction (new: template-aware) ---
// extractFaceOutlineClean uses template element erasure instead of morphological hacks.
// It thresholds the cell area, erases all known template elements by position, then
// traces the remaining contour — which is the user's hand-drawn outline.
func extractFaceOutlineClean(img *image.Gray, xform affineTransform, cell faceCell, dpi float64, isTemplate func(float64, float64) bool) ([][2]float64, error) {
inv := invertAffine(xform)
pad := 2.0
mmX0, mmY0 := cell.X-pad, cell.Y-pad
mmX1, mmY1 := cell.X+cell.W+pad, cell.Y+cell.H+pad
corners := [4][2]float64{
{mmX0, mmY0}, {mmX1, mmY0}, {mmX0, mmY1}, {mmX1, mmY1},
}
var pxMinX, pxMinY float64 = math.MaxFloat64, math.MaxFloat64
var pxMaxX, pxMaxY float64 = -math.MaxFloat64, -math.MaxFloat64
for _, c := range corners {
px, py := inv.transform(c[0], c[1])
pxMinX = math.Min(pxMinX, px)
pxMinY = math.Min(pxMinY, py)
pxMaxX = math.Max(pxMaxX, px)
pxMaxY = math.Max(pxMaxY, py)
}
bounds := img.Bounds()
cropX0 := max(bounds.Min.X, int(pxMinX))
cropY0 := max(bounds.Min.Y, int(pxMinY))
cropX1 := min(bounds.Max.X, int(pxMaxX)+1)
cropY1 := min(bounds.Max.Y, int(pxMaxY)+1)
cropW := cropX1 - cropX0
cropH := cropY1 - cropY0
if cropW < 10 || cropH < 10 {
return nil, fmt.Errorf("cell crop too small: %dx%d", cropW, cropH)
}
sub := image.NewGray(image.Rect(0, 0, cropW, cropH))
for y := 0; y < cropH; y++ {
for x := 0; x < cropW; x++ {
sub.SetGray(x, y, img.GrayAt(cropX0+x, cropY0+y))
}
}
blockSize := max(31, int(mmToPx(10, dpi))|1)
if blockSize%2 == 0 {
blockSize++
}
binaryMask := adaptiveThreshold(sub, blockSize, 15)
// Template-aware erasure: remove all known template elements by their computed positions
EraseTemplateFromMask(binaryMask, cropW, cropH, xform, isTemplate, cropX0, cropY0)
// Light morphological opening to clean up noise (NOT for removing template elements)
noiseRadius := max(1, int(math.Round(mmToPx(0.15, dpi))))
cleaned := morphOpen(binaryMask, cropW, cropH, noiseRadius)
contour := traceLargestContour(cleaned, cropW, cropH)
if len(contour) < 3 {
return nil, fmt.Errorf("no contour found")
}
fContour := make([][2]float64, len(contour))
for i, pt := range contour {
fContour[i] = [2]float64{float64(pt[0] + cropX0), float64(pt[1] + cropY0)}
}
tolerance := mmToPx(0.5, dpi)
simplified := douglasPeucker(fContour, tolerance)
if len(simplified) < 3 {
return nil, fmt.Errorf("contour too simple after DP: %d points", len(simplified))
}
cellCenterX := cell.X + cell.W/2
cellCenterY := cell.Y + cell.H/2
mmOutline := make([][2]float64, len(simplified))
for i, pt := range simplified {
mx, my := xform.transform(pt[0], pt[1])
mmOutline[i] = [2]float64{mx - cellCenterX, my - cellCenterY}
}
return mmOutline, nil
}
// --- face outline extraction (legacy fallback) ---
func extractFaceOutline(img *image.Gray, xform affineTransform, cell faceCell, dpi float64) ([][2]float64, error) {
inv := invertAffine(xform)
// Cell bounds in mm (with padding)
pad := 2.0 // mm extra around cell
mmX0, mmY0 := cell.X-pad, cell.Y-pad
mmX1, mmY1 := cell.X+cell.W+pad, cell.Y+cell.H+pad
// Convert corners to pixel coords
corners := [4][2]float64{
{mmX0, mmY0}, {mmX1, mmY0}, {mmX0, mmY1}, {mmX1, mmY1},
}
var pxMinX, pxMinY float64 = math.MaxFloat64, math.MaxFloat64
var pxMaxX, pxMaxY float64 = -math.MaxFloat64, -math.MaxFloat64
for _, c := range corners {
px, py := inv.transform(c[0], c[1])
pxMinX = math.Min(pxMinX, px)
pxMinY = math.Min(pxMinY, py)
pxMaxX = math.Max(pxMaxX, px)
pxMaxY = math.Max(pxMaxY, py)
}
bounds := img.Bounds()
cropX0 := max(bounds.Min.X, int(pxMinX))
cropY0 := max(bounds.Min.Y, int(pxMinY))
cropX1 := min(bounds.Max.X, int(pxMaxX)+1)
cropY1 := min(bounds.Max.Y, int(pxMaxY)+1)
cropW := cropX1 - cropX0
cropH := cropY1 - cropY0
if cropW < 10 || cropH < 10 {
return nil, fmt.Errorf("cell crop too small: %dx%d", cropW, cropH)
}
// Extract sub-image
sub := image.NewGray(image.Rect(0, 0, cropW, cropH))
for y := 0; y < cropH; y++ {
for x := 0; x < cropW; x++ {
sub.SetGray(x, y, img.GrayAt(cropX0+x, cropY0+y))
}
}
// Adaptive threshold
blockSize := max(31, int(mmToPx(10, dpi))|1) // ensure odd
if blockSize%2 == 0 {
blockSize++
}
binaryMask := adaptiveThreshold(sub, blockSize, 15)
// Morphological opening to remove template elements:
// Bracket corners are 1.0mm stroke, dashed border is 0.6mm stroke.
// Erosion radius must exceed half the bracket width to fully remove it.
// At 600 DPI: 0.6mm = 14px, 1.0mm = 24px → need > 12px erosion.
// User's traced line (Sharpie/pen) is ~2-3mm = 47-71px → survives easily.
erosionRadius := max(3, int(math.Round(mmToPx(0.6, dpi))))
cleaned := morphOpen(binaryMask, cropW, cropH, erosionRadius)
// Erase anything within 3mm of cell edges to remove residual bracket/border fragments.
maskMarginMM := 3.0
for py := 0; py < cropH; py++ {
for px := 0; px < cropW; px++ {
if !cleaned[py*cropW+px] {
continue
}
mmX, mmY := xform.transform(float64(px+cropX0), float64(py+cropY0))
if mmX-cell.X < maskMarginMM || cell.X+cell.W-mmX < maskMarginMM ||
mmY-cell.Y < maskMarginMM || cell.Y+cell.H-mmY < maskMarginMM {
cleaned[py*cropW+px] = false
}
}
}
// Find the largest contour
contour := traceLargestContour(cleaned, cropW, cropH)
if len(contour) < 3 {
return nil, fmt.Errorf("no contour found")
}
// Convert pixel contour to float
fContour := make([][2]float64, len(contour))
for i, pt := range contour {
fContour[i] = [2]float64{float64(pt[0] + cropX0), float64(pt[1] + cropY0)}
}
// Simplify
tolerance := mmToPx(0.5, dpi)
simplified := douglasPeucker(fContour, tolerance)
if len(simplified) < 3 {
return nil, fmt.Errorf("contour too simple after DP: %d points", len(simplified))
}
// Convert to mm relative to cell center
cellCenterX := cell.X + cell.W/2
cellCenterY := cell.Y + cell.H/2
mmOutline := make([][2]float64, len(simplified))
for i, pt := range simplified {
mx, my := xform.transform(pt[0], pt[1])
mmOutline[i] = [2]float64{mx - cellCenterX, my - cellCenterY}
}
return mmOutline, nil
}
// --- contour tracing (Moore neighborhood) ---
func traceLargestContour(mask []bool, w, h int) [][2]int {
// Find all outer contours, return the largest by area
visited := make([]bool, w*h)
var largest [][2]int
for y := 1; y < h-1; y++ {
for x := 1; x < w-1; x++ {
if !mask[y*w+x] || visited[y*w+x] {
continue
}
// Check if it's a boundary pixel (has at least one non-set neighbor)
isBoundary := false
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
nx, ny := x+d[0], y+d[1]
if !mask[ny*w+nx] {
isBoundary = true
break
}
}
if !isBoundary {
continue
}
contour := mooreTrace(mask, w, h, x, y)
for _, pt := range contour {
visited[pt[1]*w+pt[0]] = true
}
if len(contour) > len(largest) {
largest = contour
}
}
}
return largest
}
func mooreTrace(mask []bool, w, h, startX, startY int) [][2]int {
// 8-connected Moore neighborhood tracing
// Direction offsets: 0=E, 1=SE, 2=S, 3=SW, 4=W, 5=NW, 6=N, 7=NE
dx := [8]int{1, 1, 0, -1, -1, -1, 0, 1}
dy := [8]int{0, 1, 1, 1, 0, -1, -1, -1}
var contour [][2]int
contour = append(contour, [2]int{startX, startY})
// Start direction: came from the west (direction 0 is east)
dir := 7 // start looking NE (entry from west means backtrack = west = dir 4, so start at (4+1)%8 = 5... actually standard: start at (backtrack+1) mod 8)
// Standard: if we found the pixel by scanning left-to-right, entry was from west, so backtrack direction is 4 (W), start searching at (4+1)%8 = 5 (NW)
dir = 5
cx, cy := startX, startY
maxIter := w * h * 2
for i := 0; i < maxIter; i++ {
found := false
for j := 0; j < 8; j++ {
nd := (dir + j) % 8
nx, ny := cx+dx[nd], cy+dy[nd]
if nx >= 0 && nx < w && ny >= 0 && ny < h && mask[ny*w+nx] {
cx, cy = nx, ny
contour = append(contour, [2]int{cx, cy})
// Backtrack direction
dir = (nd + 5) % 8 // opposite of nd is (nd+4)%8, start at (nd+4+1)%8 = (nd+5)%8
found = true
break
}
}
if !found || (cx == startX && cy == startY) {
break
}
}
return contour
}
// --- Douglas-Peucker simplification ---
func douglasPeucker(pts [][2]float64, epsilon float64) [][2]float64 {
if len(pts) <= 2 {
return pts
}
maxDist := 0.0
maxIdx := 0
end := len(pts) - 1
for i := 1; i < end; i++ {
d := pointToSegmentDist(pts[i], pts[0], pts[end])
if d > maxDist {
maxDist = d
maxIdx = i
}
}
if maxDist > epsilon {
left := douglasPeucker(pts[:maxIdx+1], epsilon)
right := douglasPeucker(pts[maxIdx:], epsilon)
return append(left[:len(left)-1], right...)
}
return [][2]float64{pts[0], pts[end]}
}
func pointToSegmentDist(p, a, b [2]float64) float64 {
abx, aby := b[0]-a[0], b[1]-a[1]
apx, apy := p[0]-a[0], p[1]-a[1]
ab2 := abx*abx + aby*aby
if ab2 < 1e-12 {
return math.Hypot(apx, apy)
}
t := (apx*abx + apy*aby) / ab2
t = math.Max(0, math.Min(1, t))
projX := a[0] + t*abx
projY := a[1] + t*aby
return math.Hypot(p[0]-projX, p[1]-projY)
}
// --- utility ---
// sortFaces sorts traced faces by face number.
func sortFaces(faces []TracedFace) {
sort.Slice(faces, func(i, j int) bool {
return faces[i].FaceNum < faces[j].FaceNum
})
}