744 lines
25 KiB
Go
744 lines
25 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"math"
|
|
"sort"
|
|
"time"
|
|
|
|
"gocv.io/x/gocv"
|
|
)
|
|
|
|
type Display struct {
|
|
IsSplit bool // true = one box split in half, false = two separate boxes
|
|
FullRect image.Rectangle // used when IsSplit=true
|
|
LeftRect image.Rectangle // used when IsSplit=false
|
|
RightRect image.Rectangle // used when IsSplit=false
|
|
}
|
|
|
|
type ScreenLayout struct {
|
|
SpO2Area image.Rectangle
|
|
HRArea image.Rectangle
|
|
}
|
|
|
|
func detectScreenLayoutAreas(rotated gocv.Mat) (*ScreenLayout, float64, error) {
|
|
// Correct order: Find center region FIRST (using all contours), THEN find digit displays
|
|
|
|
startTime := time.Now()
|
|
|
|
// Input is already thresholded binary image - work directly on it
|
|
// No grayscale, no threshold - just find contours
|
|
|
|
// DEBUG: Save input to see what we're working with
|
|
if DEBUG_MODE {
|
|
gocv.IMWrite("test_output/layout_step1_input.jpg", rotated)
|
|
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step1_input.jpg")
|
|
}
|
|
|
|
// STEP 3: Apply erosion to separate nearby elements (5x5 kernel) - COMMENTED OUT
|
|
// step3Start := time.Now()
|
|
// kernel := gocv.GetStructuringElement(gocv.MorphRect, image.Pt(5, 5))
|
|
// eroded := gocv.NewMat()
|
|
// gocv.Erode(thresh, &eroded, kernel)
|
|
// kernel.Close()
|
|
// defer eroded.Close()
|
|
// fmt.Printf(" [TIMING] Step 3 (Erosion): %dms\n", time.Since(step3Start).Milliseconds())
|
|
|
|
// STEP 2: Find contours - use RetrievalList to get ALL contours including nested
|
|
step2Start := time.Now()
|
|
contours := gocv.FindContours(rotated, gocv.RetrievalList, gocv.ChainApproxSimple)
|
|
defer contours.Close()
|
|
|
|
logMessage(Both, Info, " Found %d total contours", contours.Size())
|
|
if contours.Size() == 0 {
|
|
logMessage(Both, Error, " ERROR: No contours found in thresholded frame")
|
|
logMessage(Both, Error, " Frame dimensions: %dx%d", rotated.Cols(), rotated.Rows())
|
|
// Save debug image
|
|
gocv.IMWrite("test_output/error_input.jpg", rotated)
|
|
logMessage(Both, Error, " Saved debug image to test_output/error_input.jpg")
|
|
return nil, 0, nil
|
|
}
|
|
|
|
logMessage(LogFile, Debug, " [TIMING] Step 2 (Find contours): %dms", time.Since(step2Start).Milliseconds())
|
|
logMessage(LogFile, Debug, " [TIMING] Step 2 (Total): %dms", time.Since(startTime).Milliseconds())
|
|
|
|
// STEP 1: Collect ALL boxes (no filter) and print details
|
|
var allBoxes []image.Rectangle
|
|
for i := 0; i < contours.Size(); i++ {
|
|
rect := gocv.BoundingRect(contours.At(i))
|
|
allBoxes = append(allBoxes, rect)
|
|
// fmt.Printf(" Box %d: X=%d-%d (W=%d), Y=%d-%d (H=%d)\n",
|
|
// i, rect.Min.X, rect.Max.X, rect.Dx(),
|
|
// rect.Min.Y, rect.Max.Y, rect.Dy())
|
|
}
|
|
|
|
if len(allBoxes) == 0 {
|
|
logMessage(Both, Error, " ERROR: No bounding boxes found from contours")
|
|
return nil, 0, nil
|
|
}
|
|
|
|
logMessage(LogFile, Debug, " Found %d boxes (all, no filter)", len(allBoxes))
|
|
logMessage(LogFile, Debug, "")
|
|
|
|
// DEBUG: Skip step 6a and 6b visualizations
|
|
// rawContoursVis := rotated.Clone()
|
|
// ...
|
|
// allBoxesVis := rotated.Clone()
|
|
// ...
|
|
|
|
// STEP 2: Filter to significant boxes (width or height > 30px) for processing
|
|
var significantBoxes []image.Rectangle
|
|
for _, box := range allBoxes {
|
|
if box.Dx() > 30 || box.Dy() > 30 {
|
|
significantBoxes = append(significantBoxes, box)
|
|
}
|
|
}
|
|
|
|
if len(significantBoxes) == 0 {
|
|
logMessage(Both, Error, " ERROR: No significant boxes found (>30px)")
|
|
logMessage(Both, Error, " All %d boxes were too small", len(allBoxes))
|
|
return nil, 0, nil
|
|
}
|
|
|
|
logMessage(LogFile, Debug, " Found %d significant boxes (>30px) for processing", len(significantBoxes))
|
|
|
|
// STEP 3: Calculate bounding box from significant boxes (not just digit displays)
|
|
minX := significantBoxes[0].Min.X
|
|
minY := significantBoxes[0].Min.Y
|
|
maxX := significantBoxes[0].Max.X
|
|
maxY := significantBoxes[0].Max.Y
|
|
|
|
for _, box := range significantBoxes {
|
|
if box.Min.X < minX {
|
|
minX = box.Min.X
|
|
}
|
|
if box.Min.Y < minY {
|
|
minY = box.Min.Y
|
|
}
|
|
if box.Max.X > maxX {
|
|
maxX = box.Max.X
|
|
}
|
|
if box.Max.Y > maxY {
|
|
maxY = box.Max.Y
|
|
}
|
|
}
|
|
|
|
boundingBox := image.Rect(minX, minY, maxX, maxY)
|
|
boundingBoxWidth := boundingBox.Dx()
|
|
logMessage(LogFile, Debug, " Bounding box from all boxes: X=%d-%d, Y=%d-%d, Width=%d", minX, maxX, minY, maxY, boundingBoxWidth)
|
|
|
|
// Calculate required scale to normalize to 860px width
|
|
const TARGET_WIDTH = 860
|
|
scale := float64(TARGET_WIDTH) / float64(boundingBoxWidth)
|
|
logMessage(Both, Info, " 📊 Bounding box width: %dpx, Scale factor: %.3f (target: %dpx)", boundingBoxWidth, scale, TARGET_WIDTH)
|
|
|
|
// STEP 4: Scale the frame to 860px width
|
|
step4Start := time.Now()
|
|
newWidth := int(float64(rotated.Cols()) * scale)
|
|
newHeight := int(float64(rotated.Rows()) * scale)
|
|
scaled := gocv.NewMat()
|
|
gocv.Resize(rotated, &scaled, image.Pt(newWidth, newHeight), 0, 0, gocv.InterpolationLinear)
|
|
defer scaled.Close()
|
|
logMessage(LogFile, Debug, " [TIMING] Step 4 (Scale frame): %dms", time.Since(step4Start).Milliseconds())
|
|
logMessage(LogFile, Debug, " Scaled frame: %dx%d -> %dx%d", rotated.Cols(), rotated.Rows(), newWidth, newHeight)
|
|
|
|
// CRITICAL: Scale the bounding box coordinates to match the scaled frame
|
|
scaledMinX := int(float64(minX) * scale)
|
|
scaledMaxX := int(float64(maxX) * scale)
|
|
scaledMinY := int(float64(minY) * scale)
|
|
scaledMaxY := int(float64(maxY) * scale)
|
|
scaledBoundingBox := image.Rect(scaledMinX, scaledMinY, scaledMaxX, scaledMaxY)
|
|
logMessage(LogFile, Debug, " Scaled bounding box: X=%d-%d, Y=%d-%d (from original: X=%d-%d, Y=%d-%d)",
|
|
scaledMinX, scaledMaxX, scaledMinY, scaledMaxY, minX, maxX, minY, maxY)
|
|
|
|
// Scale all significant boxes coordinates too
|
|
var scaledSignificantBoxes []image.Rectangle
|
|
for _, box := range significantBoxes {
|
|
scaledBox := image.Rect(
|
|
int(float64(box.Min.X)*scale),
|
|
int(float64(box.Min.Y)*scale),
|
|
int(float64(box.Max.X)*scale),
|
|
int(float64(box.Max.Y)*scale),
|
|
)
|
|
scaledSignificantBoxes = append(scaledSignificantBoxes, scaledBox)
|
|
}
|
|
|
|
// Scale all boxes too (for visualization)
|
|
var scaledAllBoxes []image.Rectangle
|
|
for _, box := range allBoxes {
|
|
scaledBox := image.Rect(
|
|
int(float64(box.Min.X)*scale),
|
|
int(float64(box.Min.Y)*scale),
|
|
int(float64(box.Max.X)*scale),
|
|
int(float64(box.Max.Y)*scale),
|
|
)
|
|
scaledAllBoxes = append(scaledAllBoxes, scaledBox)
|
|
}
|
|
|
|
// All subsequent processing now works on the SCALED frame with SCALED coordinates
|
|
// This ensures boxes are calculated in scaled coordinates
|
|
|
|
// STEP 5: Find 50% line
|
|
height := scaledBoundingBox.Dy()
|
|
line50 := scaledBoundingBox.Min.Y + height/2
|
|
logMessage(LogFile, Debug, " 50%% line at Y=%d", line50)
|
|
|
|
// DEBUG: Save step 5 visualization - bounding box and 50% line
|
|
bboxVis := scaled.Clone()
|
|
// Draw bounding box in blue
|
|
gocv.Rectangle(&bboxVis, scaledBoundingBox, color.RGBA{0, 100, 255, 255}, 3)
|
|
// Draw 50% line in red
|
|
gocv.Line(&bboxVis, image.Pt(scaledMinX, line50), image.Pt(scaledMaxX, line50), color.RGBA{255, 0, 0, 255}, 3)
|
|
// Add labels
|
|
gocv.PutText(&bboxVis, fmt.Sprintf("BBox: %d-%d, %d-%d", scaledMinX, scaledMaxX, scaledMinY, scaledMaxY),
|
|
image.Pt(scaledMinX+10, scaledMinY+30),
|
|
gocv.FontHersheyPlain, 1.2, color.RGBA{0, 100, 255, 255}, 2)
|
|
gocv.PutText(&bboxVis, fmt.Sprintf("50%% line: Y=%d", line50),
|
|
image.Pt(scaledMinX+10, line50-10),
|
|
gocv.FontHersheyPlain, 1.2, color.RGBA{255, 0, 0, 255}, 2)
|
|
gocv.IMWrite("test_output/layout_step5_bbox_and_line.jpg", bboxVis)
|
|
bboxVis.Close()
|
|
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step5_bbox_and_line.jpg")
|
|
|
|
// DEBUG: Save step 6 - all boxes with numbers
|
|
allBoxesVis := scaled.Clone()
|
|
for i, box := range scaledAllBoxes {
|
|
gocv.Rectangle(&allBoxesVis, box, color.RGBA{0, 255, 0, 255}, 2)
|
|
// Add box number
|
|
gocv.PutText(&allBoxesVis, fmt.Sprintf("%d", i),
|
|
image.Pt(box.Min.X+5, box.Min.Y+15),
|
|
gocv.FontHersheyPlain, 1.2, color.RGBA{0, 255, 0, 255}, 2)
|
|
}
|
|
gocv.IMWrite("test_output/layout_step6_all_boxes.jpg", allBoxesVis)
|
|
allBoxesVis.Close()
|
|
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step6_all_boxes.jpg")
|
|
|
|
// STEP 6: Filter to boxes crossing 50% line (center region)
|
|
var centerBoxes []image.Rectangle
|
|
for _, box := range scaledSignificantBoxes {
|
|
if box.Min.Y < line50 && box.Max.Y > line50 {
|
|
centerBoxes = append(centerBoxes, box)
|
|
}
|
|
}
|
|
|
|
if len(centerBoxes) == 0 {
|
|
logMessage(Both, Error, " ERROR: No boxes crossing 50%%%% line")
|
|
logMessage(Both, Error, " 50%%%% line at Y=%d, checked %d significant boxes", line50, len(scaledSignificantBoxes))
|
|
return nil, 0, nil
|
|
}
|
|
|
|
logMessage(LogFile, Debug, " Found %d boxes crossing 50%% line (center region)", len(centerBoxes))
|
|
|
|
// DEBUG: Save step 7 - center boxes visualization
|
|
centerBoxesVis := scaled.Clone()
|
|
// Draw 50% line
|
|
gocv.Line(¢erBoxesVis, image.Pt(scaledMinX, line50), image.Pt(scaledMaxX, line50), color.RGBA{255, 0, 0, 255}, 3)
|
|
// Draw center boxes
|
|
for _, box := range centerBoxes {
|
|
gocv.Rectangle(¢erBoxesVis, box, color.RGBA{0, 255, 255, 255}, 2)
|
|
}
|
|
gocv.IMWrite("test_output/layout_step7_center_boxes.jpg", centerBoxesVis)
|
|
centerBoxesVis.Close()
|
|
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step7_center_boxes.jpg")
|
|
|
|
// STEP 7: From center boxes, identify digit displays (height >= 110px)
|
|
var digitDisplays []image.Rectangle
|
|
for _, box := range centerBoxes {
|
|
logMessage(LogFile, Debug, " Center box: X=%d-%d (W=%d), Y=%d-%d (H=%d)",
|
|
box.Min.X, box.Max.X, box.Dx(),
|
|
box.Min.Y, box.Max.Y, box.Dy())
|
|
if box.Dy() >= 110 {
|
|
digitDisplays = append(digitDisplays, box)
|
|
logMessage(LogFile, Debug, " ✓ Added as digit display (H=%d >= 110)", box.Dy())
|
|
} else {
|
|
logMessage(LogFile, Debug, " ✗ Skipped (H=%d < 110)", box.Dy())
|
|
}
|
|
}
|
|
|
|
if len(digitDisplays) == 0 {
|
|
logMessage(Both, Error, " ERROR: No digit displays found (height >= 110px) in center region")
|
|
logMessage(Both, Error, " Found %d center boxes, none met height requirement", len(centerBoxes))
|
|
return nil, 0, nil
|
|
}
|
|
|
|
logMessage(LogFile, Debug, " Found %d digit displays (height > 120px) BEFORE trimming", len(digitDisplays))
|
|
|
|
// DEBUG: Save step 8 - digit displays visualization
|
|
if DEBUG_MODE {
|
|
digitDisplaysVis := scaled.Clone()
|
|
for _, box := range digitDisplays {
|
|
gocv.Rectangle(&digitDisplaysVis, box, color.RGBA{255, 0, 255, 255}, 3)
|
|
}
|
|
gocv.IMWrite("test_output/layout_step8_digit_displays.jpg", digitDisplaysVis)
|
|
digitDisplaysVis.Close()
|
|
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step8_digit_displays.jpg")
|
|
}
|
|
|
|
// STEP 8: Get Y range from digit displays
|
|
minDigitY := digitDisplays[0].Min.Y
|
|
maxDigitY := digitDisplays[0].Max.Y
|
|
for _, box := range digitDisplays {
|
|
if box.Min.Y < minDigitY {
|
|
minDigitY = box.Min.Y
|
|
}
|
|
if box.Max.Y > maxDigitY {
|
|
maxDigitY = box.Max.Y
|
|
}
|
|
}
|
|
logMessage(LogFile, Debug, " Digit display Y range: %d-%d", minDigitY, maxDigitY)
|
|
|
|
// STEP 9: Find ALL boxes (from scaledAllBoxes) within this Y range
|
|
var boxesInRange []image.Rectangle
|
|
for _, box := range scaledAllBoxes {
|
|
// Box overlaps with Y range if its bottom is below minDigitY and top is above maxDigitY
|
|
if box.Max.Y > minDigitY && box.Min.Y < maxDigitY {
|
|
boxesInRange = append(boxesInRange, box)
|
|
}
|
|
}
|
|
logMessage(LogFile, Debug, " Found %d boxes in Y range %d-%d", len(boxesInRange), minDigitY, maxDigitY)
|
|
|
|
// DEBUG: Save step 9 - boxes in range visualization
|
|
boxesInRangeVis := scaled.Clone()
|
|
// Draw Y range lines
|
|
gocv.Line(&boxesInRangeVis, image.Pt(0, minDigitY), image.Pt(boxesInRangeVis.Cols(), minDigitY), color.RGBA{255, 0, 0, 255}, 2)
|
|
gocv.Line(&boxesInRangeVis, image.Pt(0, maxDigitY), image.Pt(boxesInRangeVis.Cols(), maxDigitY), color.RGBA{255, 0, 0, 255}, 2)
|
|
// Draw digit displays in magenta
|
|
for _, box := range digitDisplays {
|
|
gocv.Rectangle(&boxesInRangeVis, box, color.RGBA{255, 0, 255, 255}, 3)
|
|
}
|
|
// Draw all boxes in range in cyan
|
|
for _, box := range boxesInRange {
|
|
gocv.Rectangle(&boxesInRangeVis, box, color.RGBA{0, 255, 255, 255}, 1)
|
|
}
|
|
gocv.IMWrite("test_output/layout_step9_boxes_in_range.jpg", boxesInRangeVis)
|
|
boxesInRangeVis.Close()
|
|
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step9_boxes_in_range.jpg")
|
|
|
|
// STEP 10: Trim digit displays based on small boxes crossing their right edge
|
|
for i := range digitDisplays {
|
|
originalMaxX := digitDisplays[i].Max.X
|
|
newMaxX := originalMaxX
|
|
|
|
// Check each box in range
|
|
for _, smallBox := range boxesInRange {
|
|
// Skip if this is the digit display itself
|
|
if smallBox == digitDisplays[i] {
|
|
continue
|
|
}
|
|
|
|
// Check if small box crosses the right edge of this digit display
|
|
// Crosses if: smallBox.Min.X < digitDisplay.Max.X AND smallBox.Max.X > digitDisplay.Max.X
|
|
if smallBox.Min.X < digitDisplays[i].Max.X && smallBox.Max.X > digitDisplays[i].Max.X {
|
|
// Use the left edge of the small box as the new right edge
|
|
if smallBox.Min.X < newMaxX {
|
|
newMaxX = smallBox.Min.X
|
|
logMessage(LogFile, Debug, " Trimming digit display %d: right edge %d -> %d (small box at X=%d-%d)",
|
|
i, originalMaxX, newMaxX, smallBox.Min.X, smallBox.Max.X)
|
|
}
|
|
}
|
|
}
|
|
|
|
digitDisplays[i].Max.X = newMaxX
|
|
}
|
|
|
|
logMessage(LogFile, Debug, " Digit displays AFTER trimming: %d", len(digitDisplays))
|
|
|
|
// DEBUG: Save step 11 - trimmed digit displays visualization
|
|
trimmedVis := scaled.Clone()
|
|
for _, box := range digitDisplays {
|
|
gocv.Rectangle(&trimmedVis, box, color.RGBA{0, 255, 0, 255}, 4)
|
|
}
|
|
gocv.IMWrite("test_output/layout_step11_trimmed_displays.jpg", trimmedVis)
|
|
trimmedVis.Close()
|
|
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step11_trimmed_displays.jpg")
|
|
|
|
if len(digitDisplays) < 2 {
|
|
// Save debug visualization showing what was found
|
|
debugVis := scaled.Clone()
|
|
|
|
// Draw 50% line
|
|
gocv.Line(&debugVis, image.Pt(scaledMinX, line50), image.Pt(scaledMaxX, line50), color.RGBA{255, 0, 0, 255}, 2)
|
|
gocv.PutText(&debugVis, fmt.Sprintf("50%% line: Y=%d", line50),
|
|
image.Pt(10, line50-10), gocv.FontHersheyPlain, 1.5, color.RGBA{255, 0, 0, 255}, 2)
|
|
|
|
// Draw all center boxes with labels
|
|
for i, box := range centerBoxes {
|
|
boxColor := color.RGBA{0, 255, 255, 255} // Cyan for rejected
|
|
label := fmt.Sprintf("#%d: H=%d", i, box.Dy())
|
|
|
|
// Check if this box qualified as digit display
|
|
qualified := false
|
|
for _, dd := range digitDisplays {
|
|
if box == dd {
|
|
qualified = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if qualified {
|
|
boxColor = color.RGBA{0, 255, 0, 255} // Green for qualified
|
|
label += " OK"
|
|
} else if box.Dy() < 110 {
|
|
label += " TOO SHORT"
|
|
}
|
|
|
|
gocv.Rectangle(&debugVis, box, boxColor, 3)
|
|
gocv.PutText(&debugVis, label,
|
|
image.Pt(box.Min.X+5, box.Min.Y+25),
|
|
gocv.FontHersheyPlain, 1.5, boxColor, 2)
|
|
}
|
|
|
|
// Add summary at top
|
|
summary := fmt.Sprintf("Found: %d center boxes, %d digit displays (need 2)",
|
|
len(centerBoxes), len(digitDisplays))
|
|
gocv.PutText(&debugVis, summary,
|
|
image.Pt(10, 30), gocv.FontHersheyDuplex, 1.0, color.RGBA{255, 255, 255, 255}, 2)
|
|
gocv.PutText(&debugVis, "Requirement: Height >= 110px",
|
|
image.Pt(10, 60), gocv.FontHersheyDuplex, 1.0, color.RGBA{255, 255, 255, 255}, 2)
|
|
|
|
timestamp := time.Now().Format("20060102_150405")
|
|
errorFilename := fmt.Sprintf("test_output/layout_error_%s.jpg", timestamp)
|
|
gocv.IMWrite(errorFilename, debugVis)
|
|
debugVis.Close()
|
|
|
|
// Enhanced error message
|
|
logMessage(Both, Error, " ERROR: Only %d digit display(s) found (need 2)", len(digitDisplays))
|
|
logMessage(Both, Error, " ")
|
|
logMessage(Both, Error, " LIKELY CAUSE: Pulse oximeter is not centered under camera")
|
|
logMessage(Both, Error, " - Device may be at an angle or offset to one side")
|
|
logMessage(Both, Error, " - Only one display (SpO2 or HR) is in the center detection region")
|
|
logMessage(Both, Error, " ")
|
|
logMessage(Both, Error, " ACTION REQUIRED: Physically reposition camera or device")
|
|
logMessage(Both, Error, " - Move device to be centered and level under camera")
|
|
logMessage(Both, Error, " - Or adjust camera angle to capture both displays")
|
|
logMessage(Both, Error, " ")
|
|
logMessage(Both, Error, " Technical details:")
|
|
logMessage(Both, Error, " Center boxes found: %d (crossing 50%% line at Y=%d)", len(centerBoxes), line50)
|
|
for i, box := range centerBoxes {
|
|
qualified := ""
|
|
for _, dd := range digitDisplays {
|
|
if box == dd {
|
|
qualified = " ✓ QUALIFIED"
|
|
break
|
|
}
|
|
}
|
|
if qualified == "" {
|
|
if box.Dy() < 110 {
|
|
qualified = " ✗ TOO SHORT (< 110px)"
|
|
}
|
|
}
|
|
logMessage(Both, Error, " Box #%d: W=%dpx, H=%dpx at X=%d-%d, Y=%d-%d%s",
|
|
i, box.Dx(), box.Dy(), box.Min.X, box.Max.X, box.Min.Y, box.Max.Y, qualified)
|
|
}
|
|
logMessage(Both, Error, " 💾 Debug image saved: %s", errorFilename)
|
|
logMessage(Both, Error, " Green boxes = qualified, Cyan = rejected")
|
|
return nil, 0, nil
|
|
}
|
|
|
|
// STEP 11: Get Y range from digit displays
|
|
minCenterY := digitDisplays[0].Min.Y
|
|
maxCenterY := digitDisplays[0].Max.Y
|
|
|
|
for _, box := range digitDisplays {
|
|
if box.Min.Y < minCenterY {
|
|
minCenterY = box.Min.Y
|
|
}
|
|
if box.Max.Y > maxCenterY {
|
|
maxCenterY = box.Max.Y
|
|
}
|
|
}
|
|
|
|
// Create center region
|
|
centerRegion := image.Rect(scaledBoundingBox.Min.X, minCenterY, scaledBoundingBox.Max.X, maxCenterY)
|
|
|
|
// STEP 12: Find X-center to split left/right
|
|
centerX := centerRegion.Min.X + centerRegion.Dx()/2
|
|
logMessage(LogFile, Debug, " Center X: %d", centerX)
|
|
|
|
// STEP 13: Find rightmost X and Y range in each half
|
|
spo2RightX := -1
|
|
spo2MinY := 10000
|
|
spo2MaxY := 0
|
|
hrRightX := -1
|
|
hrMinY := 10000
|
|
hrMaxY := 0
|
|
|
|
for _, box := range digitDisplays {
|
|
// Determine which half this box belongs to based on its center
|
|
boxCenterX := box.Min.X + box.Dx()/2
|
|
|
|
if boxCenterX < centerX {
|
|
// Left half (SpO2)
|
|
if box.Max.X > spo2RightX {
|
|
spo2RightX = 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)
|
|
if box.Max.X > hrRightX {
|
|
hrRightX = box.Max.X
|
|
}
|
|
if box.Min.Y < hrMinY {
|
|
hrMinY = box.Min.Y
|
|
}
|
|
if box.Max.Y > hrMaxY {
|
|
hrMaxY = box.Max.Y
|
|
}
|
|
}
|
|
}
|
|
|
|
if spo2RightX == -1 || hrRightX == -1 {
|
|
logMessage(Both, Error, " ERROR: Could not find displays in both halves")
|
|
logMessage(Both, Error, " Left half (SpO2): rightX=%d, Right half (HR): rightX=%d", spo2RightX, hrRightX)
|
|
return nil, 0, nil
|
|
}
|
|
|
|
// STEP 14: Create boxes with fixed CUT_WIDTH
|
|
spo2LeftX := spo2RightX - CUT_WIDTH
|
|
spo2Box := image.Rect(spo2LeftX, spo2MinY, spo2RightX, spo2MaxY)
|
|
|
|
hrLeftX := hrRightX - CUT_WIDTH
|
|
hrBox := image.Rect(hrLeftX, hrMinY, hrRightX, hrMaxY)
|
|
|
|
logMessage(LogFile, Debug, " Final SpO2 box: X=%d-%d, Y=%d-%d", spo2Box.Min.X, spo2Box.Max.X, spo2Box.Min.Y, spo2Box.Max.Y)
|
|
logMessage(LogFile, Debug, " Final HR box: X=%d-%d, Y=%d-%d", hrBox.Min.X, hrBox.Max.X, hrBox.Min.Y, hrBox.Max.Y)
|
|
|
|
// DEBUG: Save step 15 - final boxes visualization
|
|
finalVis := scaled.Clone()
|
|
gocv.Rectangle(&finalVis, spo2Box, color.RGBA{255, 0, 0, 255}, 4)
|
|
gocv.Rectangle(&finalVis, hrBox, color.RGBA{0, 255, 255, 255}, 4)
|
|
gocv.PutText(&finalVis, "SpO2", image.Pt(spo2Box.Min.X, spo2Box.Min.Y-10),
|
|
gocv.FontHersheyDuplex, 1.2, color.RGBA{255, 0, 0, 255}, 2)
|
|
gocv.PutText(&finalVis, "HR", image.Pt(hrBox.Min.X, hrBox.Min.Y-10),
|
|
gocv.FontHersheyDuplex, 1.2, color.RGBA{0, 255, 255, 255}, 2)
|
|
gocv.IMWrite("test_output/layout_step15_final_boxes.jpg", finalVis)
|
|
finalVis.Close()
|
|
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step15_final_boxes.jpg")
|
|
|
|
logMessage(LogFile, Debug, " [TIMING] Total layout detection: %dms", time.Since(startTime).Milliseconds())
|
|
|
|
// Return layout and scale factor
|
|
return &ScreenLayout{
|
|
SpO2Area: spo2Box,
|
|
HRArea: hrBox,
|
|
}, scale, nil
|
|
}
|
|
|
|
// Old functions kept for backward compatibility but deprecated
|
|
func groupAdjacentContours(rects []image.Rectangle) []Display {
|
|
var displays []Display
|
|
used := make([]bool, len(rects))
|
|
|
|
logMessage(LogFile, Debug, " Grouping %d candidate contours:", len(rects))
|
|
for i := 0; i < len(rects); i++ {
|
|
if used[i] {
|
|
continue
|
|
}
|
|
|
|
// Look for an adjacent rect with similar Y coordinate
|
|
var adjacent *int
|
|
for j := i + 1; j < len(rects); j++ {
|
|
if used[j] {
|
|
continue
|
|
}
|
|
|
|
// Check if Y coordinates are similar (within 20 pixels)
|
|
yDiff := rects[i].Min.Y - rects[j].Min.Y
|
|
if yDiff < 0 {
|
|
yDiff = -yDiff
|
|
}
|
|
if yDiff > 20 {
|
|
logMessage(LogFile, Debug, " Skip pairing rect[%d] and rect[%d]: Y diff=%d > 20", i, j, yDiff)
|
|
continue
|
|
}
|
|
|
|
// Check if they're side-by-side (allow small overlap or gap)
|
|
xGap := rects[j].Min.X - rects[i].Max.X
|
|
// Allow up to 50 pixels of overlap (negative gap) or up to 200 pixels of space
|
|
if xGap >= -50 && xGap < 200 {
|
|
logMessage(LogFile, Debug, " Pairing rect[%d] and rect[%d]: Y diff=%d, X gap=%d", i, j, yDiff, xGap)
|
|
adjacent = &j
|
|
break
|
|
} else {
|
|
logMessage(LogFile, Debug, " Skip pairing rect[%d] and rect[%d]: X gap=%d not in range [-50, 200)", i, j, xGap)
|
|
}
|
|
}
|
|
|
|
if adjacent != nil {
|
|
// Two separate contours - use them as left and right
|
|
used[i] = true
|
|
used[*adjacent] = true
|
|
|
|
left := rects[i]
|
|
right := rects[*adjacent]
|
|
|
|
// Ensure left is actually on the left
|
|
if left.Min.X > right.Min.X {
|
|
left, right = right, left
|
|
}
|
|
|
|
logMessage(LogFile, Debug, " DUAL: [%dx%d @(%d,%d)] + [%dx%d @(%d,%d)]",
|
|
left.Dx(), left.Dy(), left.Min.X, left.Min.Y,
|
|
right.Dx(), right.Dy(), right.Min.X, right.Min.Y)
|
|
|
|
displays = append(displays, Display{
|
|
IsSplit: false,
|
|
LeftRect: left,
|
|
RightRect: right,
|
|
})
|
|
} else {
|
|
// Single contour - will be split in half
|
|
used[i] = true
|
|
|
|
logMessage(LogFile, Debug, " SPLIT: [%dx%d @(%d,%d)]",
|
|
rects[i].Dx(), rects[i].Dy(), rects[i].Min.X, rects[i].Min.Y)
|
|
|
|
displays = append(displays, Display{
|
|
IsSplit: true,
|
|
FullRect: rects[i],
|
|
})
|
|
}
|
|
}
|
|
|
|
return displays
|
|
}
|
|
|
|
func detectScreenLayout(rotated gocv.Mat) ([]Display, error) {
|
|
gray := gocv.NewMat()
|
|
gocv.CvtColor(rotated, &gray, gocv.ColorBGRToGray)
|
|
thresh := gocv.NewMat()
|
|
gocv.Threshold(gray, &thresh, 170, 255, gocv.ThresholdBinary)
|
|
gray.Close()
|
|
contours := gocv.FindContours(thresh, gocv.RetrievalExternal, gocv.ChainApproxSimple)
|
|
thresh.Close()
|
|
|
|
var rects []image.Rectangle
|
|
for i := 0; i < contours.Size(); i++ {
|
|
rect := gocv.BoundingRect(contours.At(i))
|
|
if rect.Dy() > MIN_BOX_HEIGHT && rect.Dy() < 200 {
|
|
rects = append(rects, rect)
|
|
}
|
|
}
|
|
contours.Close()
|
|
|
|
sort.Slice(rects, func(i, j int) bool {
|
|
return rects[i].Dy() > rects[j].Dy()
|
|
})
|
|
|
|
if len(rects) < 3 {
|
|
return nil, fmt.Errorf("found only %d contours (need 3+)", len(rects))
|
|
}
|
|
|
|
// Take up to 6 candidates to ensure we capture split digits
|
|
maxCands := 6
|
|
if len(rects) < maxCands+1 {
|
|
maxCands = len(rects) - 1
|
|
}
|
|
cands := rects[1 : 1+maxCands]
|
|
|
|
// Sort by Y position for processing (Y desc, then X asc)
|
|
sort.Slice(cands, func(i, j int) bool {
|
|
if cands[i].Min.Y != cands[j].Min.Y {
|
|
return cands[i].Min.Y > cands[j].Min.Y
|
|
}
|
|
return cands[i].Min.X < cands[j].Min.X
|
|
})
|
|
|
|
logMessage(LogFile, Debug, " Selected %d candidates (excluding largest):", len(cands))
|
|
for idx, rect := range cands {
|
|
logMessage(LogFile, Debug, " [%d]: %dx%d @(%d,%d)", idx, rect.Dx(), rect.Dy(), rect.Min.X, rect.Min.Y)
|
|
}
|
|
|
|
// Group adjacent contours
|
|
displays := groupAdjacentContours(cands)
|
|
|
|
logMessage(LogFile, Debug, " Found %d displays after grouping", len(displays))
|
|
|
|
// Group displays by Y position to find the digit row
|
|
// Displays within 30 pixels vertically are considered same row
|
|
type YGroup struct {
|
|
avgY int
|
|
displays []Display
|
|
}
|
|
var yGroups []YGroup
|
|
|
|
for _, disp := range displays {
|
|
dispY := disp.FullRect.Min.Y
|
|
if !disp.IsSplit {
|
|
dispY = disp.LeftRect.Min.Y
|
|
}
|
|
|
|
// Find existing group with similar Y
|
|
found := false
|
|
for i := range yGroups {
|
|
if math.Abs(float64(dispY-yGroups[i].avgY)) < 30 {
|
|
yGroups[i].displays = append(yGroups[i].displays, disp)
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
yGroups = append(yGroups, YGroup{avgY: dispY, displays: []Display{disp}})
|
|
}
|
|
}
|
|
|
|
logMessage(LogFile, Debug, " Grouped into %d Y-bands:", len(yGroups))
|
|
for idx, group := range yGroups {
|
|
logMessage(LogFile, Debug, " Band %d (Y~%d): %d displays", idx, group.avgY, len(group.displays))
|
|
}
|
|
|
|
// Find the band with exactly 2 displays (SpO2 and HR)
|
|
var digitDisplays []Display
|
|
for _, group := range yGroups {
|
|
if len(group.displays) == 2 {
|
|
digitDisplays = group.displays
|
|
break
|
|
}
|
|
}
|
|
|
|
if len(digitDisplays) != 2 {
|
|
return nil, fmt.Errorf("could not find Y-band with exactly 2 displays (found %d bands)", len(yGroups))
|
|
}
|
|
|
|
// Sort by X position: left = SpO2, right = HR
|
|
sort.Slice(digitDisplays, func(i, j int) bool {
|
|
xI := digitDisplays[i].FullRect.Min.X
|
|
if !digitDisplays[i].IsSplit {
|
|
xI = digitDisplays[i].LeftRect.Min.X
|
|
}
|
|
xJ := digitDisplays[j].FullRect.Min.X
|
|
if !digitDisplays[j].IsSplit {
|
|
xJ = digitDisplays[j].LeftRect.Min.X
|
|
}
|
|
return xI < xJ
|
|
})
|
|
|
|
return digitDisplays, nil
|
|
}
|
|
|
|
func saveLayoutVisualization(rotated gocv.Mat, layout *ScreenLayout, filename string) {
|
|
visualization := rotated.Clone()
|
|
|
|
// Draw SpO2 box in red
|
|
red := color.RGBA{255, 0, 0, 255}
|
|
gocv.Rectangle(&visualization, layout.SpO2Area, red, 3)
|
|
gocv.PutText(&visualization, "SpO2", image.Pt(layout.SpO2Area.Min.X, layout.SpO2Area.Min.Y-10),
|
|
gocv.FontHersheyDuplex, 1.2, red, 2)
|
|
|
|
// Draw HR box in cyan
|
|
cyan := color.RGBA{0, 255, 255, 255}
|
|
gocv.Rectangle(&visualization, layout.HRArea, cyan, 3)
|
|
gocv.PutText(&visualization, "HR", image.Pt(layout.HRArea.Min.X, layout.HRArea.Min.Y-10),
|
|
gocv.FontHersheyDuplex, 1.2, cyan, 2)
|
|
|
|
gocv.IMWrite(filename, visualization)
|
|
visualization.Close()
|
|
}
|