552 lines
18 KiB
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()
|
|
}
|
|
}
|
|
}
|