300 lines
11 KiB
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)
|
|
}
|