pulse-monitor/pulseox-monitor.go

692 lines
22 KiB
Go

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