875 lines
23 KiB
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
|
|
})
|
|
}
|