package main import ( "fmt" "image" "image/color" "os" "os/signal" "syscall" "time" "gocv.io/x/gocv" ) const VERSION = "v3.57" // Global debug flag var DEBUG_MODE = false // Global timing flag var TIMING_MODE = false // Global save crops flag var SAVE_CROPS = false // Display and digit measurement constants const ( CUT_WIDTH = 280 DIGIT_ONE_WIDTH = 72 DIGIT_NON_ONE_WIDTH = 100 MIN_BOX_HEIGHT = 110 ) 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 (Refactored Architecture) ===", VERSION) logMessage(Console, Info, "") // Limit OpenCV thread pool to reduce CPU overhead // Small images (280x200px) don't benefit from multi-threading gocv.SetNumThreads(1) logMessage(Console, Info, "🔧 OpenCV threads limited to 1 (single-threaded for minimal overhead)") logMessage(Console, Info, "") // Check for flags for _, arg := range os.Args[1:] { if arg == "/debug" { DEBUG_MODE = true logMessage(Console, Info, "🐛 DEBUG MODE ENABLED") } else if arg == "/timing" { TIMING_MODE = true logMessage(Console, Info, "⏱️ TIMING MODE ENABLED") } else if arg == "/crops" { SAVE_CROPS = true logMessage(Console, Info, "✂️ SAVE CROPS ENABLED (individual digit images)") } } // 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" && arg != "/crops" { 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, " /crops Save individual digit crop images (for approving templates)") 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 /crops # Streaming with digit crops") logMessage(Console, Info, " pulseox-monitor /timing # Show performance timing") logMessage(Console, Info, " pulseox-monitor raw_frames/thresh_*.png # Test single frame") logMessage(Console, Info, " pulseox-monitor /debug /crops # Multiple flags") 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 and SAVE_CROPS for file processing DEBUG_MODE = true SAVE_CROPS = true logMessage(Console, Info, "🐛 DEBUG MODE AUTO-ENABLED (file processing)") logMessage(Console, Info, "✂️ SAVE CROPS 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) // Load templates logMessage(Console, Info, "Loading templates...") templates, err := loadTemplates() if err != nil { logMessage(Console, Error, "❌ Error loading templates: %v", err) return } defer closeTemplates(templates) logMessage(Console, Info, "✓ Templates loaded") logMessage(Console, Info, "") // Create frame source source := NewFileSource(framePath) defer source.Close() // Create processor processor := NewProcessor(templates, 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)") } // Load config config, err := LoadConfig("config.yaml") if err != nil { logMessage(Console, Error, "Error loading config: %v", err) return } // Load templates templates, err := loadTemplates() if err != nil { logMessage(Console, Error, "Error loading templates: %v", err) return } defer closeTemplates(templates) // 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, "") // 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(templates, config, logFile) state := NewProcessingState() // Run unified processing loop processFrames(source, processor, state) // 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 } } } // 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 { // Initialize timing data var timing TimingData acquireStart := time.Now() // Read raw frame from source var frame 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") frame, shouldContinue, err = source.NextImmediate() } else { // Normal operation or 2nd+ failure: use normal skip frame, shouldContinue, err = source.Next() } timing.Acquire = time.Since(acquireStart).Milliseconds() if err != nil { logMessage(Both, Error, "Error reading frame: %v", err) if !shouldContinue { break } continue } if frame.Empty() { if !shouldContinue { break } continue } // Check timestamp every 10 processed frames (BEFORE preprocessing, on colored frame) state.ProcessedCount++ state.TimestampCheckCounter++ if state.TimestampCheckCounter >= 10 { state.TimestampCheckCounter = 0 diff, _, err := extractTimestamp(frame) 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 } // Save truly raw frame (before any preprocessing) - threshold to reduce file size // Clone the colored frame, convert to grayscale, threshold at 240 // This becomes the "raw" frame that will be saved on errors rawFrameClone := frame.Clone() rawGray := gocv.NewMat() gocv.CvtColor(rawFrameClone, &rawGray, gocv.ColorBGRToGray) rawThresholded := gocv.NewMat() gocv.Threshold(rawGray, &rawThresholded, 240, 255, gocv.ThresholdBinary) rawGray.Close() rawFrameClone.Close() // rawThresholded is now the true raw frame (portrait, with timestamp, thresholded) // Will be passed to processFrame() for potential error saving // Preprocess COLORED frame (rotate, crop timestamp) preprocessStart := time.Now() logMessage(Console, Debug, " Before preprocess: %dx%d", frame.Cols(), frame.Rows()) preprocessed := preprocessFrame(frame) logMessage(Console, Debug, " After preprocess: %dx%d", preprocessed.Cols(), preprocessed.Rows()) frame.Close() timing.Preprocess = time.Since(preprocessStart).Milliseconds() // Calculate rotation angle on first frame (if not set) if state.LockedRotation == 0.0 && !state.LayoutValid { angle := calculateRotationAngle(preprocessed) state.LockedRotation = angle if angle != 0.0 { logMessage(Both, Info, "🔄 Rotation angle: %.2f° (will rotate all frames)", angle) } else { logMessage(Both, Info, "📐 No rotation needed (angle < 0.5°)") } } // Apply rotation if needed (on colored preprocessed frame, BEFORE thresholding) var rotated gocv.Mat if state.LockedRotation != 0.0 { rotateStart := time.Now() rotated = rotateImage(preprocessed, state.LockedRotation) preprocessed.Close() logMessage(Both, Info, " ✓ Applied rotation: %.3f° to frame", state.LockedRotation) logMessage(LogFile, Debug, " [TIMING] Rotation: %dms", time.Since(rotateStart).Milliseconds()) // DEBUG: Save rotated frame with same visualization as BEFORE if DEBUG_MODE && state.ProcessedCount <= 2 { // Save plain rotated frame gocv.IMWrite(fmt.Sprintf("test_output/rotated_frame_%d.png", state.ProcessedCount), rotated) // Create visualization matching BEFORE image afterVis := rotated.Clone() // Note: We can't draw the exact same boxes since they were calculated on pre-rotated frame // But we can add a reference line to show the frame is rotated gocv.PutText(&afterVis, fmt.Sprintf("AFTER rotation: %.2f degrees", state.LockedRotation), image.Pt(50, 50), gocv.FontHersheyDuplex, 1.5, color.RGBA{0, 255, 0, 255}, 3) gocv.IMWrite("test_output/rotation_boxes_AFTER.png", afterVis) afterVis.Close() logMessage(Both, Info, " 💾 Saved rotated frame and AFTER visualization") } } else { rotated = preprocessed logMessage(Both, Info, " 📐 No rotation applied (angle=0)") } // THRESHOLD AFTER PREPROCESSING AND ROTATION - SINGLE POINT OF TRUTH // Convert rotated colored frame to grayscale then threshold at 240 thresholdStart := time.Now() gray := gocv.NewMat() gocv.CvtColor(rotated, &gray, gocv.ColorBGRToGray) thresholded := gocv.NewMat() gocv.Threshold(gray, &thresholded, 240, 255, gocv.ThresholdBinary) gray.Close() rotated.Close() timing.Threshold = time.Since(thresholdStart).Milliseconds() // Try to acquire layout if we don't have it if !state.LayoutValid { // Call layout detection directly layout, scale, _ := detectScreenLayoutAreas(thresholded) if layout != nil { // Layout acquired successfully state.Layout = layout state.LockedScale = scale state.LayoutValid = true state.ConsecutiveFailures = 0 logMessage(LogFile, Info, " ✓ Layout detected, scale factor: %.3f", scale) logMessage(LogFile, Info, " SpO2 area: X=%d, Y=%d, Width=%d, Height=%d", layout.SpO2Area.Min.X, layout.SpO2Area.Min.Y, layout.SpO2Area.Dx(), layout.SpO2Area.Dy()) logMessage(LogFile, Info, " HR area: X=%d, Y=%d, Width=%d, Height=%d", layout.HRArea.Min.X, layout.HRArea.Min.Y, layout.HRArea.Dx(), layout.HRArea.Dy()) logMessage(Console, Info, "✓ Layout detected, scale: %.3f", scale) } else { // Layout acquisition failed - increment counter state.ConsecutiveFailures++ thresholded.Close() rawThresholded.Close() // Escalation strategy if state.ConsecutiveFailures == 1 { // 1st failure: Skip this frame, try next immediately logMessage(Both, Warning, " Layout detection failed (1st try) - trying next frame...") } else if state.ConsecutiveFailures == 2 { // 2nd failure: Try next frame immediately (different conditions?) logMessage(Both, Warning, " Layout detection failed (2nd try) - trying next frame...") } else if state.ConsecutiveFailures == 3 { // 3rd failure: Wait 10s logMessage(Both, Warning, " Layout detection failed (3rd try) - waiting 10s...") interruptibleSleep(source, 10*time.Second) } else if state.ConsecutiveFailures == 4 { // 4th failure: Wait 30s logMessage(Both, Warning, " Layout detection failed (4th try) - waiting 30s...") interruptibleSleep(source, 30*time.Second) } else { // 5th+ failure: Wait 60s (device likely off/removed) logMessage(Both, Info, " ⏳ Pulse oximeter not detected (day mode) - waiting 60s...") interruptibleSleep(source, 60*time.Second) } if !shouldContinue { break } continue } } // Process frame with current layout timing.FrameNum = state.ProcessedCount result := processor.processFrame(thresholded, rawThresholded, state.ProcessedCount, state, &timing) thresholded.Close() rawThresholded.Close() // Print timing table (header every 20 frames) state.TimingFrameCount++ showHeader := (state.TimingFrameCount % 20) == 1 printTimingTable(timing, showHeader) // DEBUG: Show result status if state.ConsecutiveFailures > 0 { logMessage(Console, Debug, " Frame result: %v", result.Status) } // Handle result switch result.Status { case StatusSuccess: // Reset failure counter on successful processing state.ConsecutiveFailures = 0 if result.ShouldPost { hassStart := time.Now() processor.postReading(&result.Reading, state) timing.HASS = time.Since(hassStart).Milliseconds() timing.Total += timing.HASS // Add HASS time to total } case StatusCorrupted: // Corruption is a recognition issue, not layout issue // Count as a failure for escalation state.ConsecutiveFailures++ if state.ConsecutiveFailures == 2 { // 2nd consecutive problem: invalidate layout for re-detection logMessage(Both, Warning, " Re-detecting layout...") state.LayoutValid = false } case StatusLowConfidence: // Low confidence is also a problem that needs escalation state.ConsecutiveFailures++ if state.ConsecutiveFailures == 1 { logMessage(Both, Warning, " Low confidence - grabbing next frame immediately...") // Next iteration will automatically use NextImmediate() due to counter = 1 } else if state.ConsecutiveFailures == 2 { // 2nd consecutive problem: invalidate layout for re-detection logMessage(Both, Warning, " 2nd consecutive low confidence - re-detecting layout...") state.LayoutValid = false } case StatusNoChange: // Values didn't change - this is normal, not a failure // Don't increment failure counter case StatusUnstable: // Reading held for validation - this is normal, not a failure // Don't increment failure counter } // Check if we should continue if !shouldContinue { break } } } func closeTemplates(templates map[int][]gocv.Mat) { for _, templateList := range templates { for _, t := range templateList { t.Close() } } }