794 lines
24 KiB
Go
794 lines
24 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
|
|
}
|
|
|
|
// Remove thin vertical lines (UI borders) using morphological opening with horizontal kernel
|
|
// This breaks the connection between rows caused by vertical UI elements
|
|
kernel := gocv.GetStructuringElement(gocv.MorphRect, image.Pt(15, 1)) // Wide horizontal kernel
|
|
cleaned := gocv.NewMat()
|
|
gocv.MorphologyEx(binary, &cleaned, gocv.MorphOpen, kernel)
|
|
kernel.Close()
|
|
defer cleaned.Close()
|
|
|
|
// Find contours on the cleaned image
|
|
contours := gocv.FindContours(cleaned, gocv.RetrievalExternal, gocv.ChainApproxSimple)
|
|
|
|
// Get all bounding boxes, filtering out tall thin UI elements (borders)
|
|
boxes := []Box{}
|
|
frameHeight := binary.Rows()
|
|
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
|
|
|
|
// Skip tall thin contours (aspect ratio > 8) that span most of frame height
|
|
// These are likely UI borders, not content
|
|
if width > 0 && height > frameHeight*6/10 {
|
|
aspectRatio := float64(height) / float64(width)
|
|
if aspectRatio > 8 {
|
|
if DEBUG {
|
|
fmt.Printf("DEBUG: Skipping border contour: %dx%d (aspect=%.1f)\n", width, height, aspectRatio)
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
|
|
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
|
|
width int
|
|
height int
|
|
}
|
|
sections := []Section{}
|
|
allContours := []Section{} // For debug visualization
|
|
|
|
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
|
|
|
|
// Collect ALL contours >= 50px high for visualization
|
|
if height >= 50 {
|
|
allContours = append(allContours, Section{
|
|
minX: rect.Min.X,
|
|
maxX: rect.Max.X,
|
|
minY: rect.Min.Y + graphBand.minY,
|
|
maxY: rect.Max.Y + graphBand.minY,
|
|
width: width,
|
|
height: height,
|
|
})
|
|
}
|
|
|
|
// Filter: width >= 5px AND height >= 50px for baseline detection
|
|
// (lowered from 80px to handle smaller waveform sections)
|
|
if width >= 5 && height >= 50 {
|
|
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,
|
|
width: width,
|
|
height: height,
|
|
})
|
|
}
|
|
}
|
|
|
|
// DEBUG: Visualize ALL contours >= 50px high
|
|
if DEBUG {
|
|
viz := gocv.NewMat()
|
|
gocv.CvtColor(binary, &viz, gocv.ColorGrayToBGR)
|
|
|
|
// Draw graph band boundaries
|
|
gocv.Line(&viz, image.Pt(0, graphBand.minY), image.Pt(viz.Cols(), graphBand.minY),
|
|
color.RGBA{255, 0, 255, 255}, 2)
|
|
gocv.Line(&viz, image.Pt(0, graphBand.maxY), image.Pt(viz.Cols(), graphBand.maxY),
|
|
color.RGBA{255, 0, 255, 255}, 2)
|
|
|
|
// Draw ALL contours >= 50px high in yellow
|
|
for i, c := range allContours {
|
|
rect := image.Rect(c.minX, c.minY, c.maxX, c.maxY)
|
|
gocv.Rectangle(&viz, rect, color.RGBA{0, 255, 255, 255}, 2)
|
|
label := fmt.Sprintf("#%d: %dx%d", i+1, c.width, c.height)
|
|
gocv.PutText(&viz, label, image.Pt(c.minX, c.minY-5),
|
|
gocv.FontHersheyPlain, 1.0, color.RGBA{0, 255, 255, 255}, 1)
|
|
}
|
|
|
|
// Highlight sections that passed the 80px height filter in green
|
|
for i, s := range sections {
|
|
rect := image.Rect(s.minX, s.minY, s.maxX, s.maxY)
|
|
gocv.Rectangle(&viz, rect, color.RGBA{0, 255, 0, 255}, 3)
|
|
label := fmt.Sprintf("BASELINE#%d", i+1)
|
|
gocv.PutText(&viz, label, image.Pt(s.minX, s.maxY+20),
|
|
gocv.FontHersheyPlain, 1.2, color.RGBA{0, 255, 0, 255}, 2)
|
|
}
|
|
|
|
// Add summary text
|
|
summary := fmt.Sprintf("Contours H>=50px: %d | Baseline sections: %d", len(allContours), len(sections))
|
|
gocv.PutText(&viz, summary, image.Pt(10, 30),
|
|
gocv.FontHersheyDuplex, 1.0, color.RGBA{255, 255, 0, 255}, 2)
|
|
|
|
gocv.IMWrite("test_output/debug_all_contours.png", viz)
|
|
viz.Close()
|
|
fmt.Println("💾 Saved all contours visualization to test_output/debug_all_contours.png")
|
|
}
|
|
|
|
// 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
|
|
|
|
// Fixed offset from edges for sampling points
|
|
// Needs to be large enough to avoid curved edges due to tilt
|
|
offset := 10
|
|
|
|
// 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 (matching main pipeline)
|
|
binary := gocv.NewMat()
|
|
gocv.Threshold(gray, &binary, 180, 255, gocv.ThresholdBinary)
|
|
gray.Close()
|
|
|
|
return binary
|
|
}
|
|
|
|
// DetectRotationAndWidth analyzes a binary frame and returns rotation angle, width, and display areas
|
|
// Works on its own internal copy - does NOT modify the input frame
|
|
// Returns coordinates in TRANSFORMED (scaled to 860px, rotated) coordinate space
|
|
func DetectRotationAndWidth(binary gocv.Mat) DetectionResult {
|
|
// Clone the input so we don't modify it
|
|
workingCopy := binary.Clone()
|
|
defer workingCopy.Close()
|
|
|
|
// ========== PHASE 1: Detect rotation & scale from baseline ==========
|
|
|
|
// Detect bands (using 40px minimum height for original scale - lowered for stream2 which is lower res)
|
|
bands := DetectBands(workingCopy, 40)
|
|
|
|
// Save debug image showing detected bands (only in DEBUG mode)
|
|
if DEBUG {
|
|
viz := gocv.NewMat()
|
|
gocv.CvtColor(workingCopy, &viz, gocv.ColorGrayToBGR)
|
|
colors := []color.RGBA{
|
|
{255, 0, 0, 0}, // Red
|
|
{0, 255, 0, 0}, // Green
|
|
{0, 0, 255, 0}, // Blue
|
|
{255, 255, 0, 0}, // Yellow
|
|
{255, 0, 255, 0}, // Magenta
|
|
{0, 255, 255, 0}, // Cyan
|
|
}
|
|
for i, band := range bands {
|
|
c := colors[i%len(colors)]
|
|
gocv.Rectangle(&viz, image.Rect(band.minX, band.minY, band.maxX, band.maxY), c, 2)
|
|
gocv.PutText(&viz, fmt.Sprintf("Band %d: h=%d", i, band.maxY-band.minY),
|
|
image.Pt(band.minX, band.minY-5), gocv.FontHersheyPlain, 1.0, c, 1)
|
|
}
|
|
gocv.PutText(&viz, fmt.Sprintf("Total bands: %d (need >=2)", len(bands)),
|
|
image.Pt(10, 30), gocv.FontHersheyPlain, 1.5, color.RGBA{255, 255, 255, 0}, 2)
|
|
gocv.IMWrite("test_output/debug_bands.png", viz)
|
|
viz.Close()
|
|
}
|
|
|
|
// Check if we have any content visible
|
|
if len(bands) < 1 {
|
|
if DEBUG {
|
|
fmt.Printf("DEBUG: Detection failed - no bands found\n")
|
|
}
|
|
return DetectionResult{Success: false}
|
|
}
|
|
|
|
// For rotation detection, we ideally want a waveform band
|
|
// If available, use band 0 as graph band; otherwise skip rotation detection
|
|
var graphBand Band
|
|
var hasGraphBand bool
|
|
if len(bands) >= 1 {
|
|
graphBand = bands[0] // Use first band for rotation detection if available
|
|
hasGraphBand = true
|
|
if DEBUG {
|
|
fmt.Printf("DEBUG: Using band 0 as graph band (h=%d)\n", graphBand.maxY-graphBand.minY)
|
|
}
|
|
}
|
|
|
|
// Find baseline for rotation detection (only if we have a usable graph band)
|
|
var leftPoint, rightPoint *image.Point
|
|
var width, sections int
|
|
var err error
|
|
|
|
if hasGraphBand {
|
|
leftPoint, rightPoint, width, sections, err = findBaseline(workingCopy, graphBand)
|
|
}
|
|
|
|
// Variables for rotation and scale
|
|
var rotation float64
|
|
var scaleFactor float64
|
|
|
|
if !hasGraphBand || err != nil || sections != 2 {
|
|
// Baseline detection failed or not available - use fallback (assume no rotation, scale=1)
|
|
if DEBUG {
|
|
if !hasGraphBand {
|
|
fmt.Printf("DEBUG: No graph band - using fallback rotation=0, scale=1\n")
|
|
} else if err != nil {
|
|
fmt.Printf("DEBUG: Baseline failed (%v) - using fallback\n", err)
|
|
} else {
|
|
fmt.Printf("DEBUG: Baseline found %d sections - using fallback\n", sections)
|
|
}
|
|
}
|
|
rotation = 0.0
|
|
scaleFactor = 1.0 // No scaling for fixed layout approach
|
|
width = workingCopy.Cols()
|
|
// Create dummy points for visualization
|
|
midY := workingCopy.Rows() / 2
|
|
leftPoint = &image.Point{X: 0, Y: midY}
|
|
rightPoint = &image.Point{X: workingCopy.Cols(), Y: midY}
|
|
} else {
|
|
// Baseline detection succeeded - calculate rotation and scale
|
|
deltaY := float64(rightPoint.Y - leftPoint.Y)
|
|
deltaX := float64(rightPoint.X - leftPoint.X)
|
|
rotation = math.Atan2(deltaY, deltaX) * 180 / math.Pi
|
|
scaleFactor = 860.0 / float64(width)
|
|
}
|
|
|
|
// DEBUG: Visualize the baseline points
|
|
if DEBUG {
|
|
viz := gocv.NewMat()
|
|
gocv.CvtColor(workingCopy, &viz, gocv.ColorGrayToBGR)
|
|
|
|
// Find the two baseline sections for visualization
|
|
graphRegion := workingCopy.Region(image.Rect(0, graphBand.minY, workingCopy.Cols(), graphBand.maxY+1))
|
|
contours := gocv.FindContours(graphRegion, gocv.RetrievalExternal, gocv.ChainApproxSimple)
|
|
|
|
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
|
|
if width >= 5 && height >= 80 {
|
|
sections = append(sections, Section{
|
|
minX: rect.Min.X,
|
|
maxX: rect.Max.X,
|
|
minY: rect.Min.Y + graphBand.minY,
|
|
maxY: rect.Max.Y + graphBand.minY,
|
|
})
|
|
}
|
|
}
|
|
|
|
sort.Slice(sections, func(i, j int) bool {
|
|
return sections[i].minX < sections[j].minX
|
|
})
|
|
|
|
// Draw baseline sections in cyan
|
|
for i, sec := range sections {
|
|
rect := image.Rect(sec.minX, sec.minY, sec.maxX, sec.maxY)
|
|
gocv.Rectangle(&viz, rect, color.RGBA{0, 255, 255, 255}, 2)
|
|
label := fmt.Sprintf("Sec%d: %dpx", i+1, sec.maxX-sec.minX)
|
|
gocv.PutText(&viz, label, image.Pt(sec.minX, sec.minY-5),
|
|
gocv.FontHersheyPlain, 1.0, color.RGBA{0, 255, 255, 255}, 1)
|
|
}
|
|
|
|
// Draw left point (green circle)
|
|
gocv.Circle(&viz, *leftPoint, 10, color.RGBA{0, 255, 0, 255}, -1)
|
|
gocv.PutText(&viz, "L", image.Pt(leftPoint.X-5, leftPoint.Y-15),
|
|
gocv.FontHersheyPlain, 1.5, color.RGBA{0, 255, 0, 255}, 2)
|
|
|
|
// Draw right point (red circle)
|
|
gocv.Circle(&viz, *rightPoint, 10, color.RGBA{0, 0, 255, 255}, -1)
|
|
gocv.PutText(&viz, "R", image.Pt(rightPoint.X-5, rightPoint.Y-15),
|
|
gocv.FontHersheyPlain, 1.5, color.RGBA{0, 0, 255, 255}, 2)
|
|
|
|
// Draw line between baseline points (yellow)
|
|
gocv.Line(&viz, *leftPoint, *rightPoint, color.RGBA{0, 255, 255, 255}, 3)
|
|
|
|
// Draw graph band boundaries (magenta)
|
|
gocv.Line(&viz, image.Pt(0, graphBand.minY), image.Pt(viz.Cols(), graphBand.minY), color.RGBA{255, 0, 255, 255}, 2)
|
|
gocv.Line(&viz, image.Pt(0, graphBand.maxY), image.Pt(viz.Cols(), graphBand.maxY), color.RGBA{255, 0, 255, 255}, 2)
|
|
gocv.PutText(&viz, "Graph Band", image.Pt(10, graphBand.minY+20),
|
|
gocv.FontHersheyPlain, 1.2, color.RGBA{255, 0, 255, 255}, 1)
|
|
|
|
// Add measurements text overlay at top
|
|
widthText := fmt.Sprintf("Baseline Width: %dpx", width)
|
|
gocv.PutText(&viz, widthText, image.Pt(10, 30),
|
|
gocv.FontHersheyDuplex, 1.0, color.RGBA{255, 255, 0, 255}, 2)
|
|
|
|
rotText := fmt.Sprintf("Rotation: %.3f deg", rotation)
|
|
gocv.PutText(&viz, rotText, image.Pt(10, 60),
|
|
gocv.FontHersheyDuplex, 1.0, color.RGBA{255, 255, 0, 255}, 2)
|
|
|
|
scaleText := fmt.Sprintf("Scale: %.3f (->860px)", scaleFactor)
|
|
gocv.PutText(&viz, scaleText, image.Pt(10, 90),
|
|
gocv.FontHersheyDuplex, 1.0, color.RGBA{255, 255, 0, 255}, 2)
|
|
|
|
gocv.IMWrite("test_output/debug_baseline_detection.png", viz)
|
|
viz.Close()
|
|
fmt.Println("💾 Saved baseline detection visualization to test_output/debug_baseline_detection.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)
|
|
fmt.Printf("DEBUG: Width=%dpx, Scale factor=%.3f (baseline will become 860px)\n",
|
|
width, scaleFactor)
|
|
}
|
|
|
|
// ========== PHASE 2: Find the main digit band for SpO2/HR ==========
|
|
//
|
|
// For this pulse-ox display, use FIXED proportions since the layout is consistent
|
|
// and dynamic band detection is unreliable due to vertical UI elements
|
|
//
|
|
// After 90° CW rotation, the frame layout is approximately:
|
|
// - Y 0-35%: Status bar, bell icon
|
|
// - Y 35-45%: Waveform display
|
|
// - Y 45-60%: Main SpO2/HR digits (what we want!)
|
|
// - Y 60-75%: Secondary readings
|
|
|
|
frameHeight := workingCopy.Rows()
|
|
frameWidth := workingCopy.Cols()
|
|
|
|
// Use fixed proportions for the digit band
|
|
digitBand := Band{
|
|
minX: int(float64(frameWidth) * 0.05), // Slight margin from left
|
|
maxX: int(float64(frameWidth) * 0.75), // Avoid the vertical progress bar on right
|
|
minY: int(float64(frameHeight) * 0.45),
|
|
maxY: int(float64(frameHeight) * 0.58),
|
|
}
|
|
|
|
if DEBUG {
|
|
fmt.Printf("DEBUG: Using fixed digit band: Y[%d-%d], X[%d-%d] (frame %dx%d)\n",
|
|
digitBand.minY, digitBand.maxY, digitBand.minX, digitBand.maxX,
|
|
frameWidth, frameHeight)
|
|
}
|
|
|
|
// Split digit band: left half = SpO2, right half = HR
|
|
bandWidth := digitBand.maxX - digitBand.minX
|
|
midX := digitBand.minX + bandWidth/2
|
|
|
|
// SpO2 is left half, HR is right half
|
|
spo2Rect := image.Rect(
|
|
digitBand.minX,
|
|
digitBand.minY,
|
|
midX,
|
|
digitBand.maxY,
|
|
)
|
|
hrRect := image.Rect(
|
|
midX,
|
|
digitBand.minY,
|
|
digitBand.maxX,
|
|
digitBand.maxY,
|
|
)
|
|
|
|
if DEBUG {
|
|
fmt.Printf("DEBUG: SpO2 display area: 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: X[%d-%d] Y[%d-%d]\n",
|
|
hrRect.Min.X, hrRect.Max.X, hrRect.Min.Y, hrRect.Max.Y)
|
|
|
|
// Visualize final display boxes
|
|
viz2 := gocv.NewMat()
|
|
gocv.CvtColor(workingCopy, &viz2, gocv.ColorGrayToBGR)
|
|
|
|
// Draw SpO2 box in red
|
|
gocv.Rectangle(&viz2, spo2Rect, color.RGBA{255, 0, 0, 255}, 3)
|
|
gocv.PutText(&viz2, "SpO2", image.Pt(spo2Rect.Min.X, spo2Rect.Min.Y-10),
|
|
gocv.FontHersheyDuplex, 1.2, color.RGBA{255, 0, 0, 255}, 2)
|
|
|
|
// Draw HR box in cyan
|
|
gocv.Rectangle(&viz2, hrRect, color.RGBA{0, 255, 255, 255}, 3)
|
|
gocv.PutText(&viz2, "HR", image.Pt(hrRect.Min.X, hrRect.Min.Y-10),
|
|
gocv.FontHersheyDuplex, 1.2, color.RGBA{0, 255, 255, 255}, 2)
|
|
|
|
gocv.IMWrite("test_output/debug_final_boxes.png", viz2)
|
|
viz2.Close()
|
|
fmt.Println("💾 Saved final display boxes visualization to test_output/debug_final_boxes.png")
|
|
}
|
|
|
|
return DetectionResult{
|
|
Rotation: rotation,
|
|
Width: width,
|
|
ScaleFactor: scaleFactor,
|
|
SpO2: spo2Rect,
|
|
HR: hrRect,
|
|
Success: 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
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Visualize found boxes
|
|
visualization := gocv.NewMat()
|
|
defer visualization.Close()
|
|
gocv.CvtColor(digitRegion, &visualization, gocv.ColorGrayToBGR)
|
|
|
|
for i, box := range digitBoxes {
|
|
// Draw box in green
|
|
green := color.RGBA{0, 255, 0, 255}
|
|
gocv.Rectangle(&visualization, box, green, 2)
|
|
|
|
// Show box number and size
|
|
label := fmt.Sprintf("#%d: %dx%d", i, box.Dx(), box.Dy())
|
|
gocv.PutText(&visualization, label, image.Pt(box.Min.X, box.Min.Y-5),
|
|
gocv.FontHersheyPlain, 0.8, green, 1)
|
|
}
|
|
|
|
gocv.IMWrite("test_output/debug_digit_boxes.png", visualization)
|
|
fmt.Printf("DEBUG: Saved digit boxes to test_output/debug_digit_boxes.png\n")
|
|
}
|
|
|
|
return digitBoxes
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// applyTransforms applies rotation and scaling to a frame
|
|
// This is the SINGLE source of truth for transform logic
|
|
// Both detection and main loop MUST use this function to ensure identical transforms
|
|
func applyTransforms(img gocv.Mat, rotation float64, scaleFactor float64) gocv.Mat {
|
|
// Apply rotation (if needed)
|
|
var rotated gocv.Mat
|
|
if rotation != 0.0 {
|
|
rotated = RotateImage(img, rotation)
|
|
} else {
|
|
rotated = img.Clone()
|
|
}
|
|
|
|
// Apply scaling (if needed)
|
|
var scaled gocv.Mat
|
|
if scaleFactor != 1.0 {
|
|
scaled = ScaleByFactor(rotated, scaleFactor)
|
|
if rotation != 0.0 {
|
|
rotated.Close() // Only close if we created a new Mat
|
|
}
|
|
} else {
|
|
scaled = rotated
|
|
}
|
|
|
|
return scaled
|
|
}
|
|
|
|
// DisplayAreas holds the two display rectangles for SpO2 and HR
|
|
type DisplayAreas struct {
|
|
SpO2 image.Rectangle
|
|
HR image.Rectangle
|
|
}
|
|
|
|
// 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),
|
|
}
|
|
}
|