pulse-monitor/backups/backup_20251127/pulseox-monitor.go

535 lines
17 KiB
Go

package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"time"
"gocv.io/x/gocv"
)
const VERSION = "v3.60"
// 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 (Unified Detection) ===", 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
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")
} 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
DEBUG = true // detection.go debug flag
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 {
loopStart := time.Now()
logMessage(Console, Debug, "[LOOP] Starting iteration")
// 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()
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 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
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()
// Preprocess frame: crop timestamp + rotate 90° (stays colored for now)
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()
// Threshold to binary (240 threshold)
thresholdStart := time.Now()
gray := gocv.NewMat()
gocv.CvtColor(preprocessed, &gray, gocv.ColorBGRToGray)
preprocessed.Close()
binary := gocv.NewMat()
gocv.Threshold(gray, &binary, 240, 255, gocv.ThresholdBinary)
gray.Close()
timing.Threshold = time.Since(thresholdStart).Milliseconds()
logMessage(Console, Debug, "[LOOP] Threshold in %dms", timing.Threshold)
// ========== DETECTION (if needed) ==========
if state.NeedsDetection() {
logMessage(Both, Info, "🔍 Running detection...")
result := DetectRotationAndWidth(binary)
if !result.Success {
// Detection failed
state.ConsecutiveFailures++
binary.Close()
rawThresholded.Close()
// Escalation strategy
if state.ConsecutiveFailures == 1 {
logMessage(Both, Warning, " Detection failed (1st try) - trying next frame...")
} else if state.ConsecutiveFailures == 2 {
logMessage(Both, Warning, " Detection failed (2nd try) - trying next frame...")
} else if state.ConsecutiveFailures == 3 {
logMessage(Both, Warning, " Detection failed (3rd try) - waiting 10s...")
interruptibleSleep(source, 10*time.Second)
} else if state.ConsecutiveFailures == 4 {
logMessage(Both, Warning, " Detection failed (4th try) - waiting 30s...")
interruptibleSleep(source, 30*time.Second)
} else {
logMessage(Both, Info, " ⏳ Pulse oximeter not detected (day mode) - waiting 60s...")
interruptibleSleep(source, 60*time.Second)
}
if !shouldContinue {
break
}
continue
}
// Detection succeeded - store results
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 ==========
// Rotate
var rotated gocv.Mat
if state.LockedRotation != 0.0 {
rotated = RotateImage(binary, state.LockedRotation)
binary.Close()
} else {
rotated = binary
}
// Scale
var scaled gocv.Mat
if state.LockedScale != 1.0 {
scaled = ScaleByFactor(rotated, state.LockedScale)
rotated.Close()
} else {
scaled = rotated
}
// 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:
state.ConsecutiveFailures++
if state.ConsecutiveFailures == 2 {
logMessage(Both, Warning, " Re-detecting layout...")
state.ResetDetection()
}
case StatusLowConfidence:
// Low confidence is a template matching issue, NOT a layout issue
// Counter already incremented in processor.go
// Don't re-detect - just continue
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())
}
}
func closeTemplates(templates map[int][]gocv.Mat) {
for _, templateList := range templates {
for _, t := range templateList {
t.Close()
}
}
}