pulse-monitor/processor.go.backup

489 lines
17 KiB
Plaintext

package main
import (
"fmt"
"image"
"io"
"time"
"gocv.io/x/gocv"
)
// Processor handles the frame processing pipeline
type Processor struct {
templates map[int][]gocv.Mat
config *Config
logger io.Writer
}
// NewProcessor creates a new processor
func NewProcessor(templates map[int][]gocv.Mat, config *Config, logger io.Writer) *Processor {
return &Processor{
templates: templates,
config: config,
logger: logger,
}
}
// processFrame runs OCR on a normalized frame with valid layout
// rawFrame is the untouched frame from source (used for saving on errors)
func (p *Processor) processFrame(frame gocv.Mat, rawFrame gocv.Mat, frameNum int, state *ProcessingState, timing *TimingData) ProcessingResult {
timestamp := time.Now().Format("15:04:05.000")
frameStartTime := time.Now() // Measure total processing time
logMessage(LogFile, Info, "Frame #%d", frameNum)
// Apply scaling if needed
scaleStart := time.Now()
var normalized gocv.Mat
if state.LockedScale != 1.0 {
newWidth := int(float64(frame.Cols()) * state.LockedScale)
newHeight := int(float64(frame.Rows()) * state.LockedScale)
normalized = gocv.NewMat()
gocv.Resize(frame, &normalized, image.Pt(newWidth, newHeight), 0, 0, gocv.InterpolationLinear)
defer normalized.Close()
logMessage(LogFile, Debug, " Applied scaling: %dx%d -> %dx%d (scale: %.3f)",
frame.Cols(), frame.Rows(), newWidth, newHeight, state.LockedScale)
} else {
normalized = frame
}
timing.Scale = time.Since(scaleStart).Milliseconds()
// Run OCR
logMessage(LogFile, Info, " Recognizing displays...")
spo2Start := time.Now()
spo2Val, spo2Left, spo2LeftConf, spo2Right, spo2RightConf := recognizeDisplayArea(
normalized, state.Layout.SpO2Area, p.templates, "SpO2", frameNum, p.logger)
timing.OCR_SpO2 = time.Since(spo2Start).Milliseconds()
hrStart := time.Now()
hrVal, hrLeft, hrLeftConf, hrRight, hrRightConf := recognizeDisplayArea(
normalized, state.Layout.HRArea, p.templates, "HR", frameNum, p.logger)
timing.OCR_HR = time.Since(hrStart).Milliseconds()
reading := Reading{
SpO2: spo2Val,
SpO2LeftDigit: spo2Left,
SpO2LeftConf: spo2LeftConf,
SpO2RightDigit: spo2Right,
SpO2RightConf: spo2RightConf,
HR: hrVal,
HRLeftDigit: hrLeft,
HRLeftConf: hrLeftConf,
HRRightDigit: hrRight,
HRRightConf: hrRightConf,
Timestamp: timestamp,
FrameNum: frameNum,
}
// Check for corruption (Handler #1) - ANY -1 digit means invalid/corrupted
// Silently skip - invalid patterns are working as intended
if reading.IsCorrupted() {
// Validation check is very fast (just checking digit values)
validationStart := time.Now()
// Reading.IsCorrupted() was already called in the if condition
timing.Validation += time.Since(validationStart).Milliseconds()
// Console + Log: descriptive message with frame number
logMessage(LogFile, Warning, " Frame #%d: Corruption detected - %s matched invalid pattern (marked as -1)",
frameNum, reading.GetCorruptionDetails())
logMessage(LogFile, Info, " [CORRUPTION] Frame #%d - Digit = -1 detected: SpO2(%d,%d) HR(%d,%d) - skipping frame",
frameNum,
reading.SpO2LeftDigit, reading.SpO2RightDigit,
reading.HRLeftDigit, reading.HRRightDigit)
// Save debug output to test_output/ with timestamp (only in debug mode)
if DEBUG_MODE {
debugTimestamp := time.Now().Format("20060102_150405")
// Save normalized frame
normalizedFilename := fmt.Sprintf("test_output/corruption_%s_frame%d_normalized.png", debugTimestamp, frameNum)
gocv.IMWrite(normalizedFilename, normalized)
// Save layout visualization
layoutFilename := fmt.Sprintf("test_output/corruption_%s_frame%d_layout.jpg", debugTimestamp, frameNum)
saveLayoutVisualization(normalized, state.Layout, layoutFilename)
logMessage(LogFile, Info, " 💾 Debug saved: %s", debugTimestamp)
}
// Add to review HTML so we can see the digit images
entry := ReviewEntry{
FrameNum: frameNum,
Timestamp: timestamp,
SpO2Value: reading.SpO2,
SpO2LeftDigit: reading.SpO2LeftDigit,
SpO2LeftConf: reading.SpO2LeftConf,
SpO2RightDigit: reading.SpO2RightDigit,
SpO2RightConf: reading.SpO2RightConf,
HRValue: reading.HR,
HRLeftDigit: reading.HRLeftDigit,
HRLeftConf: reading.HRLeftConf,
HRRightDigit: reading.HRRightDigit,
HRRightConf: reading.HRRightConf,
Failed: true,
FailureReason: fmt.Sprintf("Corruption: %s", reading.GetCorruptionDetails()),
}
state.ReviewEntries = append(state.ReviewEntries, entry)
// Append to live HTML
if err := appendReviewEntry(entry); err != nil {
logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err)
}
// Save thresholded raw frame (untouched, just thresholded) for replay/debugging
fileIOStart := time.Now()
saveThresholdedFrame(rawFrame, frameNum)
// Save layout visualization for debugging
saveLayoutVisualization(normalized, state.Layout, fmt.Sprintf("review/f%d_boxes.jpg", frameNum))
timing.FileIO = time.Since(fileIOStart).Milliseconds()
// Calculate total
timing.Total = time.Since(frameStartTime).Milliseconds()
return ProcessingResult{
Status: StatusCorrupted,
Reading: reading,
}
}
// Check for negative values (unrecognized digits) - treat as low confidence
if reading.SpO2 < 0 || reading.HR < 0 {
validationStart := time.Now()
// Validation is just the comparison above
timing.Validation += time.Since(validationStart).Milliseconds()
logMessage(LogFile, Info, " [UNRECOGNIZED] SpO2=%d, HR=%d - treating as low confidence",
reading.SpO2, reading.HR)
// Save thresholded raw frame (untouched, just thresholded) for replay/debugging
fileIOStart := time.Now()
saveThresholdedFrame(rawFrame, frameNum)
// Save layout visualization for debugging
saveLayoutVisualization(normalized, state.Layout, fmt.Sprintf("review/f%d_boxes.jpg", frameNum))
timing.FileIO = time.Since(fileIOStart).Milliseconds()
// Add failed entry to review
entry := ReviewEntry{
FrameNum: frameNum,
Timestamp: timestamp,
SpO2Value: reading.SpO2,
SpO2LeftDigit: reading.SpO2LeftDigit,
SpO2LeftConf: reading.SpO2LeftConf,
SpO2RightDigit: reading.SpO2RightDigit,
SpO2RightConf: reading.SpO2RightConf,
HRValue: reading.HR,
HRLeftDigit: reading.HRLeftDigit,
HRLeftConf: reading.HRLeftConf,
HRRightDigit: reading.HRRightDigit,
HRRightConf: reading.HRRightConf,
Failed: true,
FailureReason: fmt.Sprintf("Unrecognized: SpO2=%d, HR=%d", reading.SpO2, reading.HR),
}
state.ReviewEntries = append(state.ReviewEntries, entry)
// Append to live HTML
if err := appendReviewEntry(entry); err != nil {
logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err)
}
// Calculate total
timing.Total = time.Since(frameStartTime).Milliseconds()
return ProcessingResult{
Status: StatusLowConfidence,
Reading: reading,
}
}
// Check for value changes
validationStart := time.Now()
valuesChanged := (reading.SpO2 != state.LastPosted.SpO2 || reading.HR != state.LastPosted.HR)
timing.Validation += time.Since(validationStart).Milliseconds()
if !valuesChanged {
// Values didn't change - no files to clean up (only created in DEBUG_MODE)
// Calculate total
timing.Total = time.Since(frameStartTime).Milliseconds()
// Log processing time for unchanged frames
logMessage(LogFile, Debug, " Frame #%d: SpO2=%d%%, HR=%d bpm (no change) - processed in %dms",
frameNum, reading.SpO2, reading.HR, timing.Total)
return ProcessingResult{
Status: StatusNoChange,
Reading: reading,
}
}
// Values changed - update last posted values
state.LastPosted = reading
// Print to console and log (MAIN OUTPUT)
// Note: timing.Total will be calculated at the end, so don't print it here
logMessage(Both, Info, "SpO2=%d%%, HR=%d bpm", reading.SpO2, reading.HR)
// Check confidence (Handler #2)
validationStart = time.Now()
action, _ := validateConfidence(&reading, state, p.logger)
timing.Validation += time.Since(validationStart).Milliseconds()
if action == ActionRetry {
// Increment and display low confidence counter
state.LowConfidenceCount++
logMessage(Both, Warning, " Low confidence (#%d) - grabbing next frame immediately...", state.LowConfidenceCount)
// Save thresholded raw frame (untouched, just thresholded) for replay/debugging
fileIOStart2 := time.Now()
saveThresholdedFrame(rawFrame, frameNum)
// Save layout visualization for debugging
saveLayoutVisualization(normalized, state.Layout, fmt.Sprintf("review/f%d_boxes.jpg", frameNum))
timing.FileIO += time.Since(fileIOStart2).Milliseconds()
// Add failed entry to review
spo2Avg, hrAvg := reading.AvgConfidence()
entry := ReviewEntry{
FrameNum: frameNum,
Timestamp: timestamp,
SpO2Value: reading.SpO2,
SpO2LeftDigit: reading.SpO2LeftDigit,
SpO2LeftConf: reading.SpO2LeftConf,
SpO2RightDigit: reading.SpO2RightDigit,
SpO2RightConf: reading.SpO2RightConf,
HRValue: reading.HR,
HRLeftDigit: reading.HRLeftDigit,
HRLeftConf: reading.HRLeftConf,
HRRightDigit: reading.HRRightDigit,
HRRightConf: reading.HRRightConf,
Failed: true,
FailureReason: fmt.Sprintf("Low confidence: SpO2 %.1f%%, HR %.1f%%", spo2Avg, hrAvg),
}
state.ReviewEntries = append(state.ReviewEntries, entry)
// Append to live HTML
if err := appendReviewEntry(entry); err != nil {
logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err)
}
// Calculate total
timing.Total = time.Since(frameStartTime).Milliseconds()
// Return status so main loop can handle escalation
return ProcessingResult{
Status: StatusLowConfidence,
Reading: reading,
}
} else if action == ActionDiscard {
// Increment and display low confidence counter
state.LowConfidenceCount++
logMessage(Both, Warning, " Low confidence after retry (#%d)", state.LowConfidenceCount)
// Save thresholded raw frame (untouched, just thresholded) for replay/debugging
fileIOStart2 := time.Now()
saveThresholdedFrame(rawFrame, frameNum)
// Save layout visualization for debugging
saveLayoutVisualization(normalized, state.Layout, fmt.Sprintf("review/f%d_boxes.jpg", frameNum))
timing.FileIO += time.Since(fileIOStart2).Milliseconds()
// Add failed entry to review
spo2Avg, hrAvg := reading.AvgConfidence()
entry := ReviewEntry{
FrameNum: frameNum,
Timestamp: timestamp,
SpO2Value: reading.SpO2,
SpO2LeftDigit: reading.SpO2LeftDigit,
SpO2LeftConf: reading.SpO2LeftConf,
SpO2RightDigit: reading.SpO2RightDigit,
SpO2RightConf: reading.SpO2RightConf,
HRValue: reading.HR,
HRLeftDigit: reading.HRLeftDigit,
HRLeftConf: reading.HRLeftConf,
HRRightDigit: reading.HRRightDigit,
HRRightConf: reading.HRRightConf,
Failed: true,
FailureReason: fmt.Sprintf("Low confidence: SpO2 %.1f%%, HR %.1f%%", spo2Avg, hrAvg),
}
state.ReviewEntries = append(state.ReviewEntries, entry)
// Append to live HTML
if err := appendReviewEntry(entry); err != nil {
logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err)
}
// Calculate total
timing.Total = time.Since(frameStartTime).Milliseconds()
return ProcessingResult{
Status: StatusLowConfidence,
Reading: reading,
}
}
// Check stability (Handler #3)
validationStart = time.Now()
action, reason := validateStability(&reading, state, p.logger)
timing.Validation += time.Since(validationStart).Milliseconds()
switch action {
case ActionPost:
// Check for physiologically impossible values (< 40)
if reading.SpO2 < 40 || reading.HR < 40 {
timing.HASS = 0 // No HASS posting
logMessage(Both, Warning, " Invalid physiological values: SpO2=%d, HR=%d (below 40) - not posting to HASS",
reading.SpO2, reading.HR)
// Add to review but don't post
entry := ReviewEntry{
FrameNum: frameNum,
Timestamp: timestamp,
SpO2Value: reading.SpO2,
SpO2LeftDigit: reading.SpO2LeftDigit,
SpO2LeftConf: reading.SpO2LeftConf,
SpO2RightDigit: reading.SpO2RightDigit,
SpO2RightConf: reading.SpO2RightConf,
HRValue: reading.HR,
HRLeftDigit: reading.HRLeftDigit,
HRLeftConf: reading.HRLeftConf,
HRRightDigit: reading.HRRightDigit,
HRRightConf: reading.HRRightConf,
Unstable: false,
}
state.ReviewEntries = append(state.ReviewEntries, entry)
// Append to live HTML
if err := appendReviewEntry(entry); err != nil {
logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err)
}
// Calculate total
timing.Total = time.Since(frameStartTime).Milliseconds()
return ProcessingResult{
Status: StatusSuccess,
Reading: reading,
ShouldPost: false, // Don't post invalid values
}
}
// Add to review
entry := ReviewEntry{
FrameNum: frameNum,
Timestamp: timestamp,
SpO2Value: reading.SpO2,
SpO2LeftDigit: reading.SpO2LeftDigit,
SpO2LeftConf: reading.SpO2LeftConf,
SpO2RightDigit: reading.SpO2RightDigit,
SpO2RightConf: reading.SpO2RightConf,
HRValue: reading.HR,
HRLeftDigit: reading.HRLeftDigit,
HRLeftConf: reading.HRLeftConf,
HRRightDigit: reading.HRRightDigit,
HRRightConf: reading.HRRightConf,
Unstable: false,
}
state.ReviewEntries = append(state.ReviewEntries, entry)
// Append to live HTML
if err := appendReviewEntry(entry); err != nil {
logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err)
}
// HASS posting happens in main loop after we return
// Will be timed there
timing.HASS = 0 // Placeholder, will be updated in main loop
// Calculate total (before HASS post)
timing.Total = time.Since(frameStartTime).Milliseconds()
return ProcessingResult{
Status: StatusSuccess,
Reading: reading,
ShouldPost: true,
}
case ActionHold:
timing.HASS = 0 // No HASS posting
// Add to review with unstable marker
entry := ReviewEntry{
FrameNum: frameNum,
Timestamp: timestamp,
SpO2Value: reading.SpO2,
SpO2LeftDigit: reading.SpO2LeftDigit,
SpO2LeftConf: reading.SpO2LeftConf,
SpO2RightDigit: reading.SpO2RightDigit,
SpO2RightConf: reading.SpO2RightConf,
HRValue: reading.HR,
HRLeftDigit: reading.HRLeftDigit,
HRLeftConf: reading.HRLeftConf,
HRRightDigit: reading.HRRightDigit,
HRRightConf: reading.HRRightConf,
Unstable: true,
UnstableReason: reason,
}
state.ReviewEntries = append(state.ReviewEntries, entry)
// Append to live HTML
if err := appendReviewEntry(entry); err != nil {
logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err)
}
logMessage(LogFile, Warning, " %s - holding for validation", reason)
// Calculate total
timing.Total = time.Since(frameStartTime).Milliseconds()
return ProcessingResult{
Status: StatusUnstable,
Reading: reading,
ShouldPost: false,
UnstableReason: reason,
}
default:
timing.HASS = 0 // No HASS posting
timing.Total = time.Since(frameStartTime).Milliseconds()
return ProcessingResult{
Status: StatusSuccess,
Reading: reading,
}
}
}
// postReading posts a reading to Home Assistant
func (p *Processor) postReading(reading *Reading, state *ProcessingState) error {
// Skip posting if no config (single-frame test mode)
if p.config == nil {
logMessage(LogFile, Info, " ⓘ Skipping HASS post (test mode)")
return nil
}
spo2Err := postToHomeAssistant(p.config, "sensor.pulse_ox_spo2", reading.SpO2, "%", "SpO2")
hrErr := postToHomeAssistant(p.config, "sensor.pulse_ox_hr", reading.HR, "bpm", "Heart Rate")
if spo2Err == nil && hrErr == nil {
state.SuccessCount++
logMessage(LogFile, Info, " ✓ Posted successfully (success: %d, fail: %d)",
state.SuccessCount, state.FailureCount)
return nil
}
state.FailureCount++
if spo2Err != nil {
logMessage(LogFile, Error, " ❌ SpO2 post error: %v", spo2Err)
}
if hrErr != nil {
logMessage(LogFile, Error, " ❌ HR post error: %v", hrErr)
}
logMessage(LogFile, Info, " (success: %d, fail: %d)", state.SuccessCount, state.FailureCount)
logMessage(Both, Error, " ❌ Post failed")
if spo2Err != nil {
return spo2Err
}
return hrErr
}
// saveThresholdedFrame saves the RAW thresholded frame
// Frame is ALREADY thresholded binary (from acquisition), just save it directly
// Useful for debugging and can be tested with ./pulseox-monitor raw_frames/thresh_*.png
func saveThresholdedFrame(frame gocv.Mat, frameNum int) {
// Frame is already thresholded binary - just save it
filename := fmt.Sprintf("raw_frames/thresh_%s-%05d.png", time.Now().Format("20060102"), frameNum)
gocv.IMWrite(filename, frame)
}