pulse-monitor/backups/backup_20251125/processor.go

300 lines
11 KiB
Go

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,
}
}
// buildReviewEntry creates a ReviewEntry from a Reading
func buildReviewEntry(reading *Reading, failed bool, failureReason string, unstable bool, unstableReason string) ReviewEntry {
return ReviewEntry{
FrameNum: reading.FrameNum,
Timestamp: reading.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: failed,
FailureReason: failureReason,
Unstable: unstable,
UnstableReason: unstableReason,
}
}
// handleFrameFailure handles all failed frame processing:
// - Saves debug files (raw frame + layout visualization)
// - Creates and appends review entry
// - Calculates total timing
// - Returns ProcessingResult with given status
func handleFrameFailure(
rawFrame gocv.Mat,
normalized gocv.Mat,
layout *ScreenLayout,
reading *Reading,
status ProcessingStatus,
failureReason string,
state *ProcessingState,
timing *TimingData,
frameStartTime time.Time,
) ProcessingResult {
// Save debug files
fileIOStart := time.Now()
saveThresholdedFrame(rawFrame, reading.FrameNum)
saveLayoutVisualization(normalized, layout, fmt.Sprintf("review/f%d_boxes.jpg", reading.FrameNum))
timing.FileIO = time.Since(fileIOStart).Milliseconds()
// Create and append review entry
entry := buildReviewEntry(reading, true, failureReason, false, "")
state.ReviewEntries = append(state.ReviewEntries, entry)
if err := appendReviewEntry(entry); err != nil {
logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err)
}
// Calculate total timing
timing.Total = time.Since(frameStartTime).Milliseconds()
return ProcessingResult{
Status: status,
Reading: *reading,
}
}
// 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()
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,
}
// HANDLER #1: Check for corruption - ANY -1 digit means invalid/corrupted
if reading.IsCorrupted() {
validationStart := time.Now()
timing.Validation += time.Since(validationStart).Milliseconds()
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 extra debug files in DEBUG_MODE
if DEBUG_MODE {
debugTimestamp := time.Now().Format("20060102_150405")
gocv.IMWrite(fmt.Sprintf("test_output/corruption_%s_frame%d_normalized.png", debugTimestamp, frameNum), normalized)
saveLayoutVisualization(normalized, state.Layout, fmt.Sprintf("test_output/corruption_%s_frame%d_layout.jpg", debugTimestamp, frameNum))
logMessage(LogFile, Info, " 💾 Debug saved: %s", debugTimestamp)
}
return handleFrameFailure(rawFrame, normalized, state.Layout, &reading, StatusCorrupted,
fmt.Sprintf("Corruption: %s", reading.GetCorruptionDetails()), state, timing, frameStartTime)
}
// HANDLER #2: Check for unrecognized digits (negative values)
if reading.SpO2 < 0 || reading.HR < 0 {
validationStart := time.Now()
timing.Validation += time.Since(validationStart).Milliseconds()
logMessage(LogFile, Info, " [UNRECOGNIZED] SpO2=%d, HR=%d - treating as low confidence", reading.SpO2, reading.HR)
return handleFrameFailure(rawFrame, normalized, state.Layout, &reading, StatusLowConfidence,
fmt.Sprintf("Unrecognized: SpO2=%d, HR=%d", reading.SpO2, reading.HR), state, timing, frameStartTime)
}
// Check if values changed
validationStart := time.Now()
valuesChanged := (reading.SpO2 != state.LastPosted.SpO2 || reading.HR != state.LastPosted.HR)
timing.Validation += time.Since(validationStart).Milliseconds()
if !valuesChanged {
timing.Total = time.Since(frameStartTime).Milliseconds()
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 state and log
state.LastPosted = reading
logMessage(Both, Info, "SpO2=%d%%, HR=%d bpm", reading.SpO2, reading.HR)
// HANDLER #3: Check confidence
validationStart = time.Now()
action, _ := validateConfidence(&reading, state, p.logger)
timing.Validation += time.Since(validationStart).Milliseconds()
if action == ActionRetry {
state.LowConfidenceCount++
logMessage(Both, Warning, " Low confidence (#%d) - grabbing next frame immediately...", state.LowConfidenceCount)
spo2Avg, hrAvg := reading.AvgConfidence()
return handleFrameFailure(rawFrame, normalized, state.Layout, &reading, StatusLowConfidence,
fmt.Sprintf("Low confidence: SpO2 %.1f%%, HR %.1f%%", spo2Avg, hrAvg), state, timing, frameStartTime)
}
if action == ActionDiscard {
state.LowConfidenceCount++
logMessage(Both, Warning, " Low confidence after retry (#%d)", state.LowConfidenceCount)
spo2Avg, hrAvg := reading.AvgConfidence()
return handleFrameFailure(rawFrame, normalized, state.Layout, &reading, StatusLowConfidence,
fmt.Sprintf("Low confidence: SpO2 %.1f%%, HR %.1f%%", spo2Avg, hrAvg), state, timing, frameStartTime)
}
// HANDLER #4: Check stability
validationStart = time.Now()
action, reason := validateStability(&reading, state, p.logger)
timing.Validation += time.Since(validationStart).Milliseconds()
// Check for physiologically impossible values (< 40)
if reading.SpO2 < 40 || reading.HR < 40 {
timing.HASS = 0
logMessage(Both, Warning, " Invalid physiological values: SpO2=%d, HR=%d (below 40) - not posting to HASS",
reading.SpO2, reading.HR)
entry := buildReviewEntry(&reading, false, "", false, "")
state.ReviewEntries = append(state.ReviewEntries, entry)
if err := appendReviewEntry(entry); err != nil {
logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err)
}
timing.Total = time.Since(frameStartTime).Milliseconds()
return ProcessingResult{Status: StatusSuccess, Reading: reading, ShouldPost: false}
}
// Handle stability action
if action == ActionHold {
timing.HASS = 0
entry := buildReviewEntry(&reading, false, "", true, reason)
state.ReviewEntries = append(state.ReviewEntries, entry)
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)
timing.Total = time.Since(frameStartTime).Milliseconds()
return ProcessingResult{Status: StatusUnstable, Reading: reading, ShouldPost: false, UnstableReason: reason}
}
// SUCCESS - ActionPost or default
timing.HASS = 0 // Will be updated in main loop
entry := buildReviewEntry(&reading, false, "", false, "")
state.ReviewEntries = append(state.ReviewEntries, entry)
if err := appendReviewEntry(entry); err != nil {
logMessage(LogFile, Warning, " Warning: Could not append to review HTML: %v", err)
}
timing.Total = time.Since(frameStartTime).Milliseconds()
return ProcessingResult{Status: StatusSuccess, Reading: reading, ShouldPost: action == ActionPost}
}
// 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) {
filename := fmt.Sprintf("raw_frames/thresh_%s-%05d.png", time.Now().Format("20060102"), frameNum)
gocv.IMWrite(filename, frame)
}