package main import ( "fmt" "image" "io" "log" "os" "os/signal" "syscall" "time" "gocv.io/x/gocv" ) const VERSION = "v2.35" // Display and digit measurement constants const ( CUT_WIDTH = 280 // Width of cropped digit area DIGIT_ONE_WIDTH = 72 // Width of narrow '1' digit DIGIT_NON_ONE_WIDTH = 100 // Width of regular digits (0,2-9) MIN_BOX_HEIGHT = 110 // Minimum height for valid digit contours ) func main() { // Check if running in single-frame test mode if len(os.Args) >= 2 { framePath := os.Args[1] runSingleFrameMode(framePath) return } // Normal streaming mode runStreamingMode() } func runSingleFrameMode(framePath string) { fmt.Printf("=== Single Frame Test Mode ===\n") fmt.Printf("Loading frame: %s\n\n", framePath) // Load the frame rotated := gocv.IMRead(framePath, gocv.IMReadColor) if rotated.Empty() { fmt.Printf("❌ Failed to load frame: %s\n", framePath) return } defer rotated.Close() fmt.Printf("✓ Frame loaded: %dx%d\n", rotated.Cols(), rotated.Rows()) // Detect and normalize screen width screenWidth := detectScreenWidth(rotated, "test_output/screen_width_detection.jpg") if screenWidth == 0 { fmt.Println("❌ Failed to detect screen width") return } fmt.Printf("✓ Detected screen width: %dpx\n", screenWidth) targetWidth := 860 normalized := normalizeToWidth(rotated, targetWidth) if normalized.Ptr() != rotated.Ptr() { defer normalized.Close() fmt.Printf("✓ Normalized to %dpx\n", targetWidth) } frameToUse := normalized fmt.Println() // Load templates fmt.Println("Loading templates...") templates, err := loadTemplates() if err != nil { fmt.Printf("❌ Error loading templates: %v\n", err) return } defer func() { for _, templateList := range templates { for _, t := range templateList { t.Close() } } }() fmt.Println("✓ Templates loaded\n") // Create output directory os.MkdirAll("test_output", 0755) // Try layout detection fmt.Println("Attempting layout detection...") screenLayout, rescaled, err := detectScreenLayoutAreas(frameToUse) if err != nil { fmt.Printf("❌ Layout detection failed: %v\n", err) return } if !rescaled.Empty() { rescaled.Close() } fmt.Println("✓ Layout detected successfully") fmt.Printf(" SpO2 area: X=%d-%d, Y=%d-%d, Size=%dx%d\n", screenLayout.SpO2Area.Min.X, screenLayout.SpO2Area.Max.X, screenLayout.SpO2Area.Min.Y, screenLayout.SpO2Area.Max.Y, screenLayout.SpO2Area.Dx(), screenLayout.SpO2Area.Dy()) fmt.Printf(" HR area: X=%d-%d, Y=%d-%d, Size=%dx%d\n\n", screenLayout.HRArea.Min.X, screenLayout.HRArea.Max.X, screenLayout.HRArea.Min.Y, screenLayout.HRArea.Max.Y, screenLayout.HRArea.Dx(), screenLayout.HRArea.Dy()) // Save layout visualization saveLayoutVisualization(frameToUse, screenLayout, "test_output/layout_boxes.jpg") fmt.Println("✓ Saved: test_output/layout_boxes.jpg") // Run OCR on both displays fmt.Println("\nRunning OCR...") spo2Val, spo2Left, spo2LeftConf, spo2Right, spo2RightConf := recognizeDisplayArea(frameToUse, screenLayout.SpO2Area, templates, "SpO2", 0, nil) hrVal, hrLeft, hrLeftConf, hrRight, hrRightConf := recognizeDisplayArea(frameToUse, screenLayout.HRArea, templates, "HR", 0, nil) spo2AvgConf := (spo2LeftConf + spo2RightConf) / 2.0 hrAvgConf := (hrLeftConf + hrRightConf) / 2.0 fmt.Printf("\n=== RESULTS ===\n") fmt.Printf("SpO2: %d%% (left=%d @%.1f%%, right=%d @%.1f%%, avg=%.1f%%)\n", spo2Val, spo2Left, spo2LeftConf, spo2Right, spo2RightConf, spo2AvgConf) fmt.Printf("HR: %d bpm (left=%d @%.1f%%, right=%d @%.1f%%, avg=%.1f%%)\n", hrVal, hrLeft, hrLeftConf, hrRight, hrRightConf, hrAvgConf) if spo2Left == -1 || spo2Right == -1 || hrLeft == -1 || hrRight == -1 { fmt.Println("\n⚠️ Invalid/corrupted digit detected") } if spo2AvgConf < 85 || hrAvgConf < 85 { fmt.Println("\n⚠️ Low confidence reading") } // Check for output files fmt.Println("\n=== OUTPUT FILES ===") fmt.Println("Debug visualizations (in current directory):") fmt.Println(" debug_01_original.png - Original frame") fmt.Println(" debug_02_gray.png - Grayscale") fmt.Println(" debug_03_threshold.png - Thresholded") fmt.Println(" debug_04_all_boxes.png - All detected boxes") fmt.Println(" debug_05_bounding_and_line.png - Bounding box & 50% line") fmt.Println(" debug_06_center_boxes.png - Center boxes") fmt.Println("\nTest outputs (in test_output/):") fmt.Println(" test_output/layout_boxes.jpg - Layout visualization") // Check if review files were created if _, err := os.Stat("review/f0_spo2_full.png"); err == nil { fmt.Println(" review/f0_spo2_full.png - SpO2 recognition") fmt.Println(" review/f0_hr_full.png - HR recognition") fmt.Println(" review/f0_spo2_digit*.png - Individual SpO2 digits") fmt.Println(" review/f0_hr_digit*.png - Individual HR digits") } fmt.Println("\n✓ Single frame test complete") } func runStreamingMode() { // Create log file with timestamp logFilename := fmt.Sprintf("pulse-monitor_%s.log", time.Now().Format("20060102_150405")) logFile, err := os.OpenFile(logFilename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { fmt.Printf("Warning: Could not create log file: %v\n", err) fmt.Printf("Continuing without file logging...\n\n") } else { defer logFile.Close() // Create multi-writer to write to both stdout and file multiWriter := io.MultiWriter(os.Stdout, logFile) log.SetOutput(multiWriter) log.SetFlags(0) // Disable timestamp prefix since we use our own log.Printf("📝 Logging to: %s\n", logFilename) } log.Printf("=== Pulse-Ox Template Matching OCR %s ===\n\n", VERSION) os.RemoveAll("review") os.MkdirAll("review", 0755) os.MkdirAll("raw_frames", 0755) os.MkdirAll("test_output", 0755) log.Println("🗑️ Cleaned review/ directory") config, err := LoadConfig("config.yaml") if err != nil { log.Printf("Error loading config: %v\n", err) return } templates, err := loadTemplates() if err != nil { log.Printf("Error loading templates: %v\n", err) return } defer func() { for _, templateList := range templates { for _, t := range templateList { t.Close() } } }() log.Println("📊 All processed frames saved to review/") log.Println(" Press Ctrl+C to stop and generate review.html\n") log.Println("Connecting to RTSP stream...") var stream *gocv.VideoCapture for { stream, err = gocv.VideoCaptureFile(config.Camera.RTSPURL) if err == nil { break } log.Printf("Failed to connect: %v\n", err) log.Println("Retrying in 5 seconds...") time.Sleep(5 * time.Second) } defer stream.Close() log.Println("Connected! Press Ctrl+C to stop") log.Printf("Posting to HASS: %s\n\n", config.HomeAssistant.URL) // Detect screen width and layout once at startup log.Println("Detecting screen width and layout...") var screenLayout *ScreenLayout var lockedScale float64 = 1.0 // Scale from last successful layout detection for screenLayout == nil { frame := gocv.NewMat() if ok := stream.Read(&frame); !ok { log.Println("Failed to read frame for layout detection, retrying...") time.Sleep(1 * time.Second) frame.Close() continue } if frame.Empty() || frame.Cols() < 640 || frame.Rows() < 480 { frame.Close() continue } noTs := frame.Region(image.Rect(0, 68, frame.Cols(), frame.Rows())) rotated := gocv.NewMat() gocv.Rotate(noTs, &rotated, gocv.Rotate90Clockwise) noTs.Close() // Detect layout with normalization var err error screenLayout, lockedScale, err = detectLayoutWithNormalization(rotated) rotated.Close() frame.Close() if err != nil { log.Printf(" Pulse oximeter not detected (day mode) - waiting 60 seconds...\n") screenLayout = nil time.Sleep(60 * time.Second) continue } log.Printf(" ✓ Layout detected, scale locked: %.3f\n", lockedScale) } log.Println("✓ Screen width and layout locked") if logFile != nil { fmt.Fprintf(logFile, " SpO2 area: X=%d, Y=%d, Width=%d, Height=%d\n", screenLayout.SpO2Area.Min.X, screenLayout.SpO2Area.Min.Y, screenLayout.SpO2Area.Dx(), screenLayout.SpO2Area.Dy()) fmt.Fprintf(logFile, " HR area: X=%d, Y=%d, Width=%d, Height=%d\n", screenLayout.HRArea.Min.X, screenLayout.HRArea.Min.Y, screenLayout.HRArea.Dx(), screenLayout.HRArea.Dy()) } log.Println() var reviewEntries []ReviewEntry sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) done := make(chan bool) go func() { <-sigChan log.Println("\n\n🛑 Received stop signal, finishing up...") done <- true }() frameCount := 0 processedCount := 0 successCount := 0 failureCount := 0 // Track last values to only save/post when changed lastSpO2 := -1 lastHR := -1 // Track last reading for stability check (need 2 consecutive within 3 points) lastReadingSpO2 := -1 lastReadingHR := -1 // Track pending reading (held for hindsight validation) type PendingReading struct { spO2 int hr int timestamp string valid bool } pendingReading := PendingReading{valid: false} // Track baseline before spike (for comparing against new readings) baselineSpO2 := -1 baselineHR := -1 // Track retry state for layout re-detection (separate from pending logic) inLayoutRetry := false mainLoop: for { select { case <-done: break mainLoop default: } frame := gocv.NewMat() if ok := stream.Read(&frame); !ok { log.Printf("Failed to read frame, reconnecting...\n") time.Sleep(5 * time.Second) stream.Close() for { stream, err = gocv.VideoCaptureFile(config.Camera.RTSPURL) if err == nil { log.Println("Reconnected!") break } time.Sleep(10 * time.Second) } continue } frameCount++ if frame.Empty() || frame.Cols() < 640 || frame.Rows() < 480 { frame.Close() continue } // Log frame dimensions (first time and periodically) if logFile != nil && (frameCount == 1 || frameCount%100 == 0) { fmt.Fprintf(logFile, "[INFO] Frame #%d dimensions: %dx%d\n", frameCount, frame.Cols(), frame.Rows()) } // Process only every 4th frame (~4 fps at 15fps stream) if frameCount%4 != 0 { frame.Close() continue } timestamp := time.Now().Format("15:04:05") frameStart := time.Now() // Log to file only if logFile != nil { fmt.Fprintf(logFile, "[%s] Frame #%d\n", timestamp, frameCount) } // Preprocessing: crop timestamp, rotate, apply scale prepStart := time.Now() noTs := frame.Region(image.Rect(0, 68, frame.Cols(), frame.Rows())) rotated := gocv.NewMat() gocv.Rotate(noTs, &rotated, gocv.Rotate90Clockwise) noTs.Close() // Apply locked scale normalized := applyScale(rotated, lockedScale) if normalized.Ptr() != rotated.Ptr() { rotated.Close() } frameToUse := normalized prepTime := time.Since(prepStart) // Log to file only if logFile != nil { fmt.Fprintf(logFile, " Recognizing displays...\n") } spo2Start := time.Now() spo2Val, spo2Left, spo2LeftConf, spo2Right, spo2RightConf := recognizeDisplayArea(frameToUse, screenLayout.SpO2Area, templates, "SpO2", frameCount, logFile) spo2Time := time.Since(spo2Start) hrStart := time.Now() hrVal, hrLeft, hrLeftConf, hrRight, hrRightConf := recognizeDisplayArea(frameToUse, screenLayout.HRArea, templates, "HR", frameCount, logFile) hrTime := time.Since(hrStart) spo2AvgConf := (spo2LeftConf + spo2RightConf) / 2.0 hrAvgConf := (hrLeftConf + hrRightConf) / 2.0 // Check for invalid digits (corrupted reading) if spo2Left == -1 || spo2Right == -1 || hrLeft == -1 || hrRight == -1 { if logFile != nil { fmt.Fprintf(logFile, " Invalid digit detected: SpO2(%d,%d) HR(%d,%d), reading next frame\n", spo2Left, spo2Right, hrLeft, hrRight) } // Skip to next frame and let normal processing handle it frameToUse.Close() frame.Close() continue } // Check if values changed valuesChanged := spo2Val != lastSpO2 || hrVal != lastHR // Only process further if values changed if !valuesChanged { frameToUse.Close() frame.Close() continue } // Values changed - NOW save debug images and timing // Save raw processed frame for replay/testing rawFilename := fmt.Sprintf("raw_frames/raw_%s-%05d.png", time.Now().Format("20060102"), frameCount) gocv.IMWrite(rawFilename, frameToUse) // Draw visualization of stored layout saveLayoutVisualization(frameToUse, screenLayout, fmt.Sprintf("review/f%d_boxes.jpg", frameCount)) totalTime := time.Since(frameStart) // Log detailed timing to file only if logFile != nil { fmt.Fprintf(logFile, " Timing: Prep=%dms, SpO2=%dms, HR=%dms, Total=%dms\n", prepTime.Milliseconds(), spo2Time.Milliseconds(), hrTime.Milliseconds(), totalTime.Milliseconds()) } // Values changed - update tracking processedCount++ lastSpO2 = spo2Val lastHR = hrVal // Print to console (simple) fmt.Printf("[%s] SpO2=%d%%, HR=%d bpm\n", timestamp, spo2Val, hrVal) // Add to review entries entry := ReviewEntry{ FrameNum: frameCount, // Use actual frame number for file references Timestamp: timestamp, SpO2Value: spo2Val, SpO2LeftDigit: spo2Left, SpO2LeftConf: spo2LeftConf, SpO2RightDigit: spo2Right, SpO2RightConf: spo2RightConf, HRValue: hrVal, HRLeftDigit: hrLeft, HRLeftConf: hrLeftConf, HRRightDigit: hrRight, HRRightConf: hrRightConf, Unstable: false, UnstableReason: "", } reviewEntries = append(reviewEntries, entry) if spo2AvgConf > 85 && hrAvgConf > 85 { // High confidence - check for stability with hindsight validation // Check if values changed too much from last reading (>3 points) if lastReadingSpO2 != -1 && lastReadingHR != -1 { spo2Diff := spo2Val - lastReadingSpO2 if spo2Diff < 0 { spo2Diff = -spo2Diff } hrDiff := hrVal - lastReadingHR if hrDiff < 0 { hrDiff = -hrDiff } if spo2Diff > 3 || hrDiff > 3 { // Values changed too much - check if we have a pending reading if pendingReading.valid { // We have a pending reading - check direction spo2Direction := spo2Val - pendingReading.spO2 hrDirection := hrVal - pendingReading.hr // Check if both readings moved in same direction from baseline pendingSpo2Diff := pendingReading.spO2 - lastReadingSpO2 pendingHrDiff := pendingReading.hr - lastReadingHR // Same direction if signs match (both positive or both negative) spo2SameDir := (spo2Direction > 0 && pendingSpo2Diff > 0) || (spo2Direction < 0 && pendingSpo2Diff < 0) hrSameDir := (hrDirection > 0 && pendingHrDiff > 0) || (hrDirection < 0 && pendingHrDiff < 0) if spo2SameDir && hrSameDir { // Both readings trending same direction - real trend, post pending! fmt.Printf(" ✓ Hindsight validation: trend confirmed, posting held reading\n") if logFile != nil { fmt.Fprintf(logFile, " Hindsight validation: trend confirmed (SpO2: %d->%d->%d, HR: %d->%d->%d)\n", lastReadingSpO2, pendingReading.spO2, spo2Val, lastReadingHR, pendingReading.hr, hrVal) } // Post the pending reading postStart := time.Now() spo2Err := postToHomeAssistant(config, "sensor.pulse_ox_spo2", pendingReading.spO2, "%", "SpO2") hrErr := postToHomeAssistant(config, "sensor.pulse_ox_hr", pendingReading.hr, "bpm", "Heart Rate") postTime := time.Since(postStart) if spo2Err == nil && hrErr == nil { successCount++ if logFile != nil { fmt.Fprintf(logFile, " Posted pending reading successfully in %dms (success: %d, fail: %d)\n", postTime.Milliseconds(), successCount, failureCount) } } else { failureCount++ if logFile != nil { fmt.Fprintf(logFile, " Failed to post pending reading\n") } } // Current reading becomes new pending pendingReading = PendingReading{ spO2: spo2Val, hr: hrVal, timestamp: timestamp, valid: true, } lastReadingSpO2 = spo2Val lastReadingHR = hrVal inLayoutRetry = false frameToUse.Close() frame.Close() continue } else { // Opposite directions - pending was a glitch, discard it fmt.Printf(" ⚠️ Direction mismatch: discarding held reading (glitch)\n") if logFile != nil { fmt.Fprintf(logFile, " Direction mismatch: discarding held reading (SpO2: %d->%d->%d, HR: %d->%d->%d)\n", lastReadingSpO2, pendingReading.spO2, spo2Val, lastReadingHR, pendingReading.hr, hrVal) } pendingReading.valid = false lastReadingSpO2 = spo2Val lastReadingHR = hrVal inLayoutRetry = false frameToUse.Close() frame.Close() continue } } else { // No pending reading yet - hold this one if !inLayoutRetry { inLayoutRetry = true // Mark this entry as unstable entry.Unstable = true entry.UnstableReason = fmt.Sprintf("Unstable (SpO2 Δ%d, HR Δ%d)", spo2Diff, hrDiff) fmt.Printf(" ⚠️ Unstable reading (SpO2 Δ%d, HR Δ%d), holding for validation...\n", spo2Diff, hrDiff) if logFile != nil { fmt.Fprintf(logFile, " Unstable reading - SpO2 delta: %d, HR delta: %d, holding for validation...\n", spo2Diff, hrDiff) } // Store baseline (before the spike) for comparison baselineSpO2 = lastReadingSpO2 baselineHR = lastReadingHR // Store as pending pendingReading = PendingReading{ spO2: spo2Val, hr: hrVal, timestamp: timestamp, valid: true, } lastReadingSpO2 = spo2Val lastReadingHR = hrVal rotated.Close() // Read next frame immediately retryFrame := gocv.NewMat() if ok := stream.Read(&retryFrame); !ok { fmt.Printf(" Failed to read retry frame\n") retryFrame.Close() inLayoutRetry = false pendingReading.valid = false continue } frameCount++ // Re-detect layout for { fmt.Printf(" Re-detecting layout...\n") if logFile != nil { fmt.Fprintf(logFile, " Re-detecting layout...\n") } noTsRetry := retryFrame.Region(image.Rect(0, 68, retryFrame.Cols(), retryFrame.Rows())) rotatedRetry := gocv.NewMat() gocv.Rotate(noTsRetry, &rotatedRetry, gocv.Rotate90Clockwise) noTsRetry.Close() newLayout, newScale, err := detectLayoutWithNormalization(rotatedRetry) rotatedRetry.Close() if err != nil { fmt.Printf(" Pulse oximeter not detected (day mode) - waiting 60 seconds...\n") if logFile != nil { fmt.Fprintf(logFile, " Pulse oximeter not detected (day mode) - waiting 60 seconds...\n") } // Reset retry state inLayoutRetry = false pendingReading.valid = false retryFrame.Close() time.Sleep(60 * time.Second) // Read next frame and try again retryFrame.Close() retryFrame = gocv.NewMat() if ok := stream.Read(&retryFrame); !ok { fmt.Printf(" Failed to read frame\n") retryFrame.Close() time.Sleep(5 * time.Second) continue } continue } screenLayout = newLayout lockedScale = newScale fmt.Printf(" ✓ Layout re-detected\n") if logFile != nil { fmt.Fprintf(logFile, " Layout re-detected successfully\n") } break } retryFrame.Close() continue } else { // Already in retry and still unstable - give up inLayoutRetry = false pendingReading.valid = false lastReadingSpO2 = spo2Val lastReadingHR = hrVal // Mark this entry as unstable (second try) entry.Unstable = true entry.UnstableReason = fmt.Sprintf("Still unstable after retry (SpO2 Δ%d, HR Δ%d)", spo2Diff, hrDiff) fmt.Printf(" ⚠️ Still unstable after retry, skipping\n") if logFile != nil { fmt.Fprintf(logFile, " Still unstable after retry - SpO2 delta: %d, HR delta: %d\n\n", spo2Diff, hrDiff) } frameToUse.Close() frame.Close() continue } } } else { // Stable reading (Δ≤3) if pendingReading.valid { // Check if new reading is closer to baseline or to pending value spo2ToBaseline := spo2Val - baselineSpO2 if spo2ToBaseline < 0 { spo2ToBaseline = -spo2ToBaseline } hrToBaseline := hrVal - baselineHR if hrToBaseline < 0 { hrToBaseline = -hrToBaseline } spo2ToPending := spo2Val - pendingReading.spO2 if spo2ToPending < 0 { spo2ToPending = -spo2ToPending } hrToPending := hrVal - pendingReading.hr if hrToPending < 0 { hrToPending = -hrToPending } // If closer to baseline than to pending, pending was a glitch if spo2ToBaseline <= spo2ToPending && hrToBaseline <= hrToPending { fmt.Printf(" ✓ New reading closer to baseline (%d,%d) than pending (%d,%d), discarding glitch\n", baselineSpO2, baselineHR, pendingReading.spO2, pendingReading.hr) if logFile != nil { fmt.Fprintf(logFile, " Discarding pending glitch: baseline %d->pending %d->current %d (SpO2), baseline %d->pending %d->current %d (HR)\n", baselineSpO2, pendingReading.spO2, spo2Val, baselineHR, pendingReading.hr, hrVal) } pendingReading.valid = false // Fall through to post current reading } else { // Closer to pending - real trend, post pending first fmt.Printf(" ✓ Stable reading confirms held value, posting both\n") if logFile != nil { fmt.Fprintf(logFile, " Stable reading confirms held value: %d->%d->%d (SpO2), %d->%d->%d (HR)\n", baselineSpO2, pendingReading.spO2, spo2Val, baselineHR, pendingReading.hr, hrVal) } postStart := time.Now() spo2Err := postToHomeAssistant(config, "sensor.pulse_ox_spo2", pendingReading.spO2, "%", "SpO2") hrErr := postToHomeAssistant(config, "sensor.pulse_ox_hr", pendingReading.hr, "bpm", "Heart Rate") postTime := time.Since(postStart) if spo2Err == nil && hrErr == nil { successCount++ if logFile != nil { fmt.Fprintf(logFile, " Posted pending reading successfully in %dms (success: %d, fail: %d)\n", postTime.Milliseconds(), successCount, failureCount) } } pendingReading.valid = false } } // Fall through to post current reading } } // Values are stable (within 3 points) - post to Home Assistant inLayoutRetry = false // Reset retry flag on success lastReadingSpO2 = spo2Val lastReadingHR = hrVal postStart := time.Now() spo2Err := postToHomeAssistant(config, "sensor.pulse_ox_spo2", spo2Val, "%", "SpO2") hrErr := postToHomeAssistant(config, "sensor.pulse_ox_hr", hrVal, "bpm", "Heart Rate") postTime := time.Since(postStart) if spo2Err == nil && hrErr == nil { successCount++ if logFile != nil { fmt.Fprintf(logFile, " Posted successfully in %dms (success: %d, fail: %d)\n\n", postTime.Milliseconds(), successCount, failureCount) } } else { failureCount++ fmt.Printf(" ❌ Post failed\n") if logFile != nil { if spo2Err != nil { fmt.Fprintf(logFile, " SpO2 post error: %v\n", spo2Err) } if hrErr != nil { fmt.Fprintf(logFile, " HR post error: %v\n", hrErr) } fmt.Fprintf(logFile, " (success: %d, fail: %d)\n\n", successCount, failureCount) } } frameToUse.Close() frame.Close() } else { failureCount++ // Update last reading even on low confidence (for next comparison) lastReadingSpO2 = spo2Val lastReadingHR = hrVal if !inLayoutRetry { // First failure - retry with next frame and re-detect layout inLayoutRetry = true fmt.Printf(" ⚠️ Low confidence, retrying with next frame...\n") if logFile != nil { fmt.Fprintf(logFile, " Low confidence - SpO2: %.1f%%, HR: %.1f%%, retrying...\n", spo2AvgConf, hrAvgConf) } rotated.Close() // Read next frame immediately retryFrame := gocv.NewMat() if ok := stream.Read(&retryFrame); !ok { fmt.Printf(" Failed to read retry frame\n") retryFrame.Close() inLayoutRetry = false continue } frameCount++ timestamp = time.Now().Format("15:04:05") // Re-detect layout for { fmt.Printf(" Re-detecting layout...\n") if logFile != nil { fmt.Fprintf(logFile, " Re-detecting layout...\n") } noTsRetry := retryFrame.Region(image.Rect(0, 68, retryFrame.Cols(), retryFrame.Rows())) rotated = gocv.NewMat() gocv.Rotate(noTsRetry, &rotated, gocv.Rotate90Clockwise) noTsRetry.Close() newLayout, newScale, err := detectLayoutWithNormalization(rotated) if err != nil { fmt.Printf(" Pulse oximeter not detected (day mode) - waiting 60 seconds...\n") if logFile != nil { fmt.Fprintf(logFile, " Pulse oximeter not detected (day mode) - waiting 60 seconds...\n") } // Reset retry state inLayoutRetry = false rotated.Close() retryFrame.Close() time.Sleep(60 * time.Second) // Read next frame and try again retryFrame.Close() retryFrame = gocv.NewMat() if ok := stream.Read(&retryFrame); !ok { fmt.Printf(" Failed to read frame\n") retryFrame.Close() time.Sleep(5 * time.Second) continue } continue } screenLayout = newLayout lockedScale = newScale fmt.Printf(" ✓ Layout re-detected, processing retry frame\n") if logFile != nil { fmt.Fprintf(logFile, " Layout re-detected successfully, processing frame #%d\n", frameCount) fmt.Fprintf(logFile, " SpO2 area: X=%d, Y=%d, Width=%d, Height=%d\n", screenLayout.SpO2Area.Min.X, screenLayout.SpO2Area.Min.Y, screenLayout.SpO2Area.Dx(), screenLayout.SpO2Area.Dy()) fmt.Fprintf(logFile, " HR area: X=%d, Y=%d, Width=%d, Height=%d\n", screenLayout.HRArea.Min.X, screenLayout.HRArea.Min.Y, screenLayout.HRArea.Dx(), screenLayout.HRArea.Dy()) } break } // Apply the new scale to the retry frame normalized := applyScale(rotated, lockedScale) if normalized.Ptr() != rotated.Ptr() { rotated.Close() rotated = normalized } // Now process this retry frame (fall through to loop which will read frame 248 next) saveLayoutVisualization(rotated, screenLayout, fmt.Sprintf("review/f%d_boxes.jpg", frameCount)) if logFile != nil { fmt.Fprintf(logFile, " Recognizing displays...\n") } spo2Val, spo2Left, spo2LeftConf, spo2Right, spo2RightConf = recognizeDisplayArea(rotated, screenLayout.SpO2Area, templates, "SpO2", frameCount, logFile) hrVal, hrLeft, hrLeftConf, hrRight, hrRightConf = recognizeDisplayArea(rotated, screenLayout.HRArea, templates, "HR", frameCount, logFile) spo2AvgConf = (spo2LeftConf + spo2RightConf) / 2.0 hrAvgConf = (hrLeftConf + hrRightConf) / 2.0 // Check if retry succeeded with high confidence if spo2AvgConf > 85 && hrAvgConf > 85 { // Retry succeeded - check if values changed valuesChanged = spo2Val != lastSpO2 || hrVal != lastHR if !valuesChanged { rotated.Close() retryFrame.Close() frame.Close() inLayoutRetry = false continue } // Values changed - update processedCount++ lastSpO2 = spo2Val lastHR = hrVal fmt.Printf("[%s] SpO2=%d%%, HR=%d bpm\n", timestamp, spo2Val, hrVal) entry = ReviewEntry{ FrameNum: frameCount, Timestamp: timestamp, SpO2Value: spo2Val, SpO2LeftDigit: spo2Left, SpO2LeftConf: spo2LeftConf, SpO2RightDigit: spo2Right, SpO2RightConf: spo2RightConf, HRValue: hrVal, HRLeftDigit: hrLeft, HRLeftConf: hrLeftConf, HRRightDigit: hrRight, HRRightConf: hrRightConf, Unstable: false, UnstableReason: "", } reviewEntries = append(reviewEntries, entry) // Now perform stability checks (same as high confidence path above) if lastReadingSpO2 != -1 && lastReadingHR != -1 { spo2Diff := spo2Val - lastReadingSpO2 if spo2Diff < 0 { spo2Diff = -spo2Diff } hrDiff := hrVal - lastReadingHR if hrDiff < 0 { hrDiff = -hrDiff } if spo2Diff <= 3 && hrDiff <= 3 { // Stable - post it inLayoutRetry = false lastReadingSpO2 = spo2Val lastReadingHR = hrVal postStart := time.Now() spo2Err := postToHomeAssistant(config, "sensor.pulse_ox_spo2", spo2Val, "%", "SpO2") hrErr := postToHomeAssistant(config, "sensor.pulse_ox_hr", hrVal, "bpm", "Heart Rate") postTime := time.Since(postStart) if spo2Err == nil && hrErr == nil { successCount++ if logFile != nil { fmt.Fprintf(logFile, " Posted successfully in %dms (success: %d, fail: %d)\n\n", postTime.Milliseconds(), successCount, failureCount) } } else { failureCount++ } rotated.Close() retryFrame.Close() frame.Close() continue } } // First reading or stable - post it inLayoutRetry = false lastReadingSpO2 = spo2Val lastReadingHR = hrVal postStart := time.Now() spo2Err := postToHomeAssistant(config, "sensor.pulse_ox_spo2", spo2Val, "%", "SpO2") hrErr := postToHomeAssistant(config, "sensor.pulse_ox_hr", hrVal, "bpm", "Heart Rate") postTime := time.Since(postStart) if spo2Err == nil && hrErr == nil { successCount++ if logFile != nil { fmt.Fprintf(logFile, " Posted successfully in %dms (success: %d, fail: %d)\n\n", postTime.Milliseconds(), successCount, failureCount) } } else { failureCount++ } rotated.Close() retryFrame.Close() frame.Close() continue } // If still low confidence, fall through to "second failure" below } else { // Second failure - give up and wait inLayoutRetry = false fmt.Printf(" ⚠️ Low confidence after retry, pausing 2 seconds\n") if logFile != nil { fmt.Fprintf(logFile, " Low confidence after retry - SpO2: %.1f%%, HR: %.1f%% (success: %d, fail: %d)\n\n", spo2AvgConf, hrAvgConf, successCount, failureCount) } time.Sleep(2 * time.Second) } } frameToUse.Close() frame.Close() } fmt.Println("\n📝 Writing review.html...") if err := writeReviewHTML(reviewEntries); err != nil { fmt.Printf("Error writing review HTML: %v\n", err) } else { fmt.Printf("✓ Review page created: review/review.html (%d frames)\n", len(reviewEntries)) fmt.Println(" Open it in browser to review recognition results") fmt.Println(" Copy good digits: cp review/f5_spo2_digit2.png training_digits/9_2.png") } }