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

552 lines
18 KiB
Go

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