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), } }