pulse-monitor/backups/backup_20251030_043342/pulse-monitor.go

931 lines
31 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"fmt"
"image"
"io"
"log"
"os"
"os/signal"
"syscall"
"time"
"gocv.io/x/gocv"
)
const VERSION = "v2.35"
// Display and digit measurement constants
const (
CUT_WIDTH = 280 // Width of cropped digit area
DIGIT_ONE_WIDTH = 72 // Width of narrow '1' digit
DIGIT_NON_ONE_WIDTH = 100 // Width of regular digits (0,2-9)
MIN_BOX_HEIGHT = 110 // Minimum height for valid digit contours
)
func main() {
// Check if running in single-frame test mode
if len(os.Args) >= 2 {
framePath := os.Args[1]
runSingleFrameMode(framePath)
return
}
// Normal streaming mode
runStreamingMode()
}
func runSingleFrameMode(framePath string) {
fmt.Printf("=== Single Frame Test Mode ===\n")
fmt.Printf("Loading frame: %s\n\n", framePath)
// Load the frame
rotated := gocv.IMRead(framePath, gocv.IMReadColor)
if rotated.Empty() {
fmt.Printf("❌ Failed to load frame: %s\n", framePath)
return
}
defer rotated.Close()
fmt.Printf("✓ Frame loaded: %dx%d\n", rotated.Cols(), rotated.Rows())
// Detect and normalize screen width
screenWidth := detectScreenWidth(rotated, "test_output/screen_width_detection.jpg")
if screenWidth == 0 {
fmt.Println("❌ Failed to detect screen width")
return
}
fmt.Printf("✓ Detected screen width: %dpx\n", screenWidth)
targetWidth := 860
normalized := normalizeToWidth(rotated, targetWidth)
if normalized.Ptr() != rotated.Ptr() {
defer normalized.Close()
fmt.Printf("✓ Normalized to %dpx\n", targetWidth)
}
frameToUse := normalized
fmt.Println()
// Load templates
fmt.Println("Loading templates...")
templates, err := loadTemplates()
if err != nil {
fmt.Printf("❌ Error loading templates: %v\n", err)
return
}
defer func() {
for _, templateList := range templates {
for _, t := range templateList {
t.Close()
}
}
}()
fmt.Println("✓ Templates loaded\n")
// Create output directory
os.MkdirAll("test_output", 0755)
// Try layout detection
fmt.Println("Attempting layout detection...")
screenLayout, rescaled, err := detectScreenLayoutAreas(frameToUse)
if err != nil {
fmt.Printf("❌ Layout detection failed: %v\n", err)
return
}
if !rescaled.Empty() {
rescaled.Close()
}
fmt.Println("✓ Layout detected successfully")
fmt.Printf(" SpO2 area: X=%d-%d, Y=%d-%d, Size=%dx%d\n",
screenLayout.SpO2Area.Min.X, screenLayout.SpO2Area.Max.X,
screenLayout.SpO2Area.Min.Y, screenLayout.SpO2Area.Max.Y,
screenLayout.SpO2Area.Dx(), screenLayout.SpO2Area.Dy())
fmt.Printf(" HR area: X=%d-%d, Y=%d-%d, Size=%dx%d\n\n",
screenLayout.HRArea.Min.X, screenLayout.HRArea.Max.X,
screenLayout.HRArea.Min.Y, screenLayout.HRArea.Max.Y,
screenLayout.HRArea.Dx(), screenLayout.HRArea.Dy())
// Save layout visualization
saveLayoutVisualization(frameToUse, screenLayout, "test_output/layout_boxes.jpg")
fmt.Println("✓ Saved: test_output/layout_boxes.jpg")
// Run OCR on both displays
fmt.Println("\nRunning OCR...")
spo2Val, spo2Left, spo2LeftConf, spo2Right, spo2RightConf := recognizeDisplayArea(frameToUse, screenLayout.SpO2Area, templates, "SpO2", 0, nil)
hrVal, hrLeft, hrLeftConf, hrRight, hrRightConf := recognizeDisplayArea(frameToUse, screenLayout.HRArea, templates, "HR", 0, nil)
spo2AvgConf := (spo2LeftConf + spo2RightConf) / 2.0
hrAvgConf := (hrLeftConf + hrRightConf) / 2.0
fmt.Printf("\n=== RESULTS ===\n")
fmt.Printf("SpO2: %d%% (left=%d @%.1f%%, right=%d @%.1f%%, avg=%.1f%%)\n",
spo2Val, spo2Left, spo2LeftConf, spo2Right, spo2RightConf, spo2AvgConf)
fmt.Printf("HR: %d bpm (left=%d @%.1f%%, right=%d @%.1f%%, avg=%.1f%%)\n",
hrVal, hrLeft, hrLeftConf, hrRight, hrRightConf, hrAvgConf)
if spo2Left == -1 || spo2Right == -1 || hrLeft == -1 || hrRight == -1 {
fmt.Println("\n⚠ Invalid/corrupted digit detected")
}
if spo2AvgConf < 85 || hrAvgConf < 85 {
fmt.Println("\n⚠ Low confidence reading")
}
// Check for output files
fmt.Println("\n=== OUTPUT FILES ===")
fmt.Println("Debug visualizations (in current directory):")
fmt.Println(" debug_01_original.png - Original frame")
fmt.Println(" debug_02_gray.png - Grayscale")
fmt.Println(" debug_03_threshold.png - Thresholded")
fmt.Println(" debug_04_all_boxes.png - All detected boxes")
fmt.Println(" debug_05_bounding_and_line.png - Bounding box & 50% line")
fmt.Println(" debug_06_center_boxes.png - Center boxes")
fmt.Println("\nTest outputs (in test_output/):")
fmt.Println(" test_output/layout_boxes.jpg - Layout visualization")
// Check if review files were created
if _, err := os.Stat("review/f0_spo2_full.png"); err == nil {
fmt.Println(" review/f0_spo2_full.png - SpO2 recognition")
fmt.Println(" review/f0_hr_full.png - HR recognition")
fmt.Println(" review/f0_spo2_digit*.png - Individual SpO2 digits")
fmt.Println(" review/f0_hr_digit*.png - Individual HR digits")
}
fmt.Println("\n✓ Single frame test complete")
}
func runStreamingMode() {
// Create log file with timestamp
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 {
fmt.Printf("Warning: Could not create log file: %v\n", err)
fmt.Printf("Continuing without file logging...\n\n")
} else {
defer logFile.Close()
// Create multi-writer to write to both stdout and file
multiWriter := io.MultiWriter(os.Stdout, logFile)
log.SetOutput(multiWriter)
log.SetFlags(0) // Disable timestamp prefix since we use our own
log.Printf("📝 Logging to: %s\n", logFilename)
}
log.Printf("=== Pulse-Ox Template Matching OCR %s ===\n\n", VERSION)
os.RemoveAll("review")
os.MkdirAll("review", 0755)
os.MkdirAll("raw_frames", 0755)
os.MkdirAll("test_output", 0755)
log.Println("🗑️ Cleaned review/ directory")
config, err := LoadConfig("config.yaml")
if err != nil {
log.Printf("Error loading config: %v\n", err)
return
}
templates, err := loadTemplates()
if err != nil {
log.Printf("Error loading templates: %v\n", err)
return
}
defer func() {
for _, templateList := range templates {
for _, t := range templateList {
t.Close()
}
}
}()
log.Println("📊 All processed frames saved to review/")
log.Println(" Press Ctrl+C to stop and generate review.html\n")
log.Println("Connecting to RTSP stream...")
var stream *gocv.VideoCapture
for {
stream, err = gocv.VideoCaptureFile(config.Camera.RTSPURL)
if err == nil {
break
}
log.Printf("Failed to connect: %v\n", err)
log.Println("Retrying in 5 seconds...")
time.Sleep(5 * time.Second)
}
defer stream.Close()
log.Println("Connected! Press Ctrl+C to stop")
log.Printf("Posting to HASS: %s\n\n", config.HomeAssistant.URL)
// Detect screen width and layout once at startup
log.Println("Detecting screen width and layout...")
var screenLayout *ScreenLayout
var lockedScale float64 = 1.0 // Scale from last successful layout detection
for screenLayout == nil {
frame := gocv.NewMat()
if ok := stream.Read(&frame); !ok {
log.Println("Failed to read frame for layout detection, retrying...")
time.Sleep(1 * time.Second)
frame.Close()
continue
}
if frame.Empty() || frame.Cols() < 640 || frame.Rows() < 480 {
frame.Close()
continue
}
noTs := frame.Region(image.Rect(0, 68, frame.Cols(), frame.Rows()))
rotated := gocv.NewMat()
gocv.Rotate(noTs, &rotated, gocv.Rotate90Clockwise)
noTs.Close()
// Detect layout with normalization
var err error
screenLayout, lockedScale, err = detectLayoutWithNormalization(rotated)
rotated.Close()
frame.Close()
if err != nil {
log.Printf(" Pulse oximeter not detected (day mode) - waiting 60 seconds...\n")
screenLayout = nil
time.Sleep(60 * time.Second)
continue
}
log.Printf(" ✓ Layout detected, scale locked: %.3f\n", lockedScale)
}
log.Println("✓ Screen width and layout locked")
if logFile != nil {
fmt.Fprintf(logFile, " SpO2 area: X=%d, Y=%d, Width=%d, Height=%d\n",
screenLayout.SpO2Area.Min.X, screenLayout.SpO2Area.Min.Y,
screenLayout.SpO2Area.Dx(), screenLayout.SpO2Area.Dy())
fmt.Fprintf(logFile, " HR area: X=%d, Y=%d, Width=%d, Height=%d\n",
screenLayout.HRArea.Min.X, screenLayout.HRArea.Min.Y,
screenLayout.HRArea.Dx(), screenLayout.HRArea.Dy())
}
log.Println()
var reviewEntries []ReviewEntry
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
done := make(chan bool)
go func() {
<-sigChan
log.Println("\n\n🛑 Received stop signal, finishing up...")
done <- true
}()
frameCount := 0
processedCount := 0
successCount := 0
failureCount := 0
// Track last values to only save/post when changed
lastSpO2 := -1
lastHR := -1
// Track last reading for stability check (need 2 consecutive within 3 points)
lastReadingSpO2 := -1
lastReadingHR := -1
// Track pending reading (held for hindsight validation)
type PendingReading struct {
spO2 int
hr int
timestamp string
valid bool
}
pendingReading := PendingReading{valid: false}
// Track baseline before spike (for comparing against new readings)
baselineSpO2 := -1
baselineHR := -1
// Track retry state for layout re-detection (separate from pending logic)
inLayoutRetry := false
mainLoop:
for {
select {
case <-done:
break mainLoop
default:
}
frame := gocv.NewMat()
if ok := stream.Read(&frame); !ok {
log.Printf("Failed to read frame, reconnecting...\n")
time.Sleep(5 * time.Second)
stream.Close()
for {
stream, err = gocv.VideoCaptureFile(config.Camera.RTSPURL)
if err == nil {
log.Println("Reconnected!")
break
}
time.Sleep(10 * time.Second)
}
continue
}
frameCount++
if frame.Empty() || frame.Cols() < 640 || frame.Rows() < 480 {
frame.Close()
continue
}
// Log frame dimensions (first time and periodically)
if logFile != nil && (frameCount == 1 || frameCount%100 == 0) {
fmt.Fprintf(logFile, "[INFO] Frame #%d dimensions: %dx%d\n", frameCount, frame.Cols(), frame.Rows())
}
// Process only every 4th frame (~4 fps at 15fps stream)
if frameCount%4 != 0 {
frame.Close()
continue
}
timestamp := time.Now().Format("15:04:05")
frameStart := time.Now()
// Log to file only
if logFile != nil {
fmt.Fprintf(logFile, "[%s] Frame #%d\n", timestamp, frameCount)
}
// Preprocessing: crop timestamp, rotate, apply scale
prepStart := time.Now()
noTs := frame.Region(image.Rect(0, 68, frame.Cols(), frame.Rows()))
rotated := gocv.NewMat()
gocv.Rotate(noTs, &rotated, gocv.Rotate90Clockwise)
noTs.Close()
// Apply locked scale
normalized := applyScale(rotated, lockedScale)
if normalized.Ptr() != rotated.Ptr() {
rotated.Close()
}
frameToUse := normalized
prepTime := time.Since(prepStart)
// Log to file only
if logFile != nil {
fmt.Fprintf(logFile, " Recognizing displays...\n")
}
spo2Start := time.Now()
spo2Val, spo2Left, spo2LeftConf, spo2Right, spo2RightConf := recognizeDisplayArea(frameToUse, screenLayout.SpO2Area, templates, "SpO2", frameCount, logFile)
spo2Time := time.Since(spo2Start)
hrStart := time.Now()
hrVal, hrLeft, hrLeftConf, hrRight, hrRightConf := recognizeDisplayArea(frameToUse, screenLayout.HRArea, templates, "HR", frameCount, logFile)
hrTime := time.Since(hrStart)
spo2AvgConf := (spo2LeftConf + spo2RightConf) / 2.0
hrAvgConf := (hrLeftConf + hrRightConf) / 2.0
// Check for invalid digits (corrupted reading)
if spo2Left == -1 || spo2Right == -1 || hrLeft == -1 || hrRight == -1 {
if logFile != nil {
fmt.Fprintf(logFile, " Invalid digit detected: SpO2(%d,%d) HR(%d,%d), reading next frame\n", spo2Left, spo2Right, hrLeft, hrRight)
}
// Skip to next frame and let normal processing handle it
frameToUse.Close()
frame.Close()
continue
}
// Check if values changed
valuesChanged := spo2Val != lastSpO2 || hrVal != lastHR
// Only process further if values changed
if !valuesChanged {
frameToUse.Close()
frame.Close()
continue
}
// Values changed - NOW save debug images and timing
// Save raw processed frame for replay/testing
rawFilename := fmt.Sprintf("raw_frames/raw_%s-%05d.png", time.Now().Format("20060102"), frameCount)
gocv.IMWrite(rawFilename, frameToUse)
// Draw visualization of stored layout
saveLayoutVisualization(frameToUse, screenLayout, fmt.Sprintf("review/f%d_boxes.jpg", frameCount))
totalTime := time.Since(frameStart)
// Log detailed timing to file only
if logFile != nil {
fmt.Fprintf(logFile, " Timing: Prep=%dms, SpO2=%dms, HR=%dms, Total=%dms\n",
prepTime.Milliseconds(), spo2Time.Milliseconds(), hrTime.Milliseconds(), totalTime.Milliseconds())
}
// Values changed - update tracking
processedCount++
lastSpO2 = spo2Val
lastHR = hrVal
// Print to console (simple)
fmt.Printf("[%s] SpO2=%d%%, HR=%d bpm\n", timestamp, spo2Val, hrVal)
// Add to review entries
entry := ReviewEntry{
FrameNum: frameCount, // Use actual frame number for file references
Timestamp: timestamp,
SpO2Value: spo2Val,
SpO2LeftDigit: spo2Left,
SpO2LeftConf: spo2LeftConf,
SpO2RightDigit: spo2Right,
SpO2RightConf: spo2RightConf,
HRValue: hrVal,
HRLeftDigit: hrLeft,
HRLeftConf: hrLeftConf,
HRRightDigit: hrRight,
HRRightConf: hrRightConf,
Unstable: false,
UnstableReason: "",
}
reviewEntries = append(reviewEntries, entry)
if spo2AvgConf > 85 && hrAvgConf > 85 {
// High confidence - check for stability with hindsight validation
// Check if values changed too much from last reading (>3 points)
if lastReadingSpO2 != -1 && lastReadingHR != -1 {
spo2Diff := spo2Val - lastReadingSpO2
if spo2Diff < 0 {
spo2Diff = -spo2Diff
}
hrDiff := hrVal - lastReadingHR
if hrDiff < 0 {
hrDiff = -hrDiff
}
if spo2Diff > 3 || hrDiff > 3 {
// Values changed too much - check if we have a pending reading
if pendingReading.valid {
// We have a pending reading - check direction
spo2Direction := spo2Val - pendingReading.spO2
hrDirection := hrVal - pendingReading.hr
// Check if both readings moved in same direction from baseline
pendingSpo2Diff := pendingReading.spO2 - lastReadingSpO2
pendingHrDiff := pendingReading.hr - lastReadingHR
// Same direction if signs match (both positive or both negative)
spo2SameDir := (spo2Direction > 0 && pendingSpo2Diff > 0) || (spo2Direction < 0 && pendingSpo2Diff < 0)
hrSameDir := (hrDirection > 0 && pendingHrDiff > 0) || (hrDirection < 0 && pendingHrDiff < 0)
if spo2SameDir && hrSameDir {
// Both readings trending same direction - real trend, post pending!
fmt.Printf(" ✓ Hindsight validation: trend confirmed, posting held reading\n")
if logFile != nil {
fmt.Fprintf(logFile, " Hindsight validation: trend confirmed (SpO2: %d->%d->%d, HR: %d->%d->%d)\n",
lastReadingSpO2, pendingReading.spO2, spo2Val,
lastReadingHR, pendingReading.hr, hrVal)
}
// Post the pending reading
postStart := time.Now()
spo2Err := postToHomeAssistant(config, "sensor.pulse_ox_spo2", pendingReading.spO2, "%", "SpO2")
hrErr := postToHomeAssistant(config, "sensor.pulse_ox_hr", pendingReading.hr, "bpm", "Heart Rate")
postTime := time.Since(postStart)
if spo2Err == nil && hrErr == nil {
successCount++
if logFile != nil {
fmt.Fprintf(logFile, " Posted pending reading successfully in %dms (success: %d, fail: %d)\n", postTime.Milliseconds(), successCount, failureCount)
}
} else {
failureCount++
if logFile != nil {
fmt.Fprintf(logFile, " Failed to post pending reading\n")
}
}
// Current reading becomes new pending
pendingReading = PendingReading{
spO2: spo2Val,
hr: hrVal,
timestamp: timestamp,
valid: true,
}
lastReadingSpO2 = spo2Val
lastReadingHR = hrVal
inLayoutRetry = false
frameToUse.Close()
frame.Close()
continue
} else {
// Opposite directions - pending was a glitch, discard it
fmt.Printf(" ⚠️ Direction mismatch: discarding held reading (glitch)\n")
if logFile != nil {
fmt.Fprintf(logFile, " Direction mismatch: discarding held reading (SpO2: %d->%d->%d, HR: %d->%d->%d)\n",
lastReadingSpO2, pendingReading.spO2, spo2Val,
lastReadingHR, pendingReading.hr, hrVal)
}
pendingReading.valid = false
lastReadingSpO2 = spo2Val
lastReadingHR = hrVal
inLayoutRetry = false
frameToUse.Close()
frame.Close()
continue
}
} else {
// No pending reading yet - hold this one
if !inLayoutRetry {
inLayoutRetry = true
// Mark this entry as unstable
entry.Unstable = true
entry.UnstableReason = fmt.Sprintf("Unstable (SpO2 Δ%d, HR Δ%d)", spo2Diff, hrDiff)
fmt.Printf(" ⚠️ Unstable reading (SpO2 Δ%d, HR Δ%d), holding for validation...\n", spo2Diff, hrDiff)
if logFile != nil {
fmt.Fprintf(logFile, " Unstable reading - SpO2 delta: %d, HR delta: %d, holding for validation...\n", spo2Diff, hrDiff)
}
// Store baseline (before the spike) for comparison
baselineSpO2 = lastReadingSpO2
baselineHR = lastReadingHR
// Store as pending
pendingReading = PendingReading{
spO2: spo2Val,
hr: hrVal,
timestamp: timestamp,
valid: true,
}
lastReadingSpO2 = spo2Val
lastReadingHR = hrVal
rotated.Close()
// Read next frame immediately
retryFrame := gocv.NewMat()
if ok := stream.Read(&retryFrame); !ok {
fmt.Printf(" Failed to read retry frame\n")
retryFrame.Close()
inLayoutRetry = false
pendingReading.valid = false
continue
}
frameCount++
// Re-detect layout
for {
fmt.Printf(" Re-detecting layout...\n")
if logFile != nil {
fmt.Fprintf(logFile, " Re-detecting layout...\n")
}
noTsRetry := retryFrame.Region(image.Rect(0, 68, retryFrame.Cols(), retryFrame.Rows()))
rotatedRetry := gocv.NewMat()
gocv.Rotate(noTsRetry, &rotatedRetry, gocv.Rotate90Clockwise)
noTsRetry.Close()
newLayout, newScale, err := detectLayoutWithNormalization(rotatedRetry)
rotatedRetry.Close()
if err != nil {
fmt.Printf(" Pulse oximeter not detected (day mode) - waiting 60 seconds...\n")
if logFile != nil {
fmt.Fprintf(logFile, " Pulse oximeter not detected (day mode) - waiting 60 seconds...\n")
}
// Reset retry state
inLayoutRetry = false
pendingReading.valid = false
retryFrame.Close()
time.Sleep(60 * time.Second)
// Read next frame and try again
retryFrame.Close()
retryFrame = gocv.NewMat()
if ok := stream.Read(&retryFrame); !ok {
fmt.Printf(" Failed to read frame\n")
retryFrame.Close()
time.Sleep(5 * time.Second)
continue
}
continue
}
screenLayout = newLayout
lockedScale = newScale
fmt.Printf(" ✓ Layout re-detected\n")
if logFile != nil {
fmt.Fprintf(logFile, " Layout re-detected successfully\n")
}
break
}
retryFrame.Close()
continue
} else {
// Already in retry and still unstable - give up
inLayoutRetry = false
pendingReading.valid = false
lastReadingSpO2 = spo2Val
lastReadingHR = hrVal
// Mark this entry as unstable (second try)
entry.Unstable = true
entry.UnstableReason = fmt.Sprintf("Still unstable after retry (SpO2 Δ%d, HR Δ%d)", spo2Diff, hrDiff)
fmt.Printf(" ⚠️ Still unstable after retry, skipping\n")
if logFile != nil {
fmt.Fprintf(logFile, " Still unstable after retry - SpO2 delta: %d, HR delta: %d\n\n", spo2Diff, hrDiff)
}
frameToUse.Close()
frame.Close()
continue
}
}
} else {
// Stable reading (Δ≤3)
if pendingReading.valid {
// Check if new reading is closer to baseline or to pending value
spo2ToBaseline := spo2Val - baselineSpO2
if spo2ToBaseline < 0 {
spo2ToBaseline = -spo2ToBaseline
}
hrToBaseline := hrVal - baselineHR
if hrToBaseline < 0 {
hrToBaseline = -hrToBaseline
}
spo2ToPending := spo2Val - pendingReading.spO2
if spo2ToPending < 0 {
spo2ToPending = -spo2ToPending
}
hrToPending := hrVal - pendingReading.hr
if hrToPending < 0 {
hrToPending = -hrToPending
}
// If closer to baseline than to pending, pending was a glitch
if spo2ToBaseline <= spo2ToPending && hrToBaseline <= hrToPending {
fmt.Printf(" ✓ New reading closer to baseline (%d,%d) than pending (%d,%d), discarding glitch\n",
baselineSpO2, baselineHR, pendingReading.spO2, pendingReading.hr)
if logFile != nil {
fmt.Fprintf(logFile, " Discarding pending glitch: baseline %d->pending %d->current %d (SpO2), baseline %d->pending %d->current %d (HR)\n",
baselineSpO2, pendingReading.spO2, spo2Val,
baselineHR, pendingReading.hr, hrVal)
}
pendingReading.valid = false
// Fall through to post current reading
} else {
// Closer to pending - real trend, post pending first
fmt.Printf(" ✓ Stable reading confirms held value, posting both\n")
if logFile != nil {
fmt.Fprintf(logFile, " Stable reading confirms held value: %d->%d->%d (SpO2), %d->%d->%d (HR)\n",
baselineSpO2, pendingReading.spO2, spo2Val,
baselineHR, pendingReading.hr, hrVal)
}
postStart := time.Now()
spo2Err := postToHomeAssistant(config, "sensor.pulse_ox_spo2", pendingReading.spO2, "%", "SpO2")
hrErr := postToHomeAssistant(config, "sensor.pulse_ox_hr", pendingReading.hr, "bpm", "Heart Rate")
postTime := time.Since(postStart)
if spo2Err == nil && hrErr == nil {
successCount++
if logFile != nil {
fmt.Fprintf(logFile, " Posted pending reading successfully in %dms (success: %d, fail: %d)\n", postTime.Milliseconds(), successCount, failureCount)
}
}
pendingReading.valid = false
}
}
// Fall through to post current reading
}
}
// Values are stable (within 3 points) - post to Home Assistant
inLayoutRetry = false // Reset retry flag on success
lastReadingSpO2 = spo2Val
lastReadingHR = hrVal
postStart := time.Now()
spo2Err := postToHomeAssistant(config, "sensor.pulse_ox_spo2", spo2Val, "%", "SpO2")
hrErr := postToHomeAssistant(config, "sensor.pulse_ox_hr", hrVal, "bpm", "Heart Rate")
postTime := time.Since(postStart)
if spo2Err == nil && hrErr == nil {
successCount++
if logFile != nil {
fmt.Fprintf(logFile, " Posted successfully in %dms (success: %d, fail: %d)\n\n", postTime.Milliseconds(), successCount, failureCount)
}
} else {
failureCount++
fmt.Printf(" ❌ Post failed\n")
if logFile != nil {
if spo2Err != nil {
fmt.Fprintf(logFile, " SpO2 post error: %v\n", spo2Err)
}
if hrErr != nil {
fmt.Fprintf(logFile, " HR post error: %v\n", hrErr)
}
fmt.Fprintf(logFile, " (success: %d, fail: %d)\n\n", successCount, failureCount)
}
}
frameToUse.Close()
frame.Close()
} else {
failureCount++
// Update last reading even on low confidence (for next comparison)
lastReadingSpO2 = spo2Val
lastReadingHR = hrVal
if !inLayoutRetry {
// First failure - retry with next frame and re-detect layout
inLayoutRetry = true
fmt.Printf(" ⚠️ Low confidence, retrying with next frame...\n")
if logFile != nil {
fmt.Fprintf(logFile, " Low confidence - SpO2: %.1f%%, HR: %.1f%%, retrying...\n", spo2AvgConf, hrAvgConf)
}
rotated.Close()
// Read next frame immediately
retryFrame := gocv.NewMat()
if ok := stream.Read(&retryFrame); !ok {
fmt.Printf(" Failed to read retry frame\n")
retryFrame.Close()
inLayoutRetry = false
continue
}
frameCount++
timestamp = time.Now().Format("15:04:05")
// Re-detect layout
for {
fmt.Printf(" Re-detecting layout...\n")
if logFile != nil {
fmt.Fprintf(logFile, " Re-detecting layout...\n")
}
noTsRetry := retryFrame.Region(image.Rect(0, 68, retryFrame.Cols(), retryFrame.Rows()))
rotated = gocv.NewMat()
gocv.Rotate(noTsRetry, &rotated, gocv.Rotate90Clockwise)
noTsRetry.Close()
newLayout, newScale, err := detectLayoutWithNormalization(rotated)
if err != nil {
fmt.Printf(" Pulse oximeter not detected (day mode) - waiting 60 seconds...\n")
if logFile != nil {
fmt.Fprintf(logFile, " Pulse oximeter not detected (day mode) - waiting 60 seconds...\n")
}
// Reset retry state
inLayoutRetry = false
rotated.Close()
retryFrame.Close()
time.Sleep(60 * time.Second)
// Read next frame and try again
retryFrame.Close()
retryFrame = gocv.NewMat()
if ok := stream.Read(&retryFrame); !ok {
fmt.Printf(" Failed to read frame\n")
retryFrame.Close()
time.Sleep(5 * time.Second)
continue
}
continue
}
screenLayout = newLayout
lockedScale = newScale
fmt.Printf(" ✓ Layout re-detected, processing retry frame\n")
if logFile != nil {
fmt.Fprintf(logFile, " Layout re-detected successfully, processing frame #%d\n", frameCount)
fmt.Fprintf(logFile, " SpO2 area: X=%d, Y=%d, Width=%d, Height=%d\n",
screenLayout.SpO2Area.Min.X, screenLayout.SpO2Area.Min.Y,
screenLayout.SpO2Area.Dx(), screenLayout.SpO2Area.Dy())
fmt.Fprintf(logFile, " HR area: X=%d, Y=%d, Width=%d, Height=%d\n",
screenLayout.HRArea.Min.X, screenLayout.HRArea.Min.Y,
screenLayout.HRArea.Dx(), screenLayout.HRArea.Dy())
}
break
}
// Apply the new scale to the retry frame
normalized := applyScale(rotated, lockedScale)
if normalized.Ptr() != rotated.Ptr() {
rotated.Close()
rotated = normalized
}
// Now process this retry frame (fall through to loop which will read frame 248 next)
saveLayoutVisualization(rotated, screenLayout, fmt.Sprintf("review/f%d_boxes.jpg", frameCount))
if logFile != nil {
fmt.Fprintf(logFile, " Recognizing displays...\n")
}
spo2Val, spo2Left, spo2LeftConf, spo2Right, spo2RightConf = recognizeDisplayArea(rotated, screenLayout.SpO2Area, templates, "SpO2", frameCount, logFile)
hrVal, hrLeft, hrLeftConf, hrRight, hrRightConf = recognizeDisplayArea(rotated, screenLayout.HRArea, templates, "HR", frameCount, logFile)
spo2AvgConf = (spo2LeftConf + spo2RightConf) / 2.0
hrAvgConf = (hrLeftConf + hrRightConf) / 2.0
// Check if retry succeeded with high confidence
if spo2AvgConf > 85 && hrAvgConf > 85 {
// Retry succeeded - check if values changed
valuesChanged = spo2Val != lastSpO2 || hrVal != lastHR
if !valuesChanged {
rotated.Close()
retryFrame.Close()
frame.Close()
inLayoutRetry = false
continue
}
// Values changed - update
processedCount++
lastSpO2 = spo2Val
lastHR = hrVal
fmt.Printf("[%s] SpO2=%d%%, HR=%d bpm\n", timestamp, spo2Val, hrVal)
entry = ReviewEntry{
FrameNum: frameCount,
Timestamp: timestamp,
SpO2Value: spo2Val,
SpO2LeftDigit: spo2Left,
SpO2LeftConf: spo2LeftConf,
SpO2RightDigit: spo2Right,
SpO2RightConf: spo2RightConf,
HRValue: hrVal,
HRLeftDigit: hrLeft,
HRLeftConf: hrLeftConf,
HRRightDigit: hrRight,
HRRightConf: hrRightConf,
Unstable: false,
UnstableReason: "",
}
reviewEntries = append(reviewEntries, entry)
// Now perform stability checks (same as high confidence path above)
if lastReadingSpO2 != -1 && lastReadingHR != -1 {
spo2Diff := spo2Val - lastReadingSpO2
if spo2Diff < 0 {
spo2Diff = -spo2Diff
}
hrDiff := hrVal - lastReadingHR
if hrDiff < 0 {
hrDiff = -hrDiff
}
if spo2Diff <= 3 && hrDiff <= 3 {
// Stable - post it
inLayoutRetry = false
lastReadingSpO2 = spo2Val
lastReadingHR = hrVal
postStart := time.Now()
spo2Err := postToHomeAssistant(config, "sensor.pulse_ox_spo2", spo2Val, "%", "SpO2")
hrErr := postToHomeAssistant(config, "sensor.pulse_ox_hr", hrVal, "bpm", "Heart Rate")
postTime := time.Since(postStart)
if spo2Err == nil && hrErr == nil {
successCount++
if logFile != nil {
fmt.Fprintf(logFile, " Posted successfully in %dms (success: %d, fail: %d)\n\n", postTime.Milliseconds(), successCount, failureCount)
}
} else {
failureCount++
}
rotated.Close()
retryFrame.Close()
frame.Close()
continue
}
}
// First reading or stable - post it
inLayoutRetry = false
lastReadingSpO2 = spo2Val
lastReadingHR = hrVal
postStart := time.Now()
spo2Err := postToHomeAssistant(config, "sensor.pulse_ox_spo2", spo2Val, "%", "SpO2")
hrErr := postToHomeAssistant(config, "sensor.pulse_ox_hr", hrVal, "bpm", "Heart Rate")
postTime := time.Since(postStart)
if spo2Err == nil && hrErr == nil {
successCount++
if logFile != nil {
fmt.Fprintf(logFile, " Posted successfully in %dms (success: %d, fail: %d)\n\n", postTime.Milliseconds(), successCount, failureCount)
}
} else {
failureCount++
}
rotated.Close()
retryFrame.Close()
frame.Close()
continue
}
// If still low confidence, fall through to "second failure" below
} else {
// Second failure - give up and wait
inLayoutRetry = false
fmt.Printf(" ⚠️ Low confidence after retry, pausing 2 seconds\n")
if logFile != nil {
fmt.Fprintf(logFile, " Low confidence after retry - SpO2: %.1f%%, HR: %.1f%% (success: %d, fail: %d)\n\n", spo2AvgConf, hrAvgConf, successCount, failureCount)
}
time.Sleep(2 * time.Second)
}
}
frameToUse.Close()
frame.Close()
}
fmt.Println("\n📝 Writing review.html...")
if err := writeReviewHTML(reviewEntries); err != nil {
fmt.Printf("Error writing review HTML: %v\n", err)
} else {
fmt.Printf("✓ Review page created: review/review.html (%d frames)\n", len(reviewEntries))
fmt.Println(" Open it in browser to review recognition results")
fmt.Println(" Copy good digits: cp review/f5_spo2_digit2.png training_digits/9_2.png")
}
}