pulse-monitor/backups/backup_20251127/detection.go

1062 lines
30 KiB
Go

package main
import (
"fmt"
"image"
"image/color"
"math"
"sort"
"gocv.io/x/gocv"
)
// DEBUG flag is declared in types.go
// Band represents a vertical band of contours
type Band struct {
minX int
maxX int
minY int
maxY int
}
// DetectBands finds and groups contours into vertical bands
// Only returns bands with height >= minHeight pixels
func DetectBands(binary gocv.Mat, minHeight int) []Band {
type Box struct {
minX int
maxX int
minY int
maxY int
}
// Find contours
contours := gocv.FindContours(binary, gocv.RetrievalExternal, gocv.ChainApproxSimple)
// Get all bounding boxes
boxes := []Box{}
for i := 0; i < contours.Size(); i++ {
rect := gocv.BoundingRect(contours.At(i))
boxes = append(boxes, Box{
minX: rect.Min.X,
maxX: rect.Max.X,
minY: rect.Min.Y,
maxY: rect.Max.Y,
})
}
// Sort boxes by minY
sort.Slice(boxes, func(i, j int) bool {
return boxes[i].minY < boxes[j].minY
})
// Group overlapping/adjacent boxes into vertical bands
bands := []Band{}
for _, box := range boxes {
// Skip boxes shorter than minHeight
height := box.maxY - box.minY
if height < minHeight {
continue
}
if len(bands) == 0 {
// First band
bands = append(bands, Band{
minX: box.minX,
maxX: box.maxX,
minY: box.minY,
maxY: box.maxY,
})
} else {
lastBand := &bands[len(bands)-1]
// Check if this box overlaps or is close to last band (within 5px)
if box.minY <= lastBand.maxY+5 {
// Merge: extend the band in both X and Y
if box.minX < lastBand.minX {
lastBand.minX = box.minX
}
if box.maxX > lastBand.maxX {
lastBand.maxX = box.maxX
}
if box.maxY > lastBand.maxY {
lastBand.maxY = box.maxY
}
} else {
// New band
bands = append(bands, Band{
minX: box.minX,
maxX: box.maxX,
minY: box.minY,
maxY: box.maxY,
})
}
}
}
return bands
}
// findBaseline analyzes the baseline in the graph band
// Returns left point, right point, width, number of sections, and error
func findBaseline(binary gocv.Mat, graphBand Band) (*image.Point, *image.Point, int, int, error) {
// Extract just the graph band region
graphRegion := binary.Region(image.Rect(0, graphBand.minY, binary.Cols(), graphBand.maxY+1))
// Find all contours in graph region
contours := gocv.FindContours(graphRegion, gocv.RetrievalExternal, gocv.ChainApproxSimple)
// Get sections (contours) with width >= 5px
type Section struct {
minX int
maxX int
minY int
maxY int
}
sections := []Section{}
for i := 0; i < contours.Size(); i++ {
rect := gocv.BoundingRect(contours.At(i))
width := rect.Max.X - rect.Min.X
height := rect.Max.Y - rect.Min.Y
// Filter: width >= 5px AND height >= 80px
if width >= 5 && height >= 80 {
sections = append(sections, Section{
minX: rect.Min.X,
maxX: rect.Max.X,
minY: rect.Min.Y + graphBand.minY, // Adjust to full image coordinates
maxY: rect.Max.Y + graphBand.minY,
})
}
}
// Check if we have exactly 2 sections
if len(sections) != 2 {
return nil, nil, 0, len(sections), fmt.Errorf("found %d sections (need exactly 2)", len(sections))
}
// Sort sections by X position (left to right)
sort.Slice(sections, func(i, j int) bool {
return sections[i].minX < sections[j].minX
})
// Horizontal width (left edge of left section to right edge of right section)
horizontalWidth := sections[1].maxX - sections[0].minX
// Calculate scale factor for offset (target 860px)
scale := 860.0 / float64(horizontalWidth)
offset := int(3.0 * scale)
if offset < 3 {
offset = 3
}
// Find rotation points
// Left section: lowest white pixel offset from left edge
leftSection := sections[0]
leftX := leftSection.minX + offset
leftPoint := findLowestWhitePixel(binary, leftX, graphBand.minY, graphBand.maxY)
// Right section: lowest white pixel offset from right edge
rightSection := sections[1]
rightX := rightSection.maxX - offset
rightPoint := findLowestWhitePixel(binary, rightX, graphBand.minY, graphBand.maxY)
if leftPoint == nil || rightPoint == nil {
return nil, nil, 0, len(sections), fmt.Errorf("could not find rotation endpoints")
}
return leftPoint, rightPoint, horizontalWidth, len(sections), nil
}
// findLowestWhitePixel finds the lowest (max Y) white pixel at given X within Y range
func findLowestWhitePixel(binary gocv.Mat, x int, minY int, maxY int) *image.Point {
lowestY := -1
for y := minY; y <= maxY; y++ {
if binary.GetUCharAt(y, x) == 255 {
lowestY = y
}
}
if lowestY == -1 {
return nil
}
return &image.Point{X: x, Y: lowestY}
}
// DetectionResult contains rotation and width detection results
type DetectionResult struct {
Rotation float64
Width int
ScaleFactor float64 // Scale factor to apply (860 / width)
SpO2 image.Rectangle // SpO2 display area in SCALED, ROTATED coordinates
HR image.Rectangle // HR display area in SCALED, ROTATED coordinates
Success bool
}
// PreprocessFrame takes a raw frame and prepares it for detection
// Returns the processed binary image
func PreprocessFrame(frame gocv.Mat) gocv.Mat {
// Crop top 68 pixels (timestamp)
cropped := frame.Region(image.Rect(0, 68, frame.Cols(), frame.Rows()))
// Rotate 90° clockwise
rotated := gocv.NewMat()
gocv.Rotate(cropped, &rotated, gocv.Rotate90Clockwise)
// Convert to grayscale
gray := gocv.NewMat()
gocv.CvtColor(rotated, &gray, gocv.ColorBGRToGray)
rotated.Close()
// Threshold to binary at 240 (matching main pipeline)
binary := gocv.NewMat()
gocv.Threshold(gray, &binary, 240, 255, gocv.ThresholdBinary)
gray.Close()
return binary
}
// DetectRotationAndWidth analyzes a binary frame and returns rotation angle, width, and display areas
func DetectRotationAndWidth(binary gocv.Mat) DetectionResult {
// ========== PHASE 1: Detect rotation & scale from baseline ==========
// Detect bands (using 80px minimum height for original scale)
bands := DetectBands(binary, 80)
// Check if we have enough bands
if len(bands) < 2 {
if DEBUG {
fmt.Printf("DEBUG: Detection failed - only %d bands found (need at least 2)\n", len(bands))
}
return DetectionResult{Success: false}
}
// Find TALLEST band (graph/waveform display)
tallestIdx := 0
tallestHeight := 0
for i, band := range bands {
height := band.maxY - band.minY
if height > tallestHeight {
tallestHeight = height
tallestIdx = i
}
}
// Check if there's a band after the tallest (for digits)
if tallestIdx >= len(bands)-1 {
if DEBUG {
fmt.Printf("DEBUG: Detection failed - tallest band is last (idx=%d, total=%d)\n", tallestIdx, len(bands))
}
return DetectionResult{Success: false}
}
graphBand := bands[tallestIdx] // Tallest = graph/waveform
// Find baseline
leftPoint, rightPoint, width, sections, err := findBaseline(binary, graphBand)
if err != nil || sections != 2 {
if DEBUG {
if err != nil {
fmt.Printf("DEBUG: Detection failed - baseline error: %v\n", err)
} else {
fmt.Printf("DEBUG: Detection failed - found %d sections (need exactly 2)\n", sections)
}
}
return DetectionResult{Success: false}
}
// Calculate rotation angle from baseline points
deltaY := float64(rightPoint.Y - leftPoint.Y)
deltaX := float64(rightPoint.X - leftPoint.X)
rotation := math.Atan2(deltaY, deltaX) * 180 / math.Pi
// DEBUG: Visualize the baseline points
if DEBUG {
viz := gocv.NewMat()
gocv.CvtColor(binary, &viz, gocv.ColorGrayToBGR)
// Draw left point (green)
gocv.Circle(&viz, *leftPoint, 10, color.RGBA{0, 255, 0, 255}, -1)
// Draw right point (red)
gocv.Circle(&viz, *rightPoint, 10, color.RGBA{0, 0, 255, 255}, -1)
// Draw line between them (yellow)
gocv.Line(&viz, *leftPoint, *rightPoint, color.RGBA{0, 255, 255, 255}, 2)
// Draw graph band boundaries
gocv.Line(&viz, image.Pt(0, graphBand.minY), image.Pt(viz.Cols(), graphBand.minY), color.RGBA{255, 0, 255, 255}, 1)
gocv.Line(&viz, image.Pt(0, graphBand.maxY), image.Pt(viz.Cols(), graphBand.maxY), color.RGBA{255, 0, 255, 255}, 1)
gocv.IMWrite("debug_baseline_points.png", viz)
viz.Close()
fmt.Println("DEBUG: Saved baseline points visualization to debug_baseline_points.png")
}
if DEBUG {
fmt.Printf("DEBUG: Baseline points: Left(%d,%d) Right(%d,%d)\n",
leftPoint.X, leftPoint.Y, rightPoint.X, rightPoint.Y)
fmt.Printf("DEBUG: Delta Y=%d, Delta X=%d, Rotation=%.3f°\n",
rightPoint.Y-leftPoint.Y, rightPoint.X-leftPoint.X, rotation)
}
// Calculate scale factor (860 / detected width)
scaleFactor := 860.0 / float64(width)
if DEBUG {
fmt.Printf("DEBUG: Width=%dpx, Scale factor=%.3f (baseline will become 860px)\n",
width, scaleFactor)
}
// ========== PHASE 2: Apply transforms ==========
// Rotate the image to correct tilt
rotated := RotateImage(binary, rotation)
defer rotated.Close()
// Scale to 860px baseline width
scaled := ScaleByFactor(rotated, scaleFactor)
defer scaled.Close()
if DEBUG {
fmt.Printf("DEBUG: Transformed image size: %dx%d\n", scaled.Cols(), scaled.Rows())
}
// ========== PHASE 3: Detect display areas on transformed image ==========
// Re-detect bands on scaled image (adjust minHeight for scale)
scaledMinHeight := int(80.0 * scaleFactor)
scaledBands := DetectBands(scaled, scaledMinHeight)
if len(scaledBands) < 2 {
if DEBUG {
fmt.Printf("DEBUG: Detection failed - only %d bands found on scaled image\n", len(scaledBands))
}
return DetectionResult{Success: false}
}
// Find tallest band again on scaled image
scaledTallestIdx := 0
scaledTallestHeight := 0
for i, band := range scaledBands {
height := band.maxY - band.minY
if height > scaledTallestHeight {
scaledTallestHeight = height
scaledTallestIdx = i
}
}
// Check if there's a digit band after the graph band
if scaledTallestIdx >= len(scaledBands)-1 {
if DEBUG {
fmt.Printf("DEBUG: Detection failed - no digit band after graph on scaled image\n")
}
return DetectionResult{Success: false}
}
digitBand := scaledBands[scaledTallestIdx+1]
if DEBUG {
fmt.Printf("DEBUG: Digit band on scaled image: Y[%d-%d], Height=%dpx\n",
digitBand.minY, digitBand.maxY, digitBand.maxY-digitBand.minY)
}
// Extract digit band region
digitRegion := ExtractBandRegion(scaled, digitBand)
defer digitRegion.Close()
// Find digit boxes
digitBoxes := FindDigitBoxes(digitRegion, scaledMinHeight)
if len(digitBoxes) < 2 {
if DEBUG {
fmt.Printf("DEBUG: Detection failed - only %d digit boxes found (need at least 2)\n", len(digitBoxes))
}
return DetectionResult{Success: false}
}
// Merge digit boxes into SpO2/HR areas
displayAreas := MergeDigitBoxesIntoDisplays(digitRegion, digitBoxes)
// CRITICAL: Expand boxes to CUT_WIDTH (280px) using right edge as anchor
// OCR code expects exactly 280px wide boxes
const CUT_WIDTH = 280
// SpO2: keep right edge, expand left
spo2RightX := displayAreas.SpO2.Max.X
spo2LeftX := spo2RightX - CUT_WIDTH
if spo2LeftX < 0 {
spo2LeftX = 0
}
// HR: keep right edge, expand left
hrRightX := displayAreas.HR.Max.X
hrLeftX := hrRightX - CUT_WIDTH
if hrLeftX < 0 {
hrLeftX = 0
}
if DEBUG {
fmt.Printf("DEBUG: Expanded SpO2 from width %d to %d (CUT_WIDTH)\n",
displayAreas.SpO2.Dx(), CUT_WIDTH)
fmt.Printf("DEBUG: Expanded HR from width %d to %d (CUT_WIDTH)\n",
displayAreas.HR.Dx(), CUT_WIDTH)
}
// Convert coordinates from digit-region space to full-image space
// (add digitBand.minY offset to Y coordinates)
// Use expanded X coordinates (CUT_WIDTH)
spo2Rect := image.Rect(
spo2LeftX,
displayAreas.SpO2.Min.Y + digitBand.minY,
spo2RightX,
displayAreas.SpO2.Max.Y + digitBand.minY,
)
hrRect := image.Rect(
hrLeftX,
displayAreas.HR.Min.Y + digitBand.minY,
hrRightX,
displayAreas.HR.Max.Y + digitBand.minY,
)
if DEBUG {
fmt.Printf("DEBUG: SpO2 display area (full coords): X[%d-%d] Y[%d-%d]\n",
spo2Rect.Min.X, spo2Rect.Max.X, spo2Rect.Min.Y, spo2Rect.Max.Y)
fmt.Printf("DEBUG: HR display area (full coords): X[%d-%d] Y[%d-%d]\n",
hrRect.Min.X, hrRect.Max.X, hrRect.Min.Y, hrRect.Max.Y)
}
return DetectionResult{
Rotation: rotation,
Width: width,
ScaleFactor: scaleFactor,
SpO2: spo2Rect,
HR: hrRect,
Success: true,
}
}
// VisualizeBands draws bands on an image with labels (only in DEBUG mode)
func VisualizeBands(binary gocv.Mat, bands []Band, tallestIdx int, filename string) {
if !DEBUG {
return
}
viz := gocv.NewMat()
gocv.CvtColor(binary, &viz, gocv.ColorGrayToBGR)
defer viz.Close()
red := color.RGBA{R: 255, G: 0, B: 0, A: 255}
green := color.RGBA{R: 0, G: 255, B: 0, A: 255}
blue := color.RGBA{R: 0, G: 0, B: 255, A: 255}
yellow := color.RGBA{R: 255, G: 255, B: 0, A: 255}
// Draw all bands
for i, band := range bands {
bandColor := red
if i == tallestIdx {
bandColor = green // Graph band in green
} else if tallestIdx < len(bands)-1 && i == tallestIdx+1 {
bandColor = blue // Numbers band in blue
}
rect := image.Rect(0, band.minY, viz.Cols(), band.maxY)
gocv.Rectangle(&viz, rect, bandColor, 2)
label := fmt.Sprintf("Band #%d", i+1)
gocv.PutText(&viz, label, image.Pt(10, band.minY+20),
gocv.FontHersheyPlain, 1.5, yellow, 2)
}
gocv.IMWrite(filename, viz)
fmt.Printf("DEBUG: Saved visualization to %s\n", filename)
}
// VisualizeDigitBand highlights the digit band specifically
func VisualizeDigitBand(binary gocv.Mat, bands []Band, tallestIdx int, filename string) {
if !DEBUG || tallestIdx >= len(bands)-1 {
return
}
viz := gocv.NewMat()
gocv.CvtColor(binary, &viz, gocv.ColorGrayToBGR)
defer viz.Close()
red := color.RGBA{R: 255, G: 0, B: 0, A: 255}
green := color.RGBA{R: 0, G: 255, B: 0, A: 255}
blue := color.RGBA{R: 0, G: 0, B: 255, A: 255}
yellow := color.RGBA{R: 255, G: 255, B: 0, A: 255}
cyan := color.RGBA{R: 0, G: 255, B: 255, A: 255}
// Draw all bands with specific highlighting
for i, band := range bands {
bandColor := red
lineThickness := 1
labelText := fmt.Sprintf("Band #%d", i+1)
if i == tallestIdx {
bandColor = green
labelText = "GRAPH BAND"
} else if i == tallestIdx+1 {
// DIGIT BAND - highlight strongly
bandColor = cyan
lineThickness = 3
labelText = fmt.Sprintf("DIGIT BAND [Y: %d-%d]", band.minY, band.maxY)
}
rect := image.Rect(0, band.minY, viz.Cols(), band.maxY)
gocv.Rectangle(&viz, rect, bandColor, lineThickness)
// Add label
labelColor := yellow
if i == tallestIdx+1 {
labelColor = cyan
}
gocv.PutText(&viz, labelText, image.Pt(10, band.minY+20),
gocv.FontHersheyPlain, 1.5, labelColor, 2)
}
// Draw additional markers for digit band
if tallestIdx < len(bands)-1 {
digitBand := bands[tallestIdx+1]
// Draw vertical lines at digit band boundaries
for x := 50; x < viz.Cols(); x += 100 {
gocv.Line(&viz,
image.Pt(x, digitBand.minY),
image.Pt(x, digitBand.maxY),
blue, 1)
}
// Add dimension text
height := digitBand.maxY - digitBand.minY
dimText := fmt.Sprintf("Height: %dpx, Width: 860px", height)
gocv.PutText(&viz, dimText, image.Pt(10, digitBand.maxY+30),
gocv.FontHersheyPlain, 1.2, cyan, 2)
}
gocv.IMWrite(filename, viz)
fmt.Printf("DEBUG: Saved digit band visualization to %s\n", filename)
}
// ValidateBaselineTwoSegments checks if the baseline consists of two separate segments
// by counting black-to-white transitions along the baseline (should be exactly 1)
func ValidateBaselineTwoSegments(binary gocv.Mat, leftPoint, rightPoint *image.Point) bool {
// Calculate slope for Y interpolation along the baseline
deltaY := float64(rightPoint.Y - leftPoint.Y)
deltaX := float64(rightPoint.X - leftPoint.X)
slope := deltaY / deltaX
if DEBUG {
fmt.Printf("DEBUG: Validating baseline segments (slope=%.4f) from X=%d to X=%d\n",
slope, leftPoint.X, rightPoint.X)
}
// Count black-to-white transitions
transitions := 0
lastWasWhite := false
for x := leftPoint.X; x <= rightPoint.X; x++ {
// Interpolate Y position along the baseline, then go 2px up (away from thick edge)
y := leftPoint.Y + int(float64(x-leftPoint.X)*slope) - 2
isWhite := binary.GetUCharAt(y, x) == 255
// Detect black-to-white transition
if isWhite && !lastWasWhite {
transitions++
}
lastWasWhite = isWhite
}
if DEBUG {
fmt.Printf("DEBUG: Found %d black-to-white transitions\n", transitions)
}
// We need exactly 1 black-to-white transition for 2 segments
// (entering the second segment after the gap)
if transitions != 1 {
if DEBUG {
fmt.Printf("DEBUG: INVALID - Need exactly 1 transition, found %d\n", transitions)
}
return false
}
if DEBUG {
fmt.Println("DEBUG: VALID - Baseline has two distinct segments")
}
return true
}
// RotateImage rotates an image by the given angle in degrees
// The angle is the detected rotation that needs to be corrected
func RotateImage(img gocv.Mat, angleDegrees float64) gocv.Mat {
// Get image center
center := image.Point{
X: img.Cols() / 2,
Y: img.Rows() / 2,
}
// Apply rotation to correct the tilt
// NOTE: If image rotates wrong direction, change to: -angleDegrees
rotMat := gocv.GetRotationMatrix2D(center, angleDegrees, 1.0)
defer rotMat.Close()
// Apply rotation
rotated := gocv.NewMat()
gocv.WarpAffine(img, &rotated, rotMat, image.Point{X: img.Cols(), Y: img.Rows()})
if DEBUG {
fmt.Printf("DEBUG: Applied rotation: %.3f°\n", angleDegrees)
}
return rotated
}
// VisualizeSimpleBands draws just the graph and digit bands clearly
func VisualizeSimpleBands(binary gocv.Mat, graphBand, digitBand Band, filename string) {
if !DEBUG {
return
}
viz := gocv.NewMat()
gocv.CvtColor(binary, &viz, gocv.ColorGrayToBGR)
defer viz.Close()
green := color.RGBA{R: 0, G: 255, B: 0, A: 255}
cyan := color.RGBA{R: 0, G: 255, B: 255, A: 255}
// Draw graph band in green
graphRect := image.Rect(0, graphBand.minY, viz.Cols(), graphBand.maxY)
gocv.Rectangle(&viz, graphRect, green, 2)
gocv.PutText(&viz, "GRAPH BAND", image.Pt(10, graphBand.minY+20),
gocv.FontHersheyPlain, 1.5, green, 2)
// Draw digit band in cyan with thick border
digitRect := image.Rect(0, digitBand.minY, viz.Cols(), digitBand.maxY)
gocv.Rectangle(&viz, digitRect, cyan, 3)
label := fmt.Sprintf("DIGIT BAND [Y: %d-%d, H: %dpx]",
digitBand.minY, digitBand.maxY, digitBand.maxY-digitBand.minY)
gocv.PutText(&viz, label, image.Pt(10, digitBand.minY+20),
gocv.FontHersheyPlain, 1.5, cyan, 2)
gocv.IMWrite(filename, viz)
fmt.Printf("DEBUG: Saved 2-band visualization to %s\n", filename)
}
// ExtractBandRegion extracts a specific band region from an image
func ExtractBandRegion(img gocv.Mat, band Band) gocv.Mat {
// Create a region of interest (ROI) for the band
rect := image.Rect(0, band.minY, img.Cols(), band.maxY)
region := img.Region(rect)
// Clone the region to create an independent Mat
extracted := gocv.NewMat()
region.CopyTo(&extracted)
if DEBUG {
fmt.Printf("DEBUG: Extracted band region [%d-%d], size: %dx%d\n",
band.minY, band.maxY, extracted.Cols(), extracted.Rows())
}
return extracted
}
// FindDigitBoxes finds individual digit contours within a region
// Returns bounding rectangles for digits larger than minSize
func FindDigitBoxes(digitRegion gocv.Mat, minSize int) []image.Rectangle {
contours := gocv.FindContours(digitRegion, gocv.RetrievalExternal, gocv.ChainApproxSimple)
var digitBoxes []image.Rectangle
for i := 0; i < contours.Size(); i++ {
rect := gocv.BoundingRect(contours.At(i))
height := rect.Max.Y - rect.Min.Y
// Filter by minimum size - height only
if height >= minSize {
digitBoxes = append(digitBoxes, rect)
}
}
// Sort boxes by X position (left to right)
sort.Slice(digitBoxes, func(i, j int) bool {
return digitBoxes[i].Min.X < digitBoxes[j].Min.X
})
if DEBUG {
fmt.Printf("DEBUG: Found %d digit contours (H>=%dpx)\n", len(digitBoxes), minSize)
}
return digitBoxes
}
// VisualizeDigitBoxes draws boxes around detected digits with numbers
func VisualizeDigitBoxes(digitRegion gocv.Mat, boxes []image.Rectangle, yOffset int, filename string) {
if !DEBUG {
return
}
// Create visualization
viz := gocv.NewMat()
gocv.CvtColor(digitRegion, &viz, gocv.ColorGrayToBGR)
defer viz.Close()
// Colors for digit boxes
red := color.RGBA{R: 255, G: 0, B: 0, A: 255}
yellow := color.RGBA{R: 255, G: 255, B: 0, A: 255}
green := color.RGBA{R: 0, G: 255, B: 0, A: 255}
cyan := color.RGBA{R: 0, G: 255, B: 255, A: 255}
magenta := color.RGBA{R: 255, G: 0, B: 255, A: 255}
// Array of colors to cycle through
colors := []color.RGBA{red, yellow, green, cyan, magenta}
// Draw each digit box
for i, box := range boxes {
// Cycle through colors
boxColor := colors[i%len(colors)]
// Draw rectangle around digit
gocv.Rectangle(&viz, box, boxColor, 2)
// Add digit number label
label := fmt.Sprintf("%d", i+1)
labelPos := image.Pt(box.Min.X+2, box.Min.Y-5)
if labelPos.Y < 10 {
// If label would be outside image, put it inside the box
labelPos.Y = box.Min.Y + 15
}
gocv.PutText(&viz, label, labelPos,
gocv.FontHersheyPlain, 1.2, boxColor, 2)
// Show dimensions
width := box.Max.X - box.Min.X
height := box.Max.Y - box.Min.Y
dimText := fmt.Sprintf("%dx%d", width, height)
gocv.PutText(&viz, dimText, image.Pt(box.Min.X, box.Max.Y+12),
gocv.FontHersheyPlain, 0.8, boxColor, 1)
}
// Add summary text
summary := fmt.Sprintf("Found %d digits (Y offset: %d)", len(boxes), yOffset)
gocv.PutText(&viz, summary, image.Pt(5, viz.Rows()-10),
gocv.FontHersheyPlain, 1.0, green, 1)
gocv.IMWrite(filename, viz)
fmt.Printf("DEBUG: Saved digit boxes visualization to %s\n", filename)
}
// VisualizeDigitBoxesOnFull draws digit boxes on the full scaled image
func VisualizeDigitBoxesOnFull(fullImage gocv.Mat, boxes []image.Rectangle, digitBand Band, filename string) {
if !DEBUG {
return
}
// Create visualization
viz := gocv.NewMat()
gocv.CvtColor(fullImage, &viz, gocv.ColorGrayToBGR)
defer viz.Close()
// Colors
green := color.RGBA{R: 0, G: 255, B: 0, A: 255}
cyan := color.RGBA{R: 0, G: 255, B: 255, A: 255}
red := color.RGBA{R: 255, G: 0, B: 0, A: 255}
yellow := color.RGBA{R: 255, G: 255, B: 0, A: 255}
magenta := color.RGBA{R: 255, G: 0, B: 255, A: 255}
colors := []color.RGBA{red, yellow, green, cyan, magenta}
// Draw the digit band outline
bandRect := image.Rect(0, digitBand.minY, viz.Cols(), digitBand.maxY)
gocv.Rectangle(&viz, bandRect, cyan, 1)
gocv.PutText(&viz, "DIGIT BAND", image.Pt(10, digitBand.minY-5),
gocv.FontHersheyPlain, 1.0, cyan, 1)
// Draw each digit box (adjust Y position to full image coordinates)
for i, box := range boxes {
// Adjust box coordinates to full image (add digitBand.minY offset)
adjustedBox := image.Rectangle{
Min: image.Pt(box.Min.X, box.Min.Y+digitBand.minY),
Max: image.Pt(box.Max.X, box.Max.Y+digitBand.minY),
}
// Cycle through colors
boxColor := colors[i%len(colors)]
// Draw rectangle
gocv.Rectangle(&viz, adjustedBox, boxColor, 2)
// Add digit number
label := fmt.Sprintf("D%d", i+1)
gocv.PutText(&viz, label, image.Pt(adjustedBox.Min.X+2, adjustedBox.Min.Y+15),
gocv.FontHersheyPlain, 1.0, boxColor, 2)
}
// Add summary
summary := fmt.Sprintf("Total: %d digits detected", len(boxes))
gocv.PutText(&viz, summary, image.Pt(10, 30),
gocv.FontHersheyPlain, 1.2, green, 2)
gocv.IMWrite(filename, viz)
fmt.Printf("DEBUG: Saved full image with digit boxes to %s\n", filename)
}
// ScaleByFactor scales an image by a specific scale factor
func ScaleByFactor(img gocv.Mat, scaleFactor float64) gocv.Mat {
// Calculate new dimensions
newWidth := int(float64(img.Cols()) * scaleFactor)
newHeight := int(float64(img.Rows()) * scaleFactor)
// Resize image
scaled := gocv.NewMat()
gocv.Resize(img, &scaled, image.Point{X: newWidth, Y: newHeight}, 0, 0, gocv.InterpolationLinear)
if DEBUG {
fmt.Printf("DEBUG: Scaled image from %dx%d to %dx%d (scale factor: %.3f)\n",
img.Cols(), img.Rows(), newWidth, newHeight, scaleFactor)
}
return scaled
}
// DisplayAreas holds the two display rectangles for SpO2 and HR
type DisplayAreas struct {
SpO2 image.Rectangle
HR image.Rectangle
}
// mergeDigitBoxesWithCenter merges digit boxes into SpO2/HR displays using a specific centerX
func mergeDigitBoxesWithCenter(digitRegion gocv.Mat, digitBoxes []image.Rectangle, centerX int) DisplayAreas {
// Initialize bounding boxes
spo2MinX, spo2MaxX := digitRegion.Cols(), 0
spo2MinY, spo2MaxY := digitRegion.Rows(), 0
hrMinX, hrMaxX := digitRegion.Cols(), 0
hrMinY, hrMaxY := digitRegion.Rows(), 0
// Track counts for logging
leftCount := 0
rightCount := 0
// Split boxes by centerX and merge
for _, box := range digitBoxes {
// Calculate box center X to determine which half it's in
boxCenterX := (box.Min.X + box.Max.X) / 2
if boxCenterX < centerX {
// LEFT HALF - SpO2
leftCount++
if box.Min.X < spo2MinX {
spo2MinX = box.Min.X
}
if box.Max.X > spo2MaxX {
spo2MaxX = box.Max.X
}
if box.Min.Y < spo2MinY {
spo2MinY = box.Min.Y
}
if box.Max.Y > spo2MaxY {
spo2MaxY = box.Max.Y
}
} else {
// RIGHT HALF - HR
rightCount++
if box.Min.X < hrMinX {
hrMinX = box.Min.X
}
if box.Max.X > hrMaxX {
hrMaxX = box.Max.X
}
if box.Min.Y < hrMinY {
hrMinY = box.Min.Y
}
if box.Max.Y > hrMaxY {
hrMaxY = box.Max.Y
}
}
}
if DEBUG {
fmt.Printf("DEBUG: Split at center X=%d: %d boxes left (SpO2), %d boxes right (HR)\n",
centerX, leftCount, rightCount)
fmt.Printf("DEBUG: SpO2 merged area: X[%d-%d] Y[%d-%d], Size: %dx%d\n",
spo2MinX, spo2MaxX, spo2MinY, spo2MaxY,
spo2MaxX-spo2MinX, spo2MaxY-spo2MinY)
fmt.Printf("DEBUG: HR merged area: X[%d-%d] Y[%d-%d], Size: %dx%d\n",
hrMinX, hrMaxX, hrMinY, hrMaxY,
hrMaxX-hrMinX, hrMaxY-hrMinY)
}
return DisplayAreas{
SpO2: image.Rect(spo2MinX, spo2MinY, spo2MaxX, spo2MaxY),
HR: image.Rect(hrMinX, hrMinY, hrMaxX, hrMaxY),
}
}
// VisualizeDetectedDisplays visualizes the detected SpO2 and HR display areas
func VisualizeDetectedDisplays(digitRegion gocv.Mat, displayAreas DisplayAreas, digitBoxes []image.Rectangle, centerX int, filename string) {
if !DEBUG {
return
}
// Create visualization
viz := gocv.NewMat()
gocv.CvtColor(digitRegion, &viz, gocv.ColorGrayToBGR)
defer viz.Close()
// Colors
red := color.RGBA{R: 255, G: 0, B: 0, A: 255}
yellow := color.RGBA{R: 255, G: 255, B: 0, A: 255}
cyan := color.RGBA{R: 0, G: 255, B: 255, A: 255}
green := color.RGBA{R: 0, G: 255, B: 0, A: 255}
// Draw all individual digit boxes in green (thin lines)
for _, box := range digitBoxes {
gocv.Rectangle(&viz, box, green, 1)
}
// Draw SpO2 area in red (thick)
gocv.Rectangle(&viz, displayAreas.SpO2, red, 3)
gocv.PutText(&viz, "SpO2", image.Pt(displayAreas.SpO2.Min.X+5, displayAreas.SpO2.Min.Y+20),
gocv.FontHersheyPlain, 1.5, red, 2)
// Draw HR area in yellow (thick)
gocv.Rectangle(&viz, displayAreas.HR, yellow, 3)
gocv.PutText(&viz, "HR", image.Pt(displayAreas.HR.Min.X+5, displayAreas.HR.Min.Y+20),
gocv.FontHersheyPlain, 1.5, yellow, 2)
// Draw center line from baseline
gocv.Line(&viz,
image.Pt(centerX, 0),
image.Pt(centerX, digitRegion.Rows()),
cyan, 2)
gocv.PutText(&viz, fmt.Sprintf("Center: %d", centerX),
image.Pt(centerX-40, 15),
gocv.FontHersheyPlain, 1.0, cyan, 1)
gocv.IMWrite(filename, viz)
fmt.Printf("DEBUG: Saved detection visualization to %s\n", filename)
}
// MergeDigitBoxesIntoDisplays merges individual digit boxes into two unified display areas
// This is the FINAL step of detection after finding digit boxes
// It splits at the center of the BOUNDING BOX (not image center)
// and merges all left boxes into SpO2, all right boxes into HR display area
func MergeDigitBoxesIntoDisplays(digitRegion gocv.Mat, digitBoxes []image.Rectangle) DisplayAreas {
// First, find the bounding box of ALL digit boxes
contentMinX := digitRegion.Cols()
contentMaxX := 0
for _, box := range digitBoxes {
if box.Min.X < contentMinX {
contentMinX = box.Min.X
}
if box.Max.X > contentMaxX {
contentMaxX = box.Max.X
}
}
// Calculate center of the CONTENT bounding box (not image center)
centerX := (contentMinX + contentMaxX) / 2
if DEBUG {
fmt.Printf("DEBUG: Content bounding box: X[%d-%d], Center X=%d\n",
contentMinX, contentMaxX, centerX)
}
// Initialize bounding boxes
spo2MinX, spo2MaxX := digitRegion.Cols(), 0
spo2MinY, spo2MaxY := digitRegion.Rows(), 0
hrMinX, hrMaxX := digitRegion.Cols(), 0
hrMinY, hrMaxY := digitRegion.Rows(), 0
// Track counts for logging
leftCount := 0
rightCount := 0
// Split boxes by center X and merge
for _, box := range digitBoxes {
// Calculate box center X to determine which half it's in
boxCenterX := (box.Min.X + box.Max.X) / 2
if boxCenterX < centerX {
// LEFT HALF - SpO2
leftCount++
if box.Min.X < spo2MinX {
spo2MinX = box.Min.X
}
if box.Max.X > spo2MaxX {
spo2MaxX = box.Max.X
}
if box.Min.Y < spo2MinY {
spo2MinY = box.Min.Y
}
if box.Max.Y > spo2MaxY {
spo2MaxY = box.Max.Y
}
} else {
// RIGHT HALF - HR
rightCount++
if box.Min.X < hrMinX {
hrMinX = box.Min.X
}
if box.Max.X > hrMaxX {
hrMaxX = box.Max.X
}
if box.Min.Y < hrMinY {
hrMinY = box.Min.Y
}
if box.Max.Y > hrMaxY {
hrMaxY = box.Max.Y
}
}
}
if DEBUG {
fmt.Printf("DEBUG: Split at center X=%d: %d boxes left (SpO2), %d boxes right (HR)\n",
centerX, leftCount, rightCount)
fmt.Printf("DEBUG: SpO2 merged area: X[%d-%d] Y[%d-%d], Size: %dx%d\n",
spo2MinX, spo2MaxX, spo2MinY, spo2MaxY,
spo2MaxX-spo2MinX, spo2MaxY-spo2MinY)
fmt.Printf("DEBUG: HR merged area: X[%d-%d] Y[%d-%d], Size: %dx%d\n",
hrMinX, hrMaxX, hrMinY, hrMaxY,
hrMaxX-hrMinX, hrMaxY-hrMinY)
}
return DisplayAreas{
SpO2: image.Rect(spo2MinX, spo2MinY, spo2MaxX, spo2MaxY),
HR: image.Rect(hrMinX, hrMinY, hrMaxX, hrMaxY),
}
}
// VisualizeMergedDisplays draws the merged display areas on the digit region
func VisualizeMergedDisplays(digitRegion gocv.Mat, displayAreas DisplayAreas, filename string) {
if !DEBUG {
return
}
// Create visualization
viz := gocv.NewMat()
gocv.CvtColor(digitRegion, &viz, gocv.ColorGrayToBGR)
defer viz.Close()
// Colors
red := color.RGBA{R: 255, G: 0, B: 0, A: 255}
yellow := color.RGBA{R: 255, G: 255, B: 0, A: 255}
cyan := color.RGBA{R: 0, G: 255, B: 255, A: 255}
// Draw SpO2 area in red
gocv.Rectangle(&viz, displayAreas.SpO2, red, 3)
gocv.PutText(&viz, "SpO2", image.Pt(displayAreas.SpO2.Min.X+5, displayAreas.SpO2.Min.Y+20),
gocv.FontHersheyPlain, 1.5, red, 2)
// Draw HR area in yellow
gocv.Rectangle(&viz, displayAreas.HR, yellow, 3)
gocv.PutText(&viz, "HR", image.Pt(displayAreas.HR.Min.X+5, displayAreas.HR.Min.Y+20),
gocv.FontHersheyPlain, 1.5, yellow, 2)
// Draw center line at midpoint between the two display areas
centerX := (displayAreas.SpO2.Max.X + displayAreas.HR.Min.X) / 2
gocv.Line(&viz,
image.Pt(centerX, 0),
image.Pt(centerX, digitRegion.Rows()),
cyan, 1)
gocv.IMWrite(filename, viz)
fmt.Printf("DEBUG: Saved merged displays visualization to %s\n", filename)
}