931 lines
31 KiB
Go
931 lines
31 KiB
Go
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")
|
||
}
|
||
}
|