package main import ( "fmt" "image" "os" "os/signal" "strings" "syscall" "time" "gocv.io/x/gocv" ) const VERSION = "v4.0-gemini" // Threshold for binary conversion - lower values capture fainter displays const BINARY_THRESHOLD = 180 // Global debug flag var DEBUG_MODE = false // Global timing flag var TIMING_MODE = false func main() { // Check for /help flag first for _, arg := range os.Args[1:] { if arg == "/help" || arg == "--help" || arg == "-h" { showHelp() return } } logMessage(Console, Info, "=== Pulse-Ox Monitor %s (Unified Detection) ===", VERSION) logMessage(Console, Info, "") // Note: gocv.SetNumThreads(1) removed - not available in gocv v0.31.0 // Small images (280x200px) don't benefit much from multi-threading anyway logMessage(Console, Info, "🔧 OpenCV initialized") logMessage(Console, Info, "") // Check for flags for _, arg := range os.Args[1:] { if arg == "/debug" { DEBUG_MODE = true DEBUG = true // Also enable detection.go debug logMessage(Console, Info, "🐛 DEBUG MODE ENABLED") } else if arg == "/timing" { TIMING_MODE = true logMessage(Console, Info, "⏱️ TIMING MODE ENABLED") } } // Check if running in single-frame test mode if len(os.Args) >= 2 { // Filter out flags framePath := "" for _, arg := range os.Args[1:] { if arg != "/debug" && arg != "/timing" { framePath = arg break } } if framePath != "" { runSingleFrameMode(framePath) return } } // Normal streaming mode runStreamingMode() } func showHelp() { logMessage(Console, Info, "=== Pulse-Ox Monitor %s ===", VERSION) logMessage(Console, Info, "") logMessage(Console, Info, "USAGE:") logMessage(Console, Info, " pulseox-monitor [options] [framefile]") logMessage(Console, Info, "") logMessage(Console, Info, "OPTIONS:") logMessage(Console, Info, " /help Show this help message") logMessage(Console, Info, " /debug Enable debug mode (extra diagnostic output)") logMessage(Console, Info, " /timing Show timing table for performance analysis") logMessage(Console, Info, "") logMessage(Console, Info, "MODES:") logMessage(Console, Info, " No arguments - Run in streaming mode (RTSP camera)") logMessage(Console, Info, " [framefile] - Test mode: process single PNG file") logMessage(Console, Info, "") logMessage(Console, Info, "EXAMPLES:") logMessage(Console, Info, " pulseox-monitor # Normal streaming") logMessage(Console, Info, " pulseox-monitor /debug # Streaming with debug output") logMessage(Console, Info, " pulseox-monitor /timing # Show performance timing") logMessage(Console, Info, " pulseox-monitor raw_frames/thresh_*.png # Test single frame") logMessage(Console, Info, "") logMessage(Console, Info, "OUTPUT:") logMessage(Console, Info, " review/ - Processed frame images and review.html") logMessage(Console, Info, " raw_frames/ - Failed recognition frames (for debugging)") logMessage(Console, Info, " test_output/ - Layout detection debug images") logMessage(Console, Info, " pulse-monitor_*.log - Detailed execution log") logMessage(Console, Info, "") } func runSingleFrameMode(framePath string) { logMessage(Console, Info, "=== Single Frame Test Mode ===") logMessage(Console, Info, "Loading frame: %s", framePath) logMessage(Console, Info, "") // Automatically enable DEBUG_MODE for file processing DEBUG_MODE = true DEBUG = true // detection.go debug flag logMessage(Console, Info, "🐛 DEBUG MODE AUTO-ENABLED (file processing)") logMessage(Console, Info, "") // In test mode, all output goes to console only (no separate file log) // Leave globalLogger as nil to avoid duplication when using Both target globalLogger = nil // Setup os.MkdirAll("test_output", 0755) os.MkdirAll("review", 0755) // Reset OCR cache ResetOCRCache() // Create frame source source := NewFileSource(framePath) defer source.Close() // Create processor processor := NewProcessor(nil, nil) state := NewProcessingState() // Process single frame with unified loop processFrames(source, processor, state) logMessage(Console, Info, "") logMessage(Console, Info, "✓ Single frame test complete") logMessage(Console, Info, "") logMessage(Console, Info, "=== OUTPUT FILES ===") logMessage(Console, Info, "Test outputs:") logMessage(Console, Info, " test_output/layout_boxes.jpg - Layout visualization") logMessage(Console, Info, " review/f*_spo2_full.png - SpO2 recognition") logMessage(Console, Info, " review/f*_hr_full.png - HR recognition") } func runStreamingMode() { // Create log file 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 { logMessage(Console, Warning, "Warning: Could not create log file: %v", err) logMessage(Console, Info, "Continuing without file logging...") logMessage(Console, Info, "") globalLogger = nil } else { defer logFile.Close() globalLogger = logFile logMessage(Both, Info, "📝 Logging to: %s", logFilename) } // Setup directories - clean output directories in streaming mode logMessage(Console, Info, "🗑️ Cleaning output directories...") logMessage(Console, Info, " - review/... ✓") os.RemoveAll("review") logMessage(Console, Info, " - raw_frames/... ✓") os.RemoveAll("raw_frames") logMessage(Console, Info, " - test_output/... ✓") os.RemoveAll("test_output") os.MkdirAll("review", 0755) os.MkdirAll("raw_frames", 0755) os.MkdirAll("test_output", 0755) logMessage(Console, Info, "") // Initialize live review HTML if err := initReviewHTML(); err != nil { logMessage(Console, Warning, "Warning: Could not initialize review HTML: %v", err) } else { logMessage(Console, Info, "✅ Live review HTML initialized: review/review.html (refresh browser to see updates)") } // Start review server startReviewServer() defer stopReviewServer() // Load config config, err := LoadConfig("config.yaml") if err != nil { logMessage(Console, Error, "Error loading config: %v", err) return } // Reset OCR cache at startup ResetOCRCache() // Initialize timestamp OCR client (reusable) InitTimestampOCR() defer CloseTimestampOCR() logMessage(Console, Info, "📊 All processed frames saved to review/") logMessage(Console, Info, " Press Ctrl+C to stop and generate review.html") logMessage(Console, Info, "") // Create RTSP source with reconnection handling logMessage(Console, Info, "Connecting to RTSP stream...") var source *RTSPSource for { source, err = NewRTSPSource(config.Camera.RTSPURL) if err == nil { break } logMessage(Console, Warning, "Failed to connect: %v", err) logMessage(Console, Info, "Retrying in 5 seconds...") time.Sleep(5 * time.Second) } defer source.Close() logMessage(Console, Info, "✓ Connected! Press Ctrl+C to stop") logMessage(Console, Info, "Posting to HASS: %s", config.HomeAssistant.URL) logMessage(Console, Info, "") // Send startup notification (but not if we already sent one recently) if !recentStartupNotification() { if err := sendNotification(config, "Pulse-Ox Monitor", "Monitoring started"); err != nil { logMessage(Both, Warning, " Failed to send startup notification: %v", err) } else { markStartupNotification() } } // Setup signal handler sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) go func() { <-sigChan logMessage(Console, Info, "") logMessage(Console, Info, "") logMessage(Console, Info, "🛑 Received stop signal, finishing up...") source.Close() // Safe to call multiple times now (sync.Once) }() // Create processor processor := NewProcessor(config, logFile) state := NewProcessingState() // Run unified processing loop processFrames(source, processor, state) // Show OCR stats calls, cacheHits, tokens := GetOCRStats() cost := EstimateCost() logMessage(Console, Info, "") logMessage(Console, Info, "📊 Gemini OCR Stats:") logMessage(Console, Info, " API calls: %d", calls) logMessage(Console, Info, " Cache hits: %d", cacheHits) logMessage(Console, Info, " Total tokens: %d", tokens) logMessage(Console, Info, " Estimated cost: $%.4f", cost) // Close review HTML logMessage(Console, Info, "") logMessage(Console, Info, "📝 Closing review.html...") if err := closeReviewHTML(); err != nil { logMessage(Console, Error, "Error closing review HTML: %v", err) } else { logMessage(Console, Info, "✓ Review page completed: review/review.html (%d frames)", len(state.ReviewEntries)) logMessage(Console, Info, " Open it in browser to review recognition results") } } // interruptibleSleep sleeps for the specified duration, but checks every second if source is closed func interruptibleSleep(source FrameSource, duration time.Duration) { remaining := duration for remaining > 0 { sleepTime := time.Second if remaining < sleepTime { sleepTime = remaining } time.Sleep(sleepTime) remaining -= sleepTime // Check if source was closed (Ctrl+C pressed) if !source.IsActive() { return } } } const startupNotificationFile = ".startup_notified" const detectionFailNotificationFile = ".detection_fail_notified" const missingTemplateNotificationFile = ".missing_template_notified" // recentStartupNotification checks if we sent a startup notification in the last hour func recentStartupNotification() bool { info, err := os.Stat(startupNotificationFile) if err != nil { return false // File doesn't exist } // Check if file is less than 1 hour old return time.Since(info.ModTime()) < 1*time.Hour } // markStartupNotification creates/updates the marker file func markStartupNotification() { os.WriteFile(startupNotificationFile, []byte(time.Now().Format(time.RFC3339)), 0644) } // recentDetectionFailNotification checks if we sent a detection failure notification in the last hour func recentDetectionFailNotification() bool { info, err := os.Stat(detectionFailNotificationFile) if err != nil { return false } return time.Since(info.ModTime()) < 1*time.Hour } // markDetectionFailNotification creates/updates the marker file func markDetectionFailNotification() { os.WriteFile(detectionFailNotificationFile, []byte(time.Now().Format(time.RFC3339)), 0644) } // loadNotifiedMissingTemplates loads previously notified templates from file func loadNotifiedMissingTemplates() map[int]bool { result := make(map[int]bool) data, err := os.ReadFile(missingTemplateNotificationFile) if err != nil { return result } for _, line := range strings.Split(string(data), "\n") { var num int if _, err := fmt.Sscanf(line, "%d", &num); err == nil { result[num] = true } } return result } // markMissingTemplateNotified adds a template to the notified list func markMissingTemplateNotified(template int) { f, err := os.OpenFile(missingTemplateNotificationFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return } defer f.Close() fmt.Fprintf(f, "%d\n", template) } // prepareBaseFrame converts raw camera frame to canonical base frame // This is the ONLY place where grayscale/threshold/crop/rotate happens // Returns: base (binary thresholded) func prepareBaseFrame(raw gocv.Mat) gocv.Mat { // 1. Grayscale gray := gocv.NewMat() gocv.CvtColor(raw, &gray, gocv.ColorBGRToGray) defer gray.Close() // 2. Threshold to binary thresholded := gocv.NewMat() gocv.Threshold(gray, &thresholded, BINARY_THRESHOLD, 255, gocv.ThresholdBinary) // 3. Crop timestamp area (top 80px) if thresholded.Rows() > 80 { cropped := thresholded.Region(image.Rect(0, 80, thresholded.Cols(), thresholded.Rows())) base := cropped.Clone() cropped.Close() thresholded.Close() thresholded = base } // 4. Rotate 90 degrees clockwise rotated := gocv.NewMat() gocv.Rotate(thresholded, &rotated, gocv.Rotate90Clockwise) thresholded.Close() return rotated } // tryDetectionWithRetries attempts detection, fetching fresh frames internally if needed // Returns the detection result only - does NOT return or modify frames // maxRetries is the number of additional frames to try after the first failure func tryDetectionWithRetries(source FrameSource, initialBinary gocv.Mat, maxRetries int) DetectionResult { // Try with the initial frame first result := DetectRotationAndWidth(initialBinary) if result.Success { return result } // Initial attempt failed - try a few more frames for retry := 1; retry <= maxRetries; retry++ { // Get a fresh frame frame, shouldContinue, err := source.Next() if err != nil || frame.Empty() || !shouldContinue { if !frame.Empty() { frame.Close() } return DetectionResult{Success: false} } // Prepare base frame (grayscale, threshold, crop, rotate) binary := prepareBaseFrame(frame) frame.Close() if binary.Empty() { // Timestamp extraction failed - skip this frame continue } // Try detection result = DetectRotationAndWidth(binary) binary.Close() // Detection works on its own copy, so we close this if result.Success { logMessage(Both, Info, " Detection succeeded on retry #%d", retry) return result } } // All retries failed return DetectionResult{Success: false} } // processFrames is the UNIFIED processing loop - works for both RTSP and single-frame func processFrames(source FrameSource, processor *Processor, state *ProcessingState) { logMessage(Console, Info, "Processing frames from: %s", source.Name()) logMessage(Console, Info, "") for { loopStart := time.Now() logMessage(Console, Debug, "[LOOP] Starting iteration") // Check if we're in a detection wait period if !state.DetectWaitUntil.IsZero() && time.Now().Before(state.DetectWaitUntil) { // Keep consuming frames to prevent buffer buildup frame, shouldContinue, _ := source.Next() if !frame.Empty() { frame.Close() } if !shouldContinue || !source.IsActive() { break } continue } // Clear wait if expired if !state.DetectWaitUntil.IsZero() { logMessage(Both, Info, "⏰ Detection wait complete, resuming...") state.DetectWaitUntil = time.Time{} // Reset to stage 4 so we get 3 fast retries before next wait // (stages 1-6 are fast, stage 7+ are waits) state.DetectFailStage = 4 } // Initialize timing data var timing TimingData acquireStart := time.Now() // Read raw frame from source var raw gocv.Mat var shouldContinue bool var err error if state.ConsecutiveFailures == 1 { // First failure: grab IMMEDIATE next frame (bypass skip) logMessage(Console, Debug, " Using NextImmediate() due to consecutive failure") raw, shouldContinue, err = source.NextImmediate() } else { // Normal operation or 2nd+ failure: use normal skip raw, shouldContinue, err = source.Next() } timing.Acquire = time.Since(acquireStart).Milliseconds() logMessage(Console, Debug, "[LOOP] Frame acquired in %dms", timing.Acquire) if err != nil { logMessage(Both, Error, "Error reading frame: %v", err) if !shouldContinue { break } continue } if raw.Empty() { if !shouldContinue { break } continue } // ========== PREPARE BASE FRAME ========== // This is the ONLY place grayscale/threshold/crop/rotate happens prepareStart := time.Now() base := prepareBaseFrame(raw) if base.Empty() { // Preprocessing failed - skip frame raw.Close() if !shouldContinue { break } continue } timing.Preprocess = time.Since(prepareStart).Milliseconds() // Check timestamp drift every 10 frames (BEFORE closing raw, on colored frame) state.ProcessedCount++ state.TimestampCheckCounter++ if state.TimestampCheckCounter >= 10 { state.TimestampCheckCounter = 0 diff, _, err := extractTimestamp(raw) if err != nil { logMessage(Both, Warning, " Timestamp OCR failed: %v", err) } else if diff > 3 { logMessage(Both, Warning, " Camera timestamp lag: %ds behind server", diff) } else if diff < -3 { logMessage(Both, Warning, " Camera timestamp ahead: %ds ahead of server", -diff) } // Silent if drift is within ±3 seconds } raw.Close() // Save base frame for debugging (threshold to reduce file size - already done) var rawThresholded gocv.Mat if state.ProcessedCount == 1 { gocv.IMWrite("test_output/pre_threshold_gray.png", base) logMessage(Both, Info, "💾 Saved base frame to test_output/pre_threshold_gray.png") } // Clone base for raw frame saving (used on errors) rawThresholded = base.Clone() // ========== DETECTION (if needed) ========== if state.NeedsDetection() { logMessage(Both, Info, "🔍 Running detection...") // Try detection on this frame result := DetectRotationAndWidth(base) if !result.Success { // Detection failed - increment failure counter state.DetectFailStage++ state.ConsecutiveFailures = 0 // Reset so we use normal frame interval, not NextImmediate base.Close() rawThresholded.Close() // Track when failures started if state.DetectionFailStart.IsZero() { state.DetectionFailStart = time.Now() } // Notify after 5 minutes of continuous detection failure (once per hour) if !state.NotifiedDetectionFailure && time.Since(state.DetectionFailStart) > 5*time.Minute { if !recentDetectionFailNotification() { state.NotifiedDetectionFailure = true if err := sendNotification(processor.config, "Pulse-Ox Monitor", "Detection failing for 5+ minutes - pulse-ox not visible?"); err != nil { logMessage(Both, Warning, " Failed to send notification: %v", err) } else { markDetectionFailNotification() } } } switch state.DetectFailStage { case 1, 2, 3, 4, 5, 6: // Stages 1-6: Try immediately (~3 seconds at 2fps) logMessage(Both, Warning, " Detection failed (#%d) - trying next frame...", state.DetectFailStage) case 7: // Stage 7: Wait 10s state.DetectWaitUntil = time.Now().Add(10 * time.Second) logMessage(Both, Warning, " Detection failed (#%d) - waiting 10s...", state.DetectFailStage) case 8: // Stage 8: Wait 30s state.DetectWaitUntil = time.Now().Add(30 * time.Second) logMessage(Both, Warning, " Detection failed (#%d) - waiting 30s...", state.DetectFailStage) default: // Stage 9+: Wait 60s state.DetectWaitUntil = time.Now().Add(60 * time.Second) logMessage(Both, Warning, " Detection failed (#%d) - waiting 60s...", state.DetectFailStage) } if !shouldContinue { break } continue } // Detection succeeded - reset stage and store results if state.DetectFailStage > 0 { logMessage(Both, Info, "✅ Detection recovered after %d attempts", state.DetectFailStage) } state.DetectFailStage = 0 state.DetectionFailStart = time.Time{} // Reset failure tracking state.NotifiedDetectionFailure = false // Allow future notifications state.LockedRotation = result.Rotation state.LockedScale = result.ScaleFactor state.Layout = &ScreenLayout{ SpO2Area: result.SpO2, HRArea: result.HR, } state.LayoutValid = true state.ConsecutiveFailures = 0 logMessage(Both, Info, "✓ Detection complete:") logMessage(Both, Info, " Rotation: %.3f°", result.Rotation) logMessage(Both, Info, " Scale: %.3f (width %dpx → 860px)", result.ScaleFactor, result.Width) logMessage(Both, Info, " SpO2: X[%d-%d] Y[%d-%d]", result.SpO2.Min.X, result.SpO2.Max.X, result.SpO2.Min.Y, result.SpO2.Max.Y) logMessage(Both, Info, " HR: X[%d-%d] Y[%d-%d]", result.HR.Min.X, result.HR.Max.X, result.HR.Min.Y, result.HR.Max.Y) } // ========== APPLY TRANSFORMS ========== // For LLM-OCR, we don't need scaling - just apply rotation if non-zero var scaled gocv.Mat if state.LockedRotation != 0 { // Only apply rotation, no scaling (scale factor = 1.0) scaled = applyTransforms(base, state.LockedRotation, 1.0) base.Close() } else { // No transforms needed - use base directly scaled = base } // Validate frame dimensions vs layout coordinates if state.Layout != nil { logMessage(Both, Debug, "Frame size: %dx%d", scaled.Cols(), scaled.Rows()) logMessage(Both, Debug, "SpO2 area: X[%d-%d] Y[%d-%d]", state.Layout.SpO2Area.Min.X, state.Layout.SpO2Area.Max.X, state.Layout.SpO2Area.Min.Y, state.Layout.SpO2Area.Max.Y) logMessage(Both, Debug, "HR area: X[%d-%d] Y[%d-%d]", state.Layout.HRArea.Min.X, state.Layout.HRArea.Max.X, state.Layout.HRArea.Min.Y, state.Layout.HRArea.Max.Y) // Check bounds if state.Layout.SpO2Area.Max.X > scaled.Cols() || state.Layout.SpO2Area.Max.Y > scaled.Rows() || state.Layout.HRArea.Max.X > scaled.Cols() || state.Layout.HRArea.Max.Y > scaled.Rows() { logMessage(Both, Error, "Layout coordinates exceed frame dimensions! Resetting detection.") state.ResetDetection() scaled.Close() rawThresholded.Close() continue } } // ========== PROCESS FRAME ========== logMessage(Console, Debug, "[LOOP] Starting OCR") timing.FrameNum = state.ProcessedCount result := processor.processFrame(scaled, rawThresholded, state.ProcessedCount, state, &timing) logMessage(Console, Debug, "[LOOP] OCR complete, status=%d", result.Status) scaled.Close() rawThresholded.Close() // Print timing table (header every 20 frames) state.TimingFrameCount++ showHeader := (state.TimingFrameCount % 20) == 1 printTimingTable(timing, showHeader) // Handle result switch result.Status { case StatusSuccess: state.ConsecutiveFailures = 0 state.LowConfidenceCount = 0 // Reset on success if result.ShouldPost { hassStart := time.Now() processor.postReading(&result.Reading, state) timing.HASS = time.Since(hassStart).Milliseconds() timing.Total += timing.HASS } case StatusCorrupted: // Just log it - don't re-detect. Boxes are locked. state.ConsecutiveFailures++ logMessage(LogFile, Warning, " Corrupted frame #%d", state.ConsecutiveFailures) case StatusLowConfidence: // Just logged in processor.go - we continue anyway, boxes are locked case StatusNoChange: // Normal - don't increment failure counter case StatusUnstable: // Held for validation - don't increment failure counter } if !shouldContinue { break } logMessage(Console, Debug, "[LOOP] Iteration complete in %dms", time.Since(loopStart).Milliseconds()) } }