package main import ( "fmt" "image" "math" "strings" ) const ( markerCellMM = 1.0 bullseyeN = 5 markerN = 7 // 5×5 bullseye + 1-cell data ring dataBitCount = 24 calibBarCells = 8 calibBarHeight = 5.0 // mm calibBarGap = 3.0 // mm between bars ) const ( CornerTL = 0 CornerTR = 1 CornerBL = 2 CornerBR = 3 CornerCenter = 4 ) type MarkerData struct { PageNum int CornerID int NumFaces int LongestMM int } type DetectedMarker struct { PixelCenter [2]float64 CellSizePx float64 Data MarkerData Rotation int } // bullseyePattern returns the 5×5 finder core. true = black. // Concentric squares: ring 0 (center) = black, ring 1 = white, ring 2 = black. func bullseyePattern() [bullseyeN][bullseyeN]bool { var p [bullseyeN][bullseyeN]bool for r := 0; r < bullseyeN; r++ { for c := 0; c < bullseyeN; c++ { dr := r - 2 dc := c - 2 if dr < 0 { dr = -dr } if dc < 0 { dc = -dc } ring := dr if dc > ring { ring = dc } p[r][c] = ring%2 == 0 } } return p } // dataRingCells returns (row,col) positions of the 24 perimeter cells of the 7×7 grid, // clockwise from top-left. func dataRingCells() [dataBitCount][2]int { var cells [dataBitCount][2]int idx := 0 for c := 0; c < markerN; c++ { cells[idx] = [2]int{0, c} idx++ } for r := 1; r < markerN-1; r++ { cells[idx] = [2]int{r, markerN - 1} idx++ } for c := markerN - 1; c >= 0; c-- { cells[idx] = [2]int{markerN - 1, c} idx++ } for r := markerN - 2; r >= 1; r-- { cells[idx] = [2]int{r, 0} idx++ } return cells } // === Encoding === // Bit layout (24 bits): // [0:2] orientation: 1,1,0 // [3:5] corner ID (3 bits) // [6:9] page number (4 bits) // [10:14] num faces (5 bits) // [15:21] longest side / 5 (7 bits → 0-635mm) // [22] reserved (0) // [23] parity func encodeMarkerBits(d MarkerData) [dataBitCount]bool { var bits [dataBitCount]bool bits[0] = true bits[1] = true bits[2] = false for i := 0; i < 3; i++ { bits[3+i] = (d.CornerID>>uint(2-i))&1 == 1 } for i := 0; i < 4; i++ { bits[6+i] = (d.PageNum>>uint(3-i))&1 == 1 } for i := 0; i < 5; i++ { bits[10+i] = (d.NumFaces>>uint(4-i))&1 == 1 } ls := d.LongestMM / 5 if ls > 127 { ls = 127 } for i := 0; i < 7; i++ { bits[15+i] = (ls>>uint(6-i))&1 == 1 } bits[22] = false parity := false for i := 0; i < 23; i++ { if bits[i] { parity = !parity } } bits[23] = parity return bits } func decodeMarkerBits(bits [dataBitCount]bool) (MarkerData, bool) { parity := false for i := 0; i < 23; i++ { if bits[i] { parity = !parity } } if bits[23] != parity { return MarkerData{}, false } if !(bits[0] && bits[1] && !bits[2]) { return MarkerData{}, false } var d MarkerData for i := 0; i < 3; i++ { if bits[3+i] { d.CornerID |= 1 << uint(2-i) } } for i := 0; i < 4; i++ { if bits[6+i] { d.PageNum |= 1 << uint(3-i) } } for i := 0; i < 5; i++ { if bits[10+i] { d.NumFaces |= 1 << uint(4-i) } } ls := 0 for i := 0; i < 7; i++ { if bits[15+i] { ls |= 1 << uint(6-i) } } d.LongestMM = ls * 5 return d, true } func encodeMarkerGrid(d MarkerData) [markerN][markerN]bool { var grid [markerN][markerN]bool bull := bullseyePattern() for r := 0; r < bullseyeN; r++ { for c := 0; c < bullseyeN; c++ { grid[r+1][c+1] = bull[r][c] } } bits := encodeMarkerBits(d) cells := dataRingCells() for i := 0; i < dataBitCount; i++ { grid[cells[i][0]][cells[i][1]] = bits[i] } return grid } // === SVG generation === func renderMarkerSVG(b *strings.Builder, cx, cy float64, data MarkerData) { grid := encodeMarkerGrid(data) half := float64(markerN) * markerCellMM / 2.0 for r := 0; r < markerN; r++ { for c := 0; c < markerN; c++ { if grid[r][c] { x := cx - half + float64(c)*markerCellMM y := cy - half + float64(r)*markerCellMM b.WriteString(fmt.Sprintf( ``, x, y, markerCellMM, markerCellMM)) b.WriteString("\n") } } } } // CalibBar defines an encoded calibration barcode. // WidthMM is both the physical width on the page and the encoded value. type CalibBar struct { WidthMM int } // encodeCalibBar returns the 8-cell pattern for a calibration bar. // Cell 0: start (black), Cells 1-6: value in 6-bit binary, Cell 7: stop (black). func encodeCalibBar(widthMM int) [calibBarCells]bool { var cells [calibBarCells]bool cells[0] = true cells[calibBarCells-1] = true for i := 0; i < 6; i++ { cells[1+i] = (widthMM>>uint(5-i))&1 == 1 } return cells } func decodeCalibBar(cells [calibBarCells]bool) (int, bool) { if !cells[0] || !cells[calibBarCells-1] { return 0, false } val := 0 for i := 0; i < 6; i++ { if cells[1+i] { val |= 1 << uint(5-i) } } return val, true } // renderCalibBarsSVG renders encoded calibration barcodes. // Each bar's total width = its encoded mm value. Internal cell pattern // encodes the value in binary for machine reading. func renderCalibBarsSVG(b *strings.Builder, x, y float64, bars []CalibBar) { bx := x for _, bar := range bars { cells := encodeCalibBar(bar.WidthMM) cellW := float64(bar.WidthMM) / float64(calibBarCells) for c := 0; c < calibBarCells; c++ { if cells[c] { b.WriteString(fmt.Sprintf( ``, bx+float64(c)*cellW, y, cellW, calibBarHeight)) b.WriteString("\n") } } b.WriteString(fmt.Sprintf( `%dmm`, bx+float64(bar.WidthMM)/2, y+calibBarHeight+2.0, bar.WidthMM)) b.WriteString("\n") bx += float64(bar.WidthMM) + calibBarGap } } // markerPositionsMM returns the expected mm positions of the 4 corner markers on a page. func markerPositionsMM(pageW, pageH float64) [4][2]float64 { m := float64(markerN)*markerCellMM/2 + 5.0 return [4][2]float64{ {m, m}, // TL {pageW - m, m}, // TR {m, pageH - m}, // BL {pageW - m, pageH - m}, // BR } } func calibBarSpecs() []CalibBar { return []CalibBar{{5}, {10}, {20}, {50}} } // === Detection === type pixelRun struct { start int width int black bool } func findHorizontalRuns(img *image.Gray, y int, threshold uint8) []pixelRun { bounds := img.Bounds() w := bounds.Dx() if w == 0 { return nil } var runs []pixelRun prevBlack := img.GrayAt(bounds.Min.X, bounds.Min.Y+y).Y < threshold runStart := 0 for x := 1; x < w; x++ { isBlack := img.GrayAt(bounds.Min.X+x, bounds.Min.Y+y).Y < threshold if isBlack != prevBlack { runs = append(runs, pixelRun{start: runStart, width: x - runStart, black: prevBlack}) runStart = x prevBlack = isBlack } } runs = append(runs, pixelRun{start: runStart, width: w - runStart, black: prevBlack}) return runs } func findVerticalRuns(img *image.Gray, x int, threshold uint8) []pixelRun { bounds := img.Bounds() h := bounds.Dy() if h == 0 { return nil } var runs []pixelRun prevBlack := img.GrayAt(bounds.Min.X+x, bounds.Min.Y).Y < threshold runStart := 0 for y := 1; y < h; y++ { isBlack := img.GrayAt(bounds.Min.X+x, bounds.Min.Y+y).Y < threshold if isBlack != prevBlack { runs = append(runs, pixelRun{start: runStart, width: y - runStart, black: prevBlack}) runStart = y prevBlack = isBlack } } runs = append(runs, pixelRun{start: runStart, width: h - runStart, black: prevBlack}) return runs } // findBullseyeInRuns looks for B-W-B-W-B sequences with roughly 1:1:1:1:1 ratio. // Returns candidate centers (pixel coord along the scan direction) and estimated cell size. type bullseyeCandidate struct { center float64 cellSizePx float64 } func findBullseyeInRuns(runs []pixelRun, minCellPx, maxCellPx float64) []bullseyeCandidate { var candidates []bullseyeCandidate for i := 0; i+4 < len(runs); i++ { if !runs[i].black { continue } ok := true for j := 0; j < 5; j++ { if runs[i+j].black != (j%2 == 0) { ok = false break } } if !ok { continue } // Use the inner 3 runs (W-B-W) to estimate cell size, since the outer // B runs may be wider due to adjacent black features (data ring cells). innerTotal := float64(runs[i+1].width + runs[i+2].width + runs[i+3].width) cellEst := innerTotal / 3.0 if cellEst < minCellPx || cellEst > maxCellPx { continue } // Inner runs must be uniform (within 40% of each other) innerOK := true for j := 1; j <= 3; j++ { ratio := float64(runs[i+j].width) / cellEst if ratio < 0.6 || ratio > 1.4 { innerOK = false break } } if !innerOK { continue } // Outer B runs: must be at least 0.5x cell and at most 2.5x cell // (allows for one adjacent data ring cell merging) for _, j := range []int{0, 4} { ratio := float64(runs[i+j].width) / cellEst if ratio < 0.5 || ratio > 2.5 { innerOK = false break } } if !innerOK { continue } // Center of the middle (3rd) run pos := float64(runs[i].start) for j := 0; j < 2; j++ { pos += float64(runs[i+j].width) } pos += float64(runs[i+2].width) / 2.0 candidates = append(candidates, bullseyeCandidate{center: pos, cellSizePx: cellEst}) } return candidates } // DetectMarkers finds all fiducial markers in a grayscale image. func DetectMarkers(img *image.Gray, dpiEstimate float64) []DetectedMarker { bounds := img.Bounds() w, h := bounds.Dx(), bounds.Dy() threshold := otsuThreshold(img) expectedCellPx := markerCellMM * dpiEstimate / 25.4 minCell := expectedCellPx * 0.4 maxCell := expectedCellPx * 2.5 debugLog("DetectMarkers: image %dx%d, dpiEst=%.0f, expectedCell=%.1fpx, threshold=%d", w, h, dpiEstimate, expectedCellPx, threshold) // Phase 1: horizontal scan for B-W-B-W-B pattern type candidate struct { x, y float64 cellSizePx float64 } var hCandidates []candidate step := max(1, int(expectedCellPx/3)) for y := 0; y < h; y += step { runs := findHorizontalRuns(img, y, threshold) for _, bc := range findBullseyeInRuns(runs, minCell, maxCell) { hCandidates = append(hCandidates, candidate{x: bc.center, y: float64(y), cellSizePx: bc.cellSizePx}) } } debugLog("DetectMarkers: %d horizontal candidates", len(hCandidates)) // Phase 2: verify each candidate with vertical cross-section type verifiedCenter struct { x, y float64 cellSizePx float64 } var verified []verifiedCenter for _, hc := range hCandidates { ix := int(hc.x) if ix < 0 || ix >= w { continue } vRuns := findVerticalRuns(img, ix, threshold) vCands := findBullseyeInRuns(vRuns, minCell, maxCell) for _, vc := range vCands { if math.Abs(vc.center-hc.y) < hc.cellSizePx*2 { avgCell := (hc.cellSizePx + vc.cellSizePx) / 2 verified = append(verified, verifiedCenter{ x: hc.x, y: (hc.y + vc.center) / 2, cellSizePx: avgCell, }) } } } debugLog("DetectMarkers: %d verified centers", len(verified)) // Phase 3: cluster nearby detections used := make([]bool, len(verified)) var clusters []verifiedCenter clusterRadius := expectedCellPx * 3 for i := range verified { if used[i] { continue } cx, cy, cs := verified[i].x, verified[i].y, verified[i].cellSizePx count := 1.0 used[i] = true for j := i + 1; j < len(verified); j++ { if used[j] { continue } dx := verified[j].x - cx dy := verified[j].y - cy if math.Sqrt(dx*dx+dy*dy) < clusterRadius { cx = (cx*count + verified[j].x) / (count + 1) cy = (cy*count + verified[j].y) / (count + 1) cs = (cs*count + verified[j].cellSizePx) / (count + 1) count++ used[j] = true } } clusters = append(clusters, verifiedCenter{x: cx, y: cy, cellSizePx: cs}) } debugLog("DetectMarkers: %d clusters", len(clusters)) // Phase 4: read grid and decode data for each cluster var markers []DetectedMarker for _, cl := range clusters { grid := readMarkerGrid(img, [2]float64{cl.x, cl.y}, cl.cellSizePx, threshold) data, rot, ok := decodeMarkerFromGrid(grid) if ok { markers = append(markers, DetectedMarker{ PixelCenter: [2]float64{cl.x, cl.y}, CellSizePx: cl.cellSizePx, Data: data, Rotation: rot, }) debugLog(" marker decoded: corner=%d page=%d faces=%d longest=%dmm rot=%d at (%.0f,%.0f)", data.CornerID, data.PageNum, data.NumFaces, data.LongestMM, rot, cl.x, cl.y) } } debugLog("DetectMarkers: %d markers decoded", len(markers)) return markers } func readMarkerGrid(img *image.Gray, center [2]float64, cellPx float64, threshold uint8) [markerN][markerN]bool { var grid [markerN][markerN]bool halfN := float64(markerN) / 2.0 bounds := img.Bounds() for r := 0; r < markerN; r++ { for c := 0; c < markerN; c++ { px := int(center[0] + (float64(c)-halfN+0.5)*cellPx) py := int(center[1] + (float64(r)-halfN+0.5)*cellPx) if px >= bounds.Min.X && px < bounds.Max.X && py >= bounds.Min.Y && py < bounds.Max.Y { grid[r][c] = img.GrayAt(px, py).Y < threshold } } } return grid } func rotateGrid90CW(g [markerN][markerN]bool) [markerN][markerN]bool { var rot [markerN][markerN]bool for r := 0; r < markerN; r++ { for c := 0; c < markerN; c++ { rot[c][markerN-1-r] = g[r][c] } } return rot } func verifyBullseye(g [markerN][markerN]bool) bool { bull := bullseyePattern() errors := 0 for r := 0; r < bullseyeN; r++ { for c := 0; c < bullseyeN; c++ { if g[r+1][c+1] != bull[r][c] { errors++ } } } return errors <= 3 } func extractDataRing(g [markerN][markerN]bool) [dataBitCount]bool { cells := dataRingCells() var bits [dataBitCount]bool for i := 0; i < dataBitCount; i++ { bits[i] = g[cells[i][0]][cells[i][1]] } return bits } func decodeMarkerFromGrid(grid [markerN][markerN]bool) (MarkerData, int, bool) { g := grid for rot := 0; rot < 4; rot++ { if verifyBullseye(g) { bits := extractDataRing(g) data, ok := decodeMarkerBits(bits) if ok { return data, rot, true } } g = rotateGrid90CW(g) } return MarkerData{}, 0, false } // === Calibration bar measurement === // MeasureCalibBarDPI measures encoded calibration barcodes and returns precise DPI. // Finds the first and last black pixels of each bar (start/stop delimiters are always black). func MeasureCalibBarDPI(img *image.Gray, threshold uint8, xform affineTransform, barX, barY float64, bars []CalibBar) float64 { inv := invertAffine(xform) var sumPxPerMM float64 count := 0 bx := barX for _, bar := range bars { cy := barY + calibBarHeight/2 leftPxF, pyF := inv.transform(bx, cy) rightPxF, _ := inv.transform(bx+float64(bar.WidthMM), cy) py := int(pyF) bounds := img.Bounds() if py < bounds.Min.Y || py >= bounds.Max.Y { bx += float64(bar.WidthMM) + calibBarGap continue } // Margin must be less than half the gap (1.5mm) to avoid adjacent bar overlap pxPerMM := math.Abs(rightPxF-leftPxF) / float64(bar.WidthMM) marginPx := pxPerMM * 1.0 searchL := max(bounds.Min.X, int(math.Min(leftPxF, rightPxF)-marginPx)) searchR := min(bounds.Max.X-1, int(math.Max(leftPxF, rightPxF)+marginPx)) firstBlack := -1 lastBlack := -1 for px := searchL; px <= searchR; px++ { if img.GrayAt(px, py).Y < threshold { if firstBlack < 0 { firstBlack = px } lastBlack = px } } if firstBlack >= 0 && lastBlack > firstBlack { measuredPx := float64(lastBlack - firstBlack) if measuredPx > 5 { sumPxPerMM += measuredPx / float64(bar.WidthMM) count++ debugLog(" calibbar %dmm: measured %.1fpx → %.1f px/mm", bar.WidthMM, measuredPx, measuredPx/float64(bar.WidthMM)) } } bx += float64(bar.WidthMM) + calibBarGap } if count == 0 { return 0 } return (sumPxPerMM / float64(count)) * 25.4 } // === Template erasure === // TemplateElementMap builds a lookup for fast "is this pixel a template element?" checks. // Returns a function that takes mm coordinates and returns true if it's a template element. func TemplateElementMap(cfg FaceTemplateConfig, page pageLayout) func(mmX, mmY float64) bool { markerPos := markerPositionsMM(cfg.PageWidth, cfg.PageHeight) markerHalf := float64(markerN)*markerCellMM/2 + 1.0 barX := markerPos[0][0] + float64(markerN)*markerCellMM/2 + 3 barY := cfg.PageHeight - 15.0 bars := calibBarSpecs() type barRect struct{ x, y, w, h float64 } var barRects []barRect bx := barX for _, bar := range bars { barRects = append(barRects, barRect{bx, barY, float64(bar.WidthMM), calibBarHeight}) bx += float64(bar.WidthMM) + calibBarGap } return func(mmX, mmY float64) bool { // Outside page margins margin := 8.0 if mmX < margin || mmX > cfg.PageWidth-margin || mmY < margin || mmY > cfg.PageHeight-margin { return true } // Marker zones for _, mp := range markerPos { if math.Abs(mmX-mp[0]) < markerHalf && math.Abs(mmY-mp[1]) < markerHalf { return true } } // Data bar zones for _, br := range barRects { if mmX >= br.x-1 && mmX <= br.x+br.w+1 && mmY >= br.y-1 && mmY <= br.y+br.h+4 { return true } } // Cell borders and their immediate vicinity borderThick := 1.0 // mm tolerance for printed+scanned border for _, cell := range page.Cells { // Near cell left/right edges if (math.Abs(mmX-cell.X) < borderThick || math.Abs(mmX-(cell.X+cell.W)) < borderThick) && mmY >= cell.Y-1 && mmY <= cell.Y+cell.H+1 { return true } // Near cell top/bottom edges if (math.Abs(mmY-cell.Y) < borderThick || math.Abs(mmY-(cell.Y+cell.H)) < borderThick) && mmX >= cell.X-1 && mmX <= cell.X+cell.W+1 { return true } // Grid lines: NOT erased. They are 0.1mm at #e0e0e0 — too faint // and thin to survive thresholding + morph opening. // Center cross: 0.15mm stroke at #ddd — too faint to need erasure. // Only erase if wider stroke or darker color is used. // Face number label area (top-left, small zone) if mmX >= cell.X && mmX <= cell.X+10 && mmY >= cell.Y && mmY <= cell.Y+7 { return true } } // Header text area if mmY < 18 { return true } // Scale bar area (bottom of each page, left side) if mmY > cfg.PageHeight-25 && mmX < cfg.PageWidth/2 { return true } return false } } // EraseTemplateFromMask zeroes out all pixels that correspond to known template elements. func EraseTemplateFromMask(mask []bool, w, h int, xform affineTransform, isTemplate func(mmX, mmY float64) bool, cropX0, cropY0 int) { for py := 0; py < h; py++ { for px := 0; px < w; px++ { if !mask[py*w+px] { continue } mmX, mmY := xform.transform(float64(px+cropX0), float64(py+cropY0)) if isTemplate(mmX, mmY) { mask[py*w+px] = false } } } }