package main import ( "fmt" "image" "image/color" "math" "sort" "gocv.io/x/gocv" ) // Global DEBUG flag is declared in test_rotation.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 binary := gocv.NewMat() gocv.Threshold(gray, &binary, 170, 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) // Convert coordinates from digit-region space to full-image space // (add digitBand.minY offset to Y coordinates) spo2Rect := image.Rect( displayAreas.SpO2.Min.X, displayAreas.SpO2.Min.Y + digitBand.minY, displayAreas.SpO2.Max.X, displayAreas.SpO2.Max.Y + digitBand.minY, ) hrRect := image.Rect( displayAreas.HR.Min.X, displayAreas.HR.Min.Y + digitBand.minY, displayAreas.HR.Max.X, 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) }