Initial commit

This commit is contained in:
Johan 2026-02-01 02:00:23 -05:00
commit 9bd05496f2
153 changed files with 24827 additions and 0 deletions

BIN
.DS_Store vendored Executable file

Binary file not shown.

BIN
._.DS_Store Executable file

Binary file not shown.

View File

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(./build.sh)"
],
"deny": [],
"ask": []
}
}

31
.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
/bin/
/dist/
# Test binary
*.test
# Output
*.out
# Dependency directories
vendor/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Env files
.env
.env.*
# Databases
*.db
*.sqlite

1672
PROJECT_STATE.md Normal file

File diff suppressed because it is too large Load Diff

62
backup.sh Executable file
View File

@ -0,0 +1,62 @@
#!/bin/bash
# Backup script for pulse-monitor project
# Creates a backup directory with today's date and copies all relevant files
DATE=$(date +%Y%m%d)
BACKUP_DIR="backup_${DATE}"
# Check if backup already exists
if [ -d "$BACKUP_DIR" ]; then
echo "Backup directory $BACKUP_DIR already exists."
read -p "Overwrite? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Backup cancelled."
exit 1
fi
rm -rf "$BACKUP_DIR"
fi
# Create backup directory
mkdir -p "$BACKUP_DIR"
# Copy relevant files
echo "Creating backup in $BACKUP_DIR..."
# Go source files
cp -v *.go "$BACKUP_DIR/" 2>/dev/null
# Markdown documentation
cp -v *.md "$BACKUP_DIR/" 2>/dev/null
# Configuration files
cp -v *.yaml "$BACKUP_DIR/" 2>/dev/null
cp -v *.yml "$BACKUP_DIR/" 2>/dev/null
# Shell scripts (except this one to avoid recursion issues)
for f in *.sh; do
if [ "$f" != "backup.sh" ]; then
cp -v "$f" "$BACKUP_DIR/" 2>/dev/null
fi
done
# Build script
cp -v build.sh "$BACKUP_DIR/" 2>/dev/null
# Makefile if exists
cp -v Makefile "$BACKUP_DIR/" 2>/dev/null
# Go module files
cp -v go.mod "$BACKUP_DIR/" 2>/dev/null
cp -v go.sum "$BACKUP_DIR/" 2>/dev/null
# Training digits directory
if [ -d "training_digits" ]; then
cp -rv training_digits "$BACKUP_DIR/" 2>/dev/null
fi
echo ""
echo "✓ Backup complete: $BACKUP_DIR"
echo "Files backed up:"
ls -la "$BACKUP_DIR/"

View File

@ -0,0 +1,22 @@
BACKUP CREATED: 2025-10-30 04:33:42
This backup contains all source files from pulse-monitor v2.35 before refactoring.
Files that should be backed up:
- pulse-monitor.go (main application - 700+ lines)
- ocr.go (template matching)
- layout_detection.go (display detection)
- normalize.go (screen width normalization)
- config.go (configuration loading)
- homeassistant.go (HA posting)
- html_report.go (review HTML generation)
- PROJECT_STATE.md (comprehensive documentation)
- config.yaml (configuration file with credentials)
To restore from this backup:
cp backups/backup_20251030_043342/*.go .
cp backups/backup_20251030_043342/PROJECT_STATE.md .
cp backups/backup_20251030_043342/config.yaml .
The original source files are still intact at /Users/johanjongsma/pulse-monitor/
This backup was created as a safety measure before major refactoring.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,41 @@
package main
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Camera struct {
RTSPURL string `yaml:"rtsp_url"`
} `yaml:"camera"`
HomeAssistant struct {
URL string `yaml:"url"`
Token string `yaml:"token"`
} `yaml:"home_assistant"`
Processing struct {
SampleInterval int `yaml:"sample_interval"`
ChangeThresholdPercent int `yaml:"change_threshold_percent"`
MaxDriftSeconds int `yaml:"max_drift_seconds"`
} `yaml:"processing"`
Logging struct {
Level string `yaml:"level"`
File string `yaml:"file"`
} `yaml:"logging"`
}
func LoadConfig(filename string) (*Config, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
return &config, nil
}

View File

@ -0,0 +1,25 @@
# Pulse Oximeter Monitor Configuration
camera:
# Tapo C110 RTSP URL (pulse ox monitoring camera)
rtsp_url: "rtsp://tapohass:!!Helder06@192.168.2.183:554/stream1"
home_assistant:
# TS140 server IP where HASS is hosted
url: "http://192.168.1.252:8123"
# Long-lived access token for pulse-monitor
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzOTMxMTA4MjczYzI0NDU1YjIzOGJlZWE0Y2NkM2I1OCIsImlhdCI6MTc2MTExNTQxNywiZXhwIjoyMDc2NDc1NDE3fQ.URFS4M0rX78rW27gQuBX-PyrPYMLlGujF16jIBHXYOw"
processing:
# How often to capture and process frames (seconds)
sample_interval: 1
# Reject readings that change more than this percent
change_threshold_percent: 5
# Maximum allowed drift INCREASE in seconds (detects camera lag/freeze)
# Camera can be 10s behind server consistently - that's fine
# But if drift increases by more than this, frame is stale
max_drift_seconds: 3
logging:
level: "info" # debug, info, warn, error
file: "./logs/pulse-monitor.log"

View File

@ -0,0 +1,43 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
func postToHomeAssistant(config *Config, entityID string, value int, unit string, friendlyName string) error {
url := fmt.Sprintf("%s/api/states/%s", config.HomeAssistant.URL, entityID)
payload := map[string]interface{}{
"state": fmt.Sprintf("%d", value),
"attributes": map[string]interface{}{
"unit_of_measurement": unit,
"friendly_name": friendlyName,
"device_class": "measurement",
},
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal error: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("request error: %w", err)
}
req.Header.Set("Authorization", "Bearer "+config.HomeAssistant.Token)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("HTTP error: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 && resp.StatusCode != 201 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return nil
}

View File

@ -0,0 +1,281 @@
package main
import (
"fmt"
"os"
)
type ReviewEntry struct {
FrameNum int
Timestamp string
SpO2Value int
SpO2LeftDigit int
SpO2LeftConf float64
SpO2RightDigit int
SpO2RightConf float64
HRValue int
HRLeftDigit int
HRLeftConf float64
HRRightDigit int
HRRightConf float64
Failed bool
FailureReason string
Unstable bool
UnstableReason string
}
// initReviewHTML creates the HTML file with header and opens the table
func initReviewHTML() error {
html := `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Pulse-Ox Recognition Review</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; background-color: #000; color: #fff; }
h1 { color: #fff; }
table { border-collapse: collapse; width: 100%; margin-top: 20px; }
th, td { border: 1px solid #444; padding: 12px; text-align: center; }
th { background-color: #2a5c2e; color: white; }
tr:nth-child(even) { background-color: #1a1a1a; }
tr:nth-child(odd) { background-color: #0a0a0a; }
.conf-high { color: #4CAF50; font-weight: bold; }
.conf-med { color: #ff9800; font-weight: bold; }
.conf-low { color: #f44336; font-weight: bold; }
.digit-box { display: inline-flex; align-items: center; margin: 5px; gap: 8px; }
.digit-img { max-height: 40px; cursor: pointer; transition: transform 0.2s; }
.digit-img:hover { opacity: 0.8; }
.ocr-value { font-size: 54px; font-weight: bold; }
p { color: #ccc; }
strong { color: #fff; }
code { background-color: #222; padding: 2px 6px; border-radius: 3px; color: #4CAF50; }
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.95); }
.modal-content { margin: auto; display: block; max-width: 90%; max-height: 90%; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }
.close { position: absolute; top: 20px; right: 35px; color: #f1f1f1; font-size: 40px; font-weight: bold; cursor: pointer; }
.close:hover { color: #bbb; }
</style>
<script>
function enlargeImage(imgSrc) {
var modal = document.getElementById('imageModal');
var modalImg = document.getElementById('enlargedImg');
modal.style.display = 'block';
modalImg.src = imgSrc;
}
function closeModal() {
document.getElementById('imageModal').style.display = 'none';
}
window.onclick = function(event) {
var modal = document.getElementById('imageModal');
if (event.target == modal) {
modal.style.display = 'none';
}
}
</script>
</head>
<body>
<div id="imageModal" class="modal" onclick="closeModal()">
<span class="close" onclick="closeModal()">&times;</span>
<img class="modal-content" id="enlargedImg">
</div>
<h1>Pulse-Ox Recognition Review (Live)</h1>
<p><strong>Tip:</strong> Press Ctrl+F (or Cmd+F on Mac) and search for "CHECK" to find frames with low confidence (&lt;85%)</p>
<p><em>Refresh page to see latest frames...</em></p>
<table>
<tr>
<th>Frame</th>
<th>Time</th>
<th>Detected Boxes</th>
<th>SpO2 Recognition</th>
<th>HR Recognition</th>
</tr>
`
return os.WriteFile("review/review.html", []byte(html), 0644)
}
// appendReviewEntry appends a single entry to the HTML file
func appendReviewEntry(e ReviewEntry) error {
confClass := func(conf float64) string {
if conf >= 85 {
return "conf-high"
} else if conf >= 70 {
return "conf-med"
}
return "conf-low"
}
needsReview := e.SpO2LeftConf < 85 || e.SpO2RightConf < 85 || e.HRLeftConf < 85 || e.HRRightConf < 85
reviewMarker := ""
if needsReview {
reviewMarker = " ⚠CHECK"
}
var rowHTML string
if e.Failed {
// Failed recognition entry
rowHTML = fmt.Sprintf(` <tr style="background-color: #330000;">
<td>%d%s</td>
<td>%s</td>
<td>
<img class="digit-img" src="f%d_boxes.jpg" onclick="enlargeImage('f%d_boxes.jpg')">
</td>
<td style="color: #ff6666;">
<strong style="color: #ff6666;"> FAILED: %s</strong><br>
<strong>SpO2: %d</strong><br>
<div style="margin: 10px 0;">
<img class="digit-img" src="f%d_spo2_checks.png" onclick="enlargeImage('f%d_spo2_checks.png')" style="max-height: 60px; border: 1px solid #444;">
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_spo2_digit1.png" onclick="enlargeImage('f%d_spo2_digit1.png')">
<span class="ocr-value" style="font-size: 24px;">D1</span>
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_spo2_digit2.png" onclick="enlargeImage('f%d_spo2_digit2.png')">
<span class="ocr-value">%d</span>
<span class="%s">(%.0f%%)</span>
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_spo2_digit3.png" onclick="enlargeImage('f%d_spo2_digit3.png')">
<span class="ocr-value">%d</span>
<span class="%s">(%.0f%%)</span>
</div>
</td>
<td style="color: #ff6666;">
<strong>HR: %d</strong><br>
<div style="margin: 10px 0;">
<img class="digit-img" src="f%d_hr_checks.png" onclick="enlargeImage('f%d_hr_checks.png')" style="max-height: 60px; border: 1px solid #444;">
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_hr_digit1.png" onclick="enlargeImage('f%d_hr_digit1.png')">
<span class="ocr-value" style="font-size: 24px;">D1</span>
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_hr_digit2.png" onclick="enlargeImage('f%d_hr_digit2.png')">
<span class="ocr-value">%d</span>
<span class="%s">(%.0f%%)</span>
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_hr_digit3.png" onclick="enlargeImage('f%d_hr_digit3.png')">
<span class="ocr-value">%d</span>
<span class="%s">(%.0f%%)</span>
</div>
</td>
</tr>
`, e.FrameNum, reviewMarker, e.Timestamp, e.FrameNum, e.FrameNum, e.FailureReason,
e.SpO2Value,
e.FrameNum, e.FrameNum,
e.FrameNum, e.FrameNum,
e.FrameNum, e.FrameNum, e.SpO2LeftDigit, confClass(e.SpO2LeftConf), e.SpO2LeftConf,
e.FrameNum, e.FrameNum, e.SpO2RightDigit, confClass(e.SpO2RightConf), e.SpO2RightConf,
e.HRValue,
e.FrameNum, e.FrameNum,
e.FrameNum, e.FrameNum,
e.FrameNum, e.FrameNum, e.HRLeftDigit, confClass(e.HRLeftConf), e.HRLeftConf,
e.FrameNum, e.FrameNum, e.HRRightDigit, confClass(e.HRRightConf), e.HRRightConf)
} else {
// Successful recognition entry
unstableMarker := ""
if e.Unstable {
unstableMarker = fmt.Sprintf(" <br><span style='color: #ff9800; font-size: 12px;'>⚠️ %s</span>", e.UnstableReason)
}
rowHTML = fmt.Sprintf(` <tr>
<td>%d%s%s</td>
<td>%s</td>
<td>
<img class="digit-img" src="f%d_boxes.jpg" onclick="enlargeImage('f%d_boxes.jpg')">
</td>
<td>
<strong>%d</strong><br>
<div style="margin: 10px 0;">
<img class="digit-img" src="f%d_spo2_checks.png" onclick="enlargeImage('f%d_spo2_checks.png')" style="max-height: 60px; border: 1px solid #444;">
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_spo2_digit1.png" onclick="enlargeImage('f%d_spo2_digit1.png')">
<span class="ocr-value" style="font-size: 24px;">D1</span>
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_spo2_digit2.png" onclick="enlargeImage('f%d_spo2_digit2.png')">
<span class="ocr-value">%d</span>
<span class="%s">(%.0f%%)</span>
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_spo2_digit3.png" onclick="enlargeImage('f%d_spo2_digit3.png')">
<span class="ocr-value">%d</span>
<span class="%s">(%.0f%%)</span>
</div>
</td>
<td>
<strong>%d</strong><br>
<div style="margin: 10px 0;">
<img class="digit-img" src="f%d_hr_checks.png" onclick="enlargeImage('f%d_hr_checks.png')" style="max-height: 60px; border: 1px solid #444;">
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_hr_digit1.png" onclick="enlargeImage('f%d_hr_digit1.png')">
<span class="ocr-value" style="font-size: 24px;">D1</span>
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_hr_digit2.png" onclick="enlargeImage('f%d_hr_digit2.png')">
<span class="ocr-value">%d</span>
<span class="%s">(%.0f%%)</span>
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_hr_digit3.png" onclick="enlargeImage('f%d_hr_digit3.png')">
<span class="ocr-value">%d</span>
<span class="%s">(%.0f%%)</span>
</div>
</td>
</tr>
`, e.FrameNum, reviewMarker, unstableMarker, e.Timestamp,
e.FrameNum, e.FrameNum,
e.SpO2Value,
e.FrameNum, e.FrameNum,
e.FrameNum, e.FrameNum,
e.FrameNum, e.FrameNum, e.SpO2LeftDigit, confClass(e.SpO2LeftConf), e.SpO2LeftConf,
e.FrameNum, e.FrameNum, e.SpO2RightDigit, confClass(e.SpO2RightConf), e.SpO2RightConf,
e.HRValue,
e.FrameNum, e.FrameNum,
e.FrameNum, e.FrameNum,
e.FrameNum, e.FrameNum, e.HRLeftDigit, confClass(e.HRLeftConf), e.HRLeftConf,
e.FrameNum, e.FrameNum, e.HRRightDigit, confClass(e.HRRightConf), e.HRRightConf)
}
// Open file in append mode
f, err := os.OpenFile("review/review.html", os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(rowHTML)
return err
}
// closeReviewHTML writes the closing tags
func closeReviewHTML() error {
footer := ` </table>
<p style="margin-top: 20px;">
<strong>Color coding:</strong>
<span class="conf-high">Green 85%</span>,
<span class="conf-med">Orange 70-84%</span>,
<span class="conf-low">Red &lt;70%</span>
</p>
<p>To add digits to training set, copy files like: <code>cp review/f5_spo2_digit2.png training_digits/9_2.png</code></p>
</body>
</html>`
f, err := os.OpenFile("review/review.html", os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(footer)
return err
}
// writeReviewHTML - kept for backward compatibility (final shutdown)
func writeReviewHTML(entries []ReviewEntry) error {
// Just close the HTML properly - entries already appended
return closeReviewHTML()
}

View File

@ -0,0 +1,743 @@
package main
import (
"fmt"
"image"
"image/color"
"math"
"sort"
"time"
"gocv.io/x/gocv"
)
type Display struct {
IsSplit bool // true = one box split in half, false = two separate boxes
FullRect image.Rectangle // used when IsSplit=true
LeftRect image.Rectangle // used when IsSplit=false
RightRect image.Rectangle // used when IsSplit=false
}
type ScreenLayout struct {
SpO2Area image.Rectangle
HRArea image.Rectangle
}
func detectScreenLayoutAreas(rotated gocv.Mat) (*ScreenLayout, float64, error) {
// Correct order: Find center region FIRST (using all contours), THEN find digit displays
startTime := time.Now()
// Input is already thresholded binary image - work directly on it
// No grayscale, no threshold - just find contours
// DEBUG: Save input to see what we're working with
if DEBUG_MODE {
gocv.IMWrite("test_output/layout_step1_input.jpg", rotated)
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step1_input.jpg")
}
// STEP 3: Apply erosion to separate nearby elements (5x5 kernel) - COMMENTED OUT
// step3Start := time.Now()
// kernel := gocv.GetStructuringElement(gocv.MorphRect, image.Pt(5, 5))
// eroded := gocv.NewMat()
// gocv.Erode(thresh, &eroded, kernel)
// kernel.Close()
// defer eroded.Close()
// fmt.Printf(" [TIMING] Step 3 (Erosion): %dms\n", time.Since(step3Start).Milliseconds())
// STEP 2: Find contours - use RetrievalList to get ALL contours including nested
step2Start := time.Now()
contours := gocv.FindContours(rotated, gocv.RetrievalList, gocv.ChainApproxSimple)
defer contours.Close()
logMessage(Both, Info, " Found %d total contours", contours.Size())
if contours.Size() == 0 {
logMessage(Both, Error, " ERROR: No contours found in thresholded frame")
logMessage(Both, Error, " Frame dimensions: %dx%d", rotated.Cols(), rotated.Rows())
// Save debug image
gocv.IMWrite("test_output/error_input.jpg", rotated)
logMessage(Both, Error, " Saved debug image to test_output/error_input.jpg")
return nil, 0, nil
}
logMessage(LogFile, Debug, " [TIMING] Step 2 (Find contours): %dms", time.Since(step2Start).Milliseconds())
logMessage(LogFile, Debug, " [TIMING] Step 2 (Total): %dms", time.Since(startTime).Milliseconds())
// STEP 1: Collect ALL boxes (no filter) and print details
var allBoxes []image.Rectangle
for i := 0; i < contours.Size(); i++ {
rect := gocv.BoundingRect(contours.At(i))
allBoxes = append(allBoxes, rect)
// fmt.Printf(" Box %d: X=%d-%d (W=%d), Y=%d-%d (H=%d)\n",
// i, rect.Min.X, rect.Max.X, rect.Dx(),
// rect.Min.Y, rect.Max.Y, rect.Dy())
}
if len(allBoxes) == 0 {
logMessage(Both, Error, " ERROR: No bounding boxes found from contours")
return nil, 0, nil
}
logMessage(LogFile, Debug, " Found %d boxes (all, no filter)", len(allBoxes))
logMessage(LogFile, Debug, "")
// DEBUG: Skip step 6a and 6b visualizations
// rawContoursVis := rotated.Clone()
// ...
// allBoxesVis := rotated.Clone()
// ...
// STEP 2: Filter to significant boxes (width or height > 30px) for processing
var significantBoxes []image.Rectangle
for _, box := range allBoxes {
if box.Dx() > 30 || box.Dy() > 30 {
significantBoxes = append(significantBoxes, box)
}
}
if len(significantBoxes) == 0 {
logMessage(Both, Error, " ERROR: No significant boxes found (>30px)")
logMessage(Both, Error, " All %d boxes were too small", len(allBoxes))
return nil, 0, nil
}
logMessage(LogFile, Debug, " Found %d significant boxes (>30px) for processing", len(significantBoxes))
// STEP 3: Calculate bounding box from significant boxes (not just digit displays)
minX := significantBoxes[0].Min.X
minY := significantBoxes[0].Min.Y
maxX := significantBoxes[0].Max.X
maxY := significantBoxes[0].Max.Y
for _, box := range significantBoxes {
if box.Min.X < minX {
minX = box.Min.X
}
if box.Min.Y < minY {
minY = box.Min.Y
}
if box.Max.X > maxX {
maxX = box.Max.X
}
if box.Max.Y > maxY {
maxY = box.Max.Y
}
}
boundingBox := image.Rect(minX, minY, maxX, maxY)
boundingBoxWidth := boundingBox.Dx()
logMessage(LogFile, Debug, " Bounding box from all boxes: X=%d-%d, Y=%d-%d, Width=%d", minX, maxX, minY, maxY, boundingBoxWidth)
// Calculate required scale to normalize to 860px width
const TARGET_WIDTH = 860
scale := float64(TARGET_WIDTH) / float64(boundingBoxWidth)
logMessage(Both, Info, " 📊 Bounding box width: %dpx, Scale factor: %.3f (target: %dpx)", boundingBoxWidth, scale, TARGET_WIDTH)
// STEP 4: Scale the frame to 860px width
step4Start := time.Now()
newWidth := int(float64(rotated.Cols()) * scale)
newHeight := int(float64(rotated.Rows()) * scale)
scaled := gocv.NewMat()
gocv.Resize(rotated, &scaled, image.Pt(newWidth, newHeight), 0, 0, gocv.InterpolationLinear)
defer scaled.Close()
logMessage(LogFile, Debug, " [TIMING] Step 4 (Scale frame): %dms", time.Since(step4Start).Milliseconds())
logMessage(LogFile, Debug, " Scaled frame: %dx%d -> %dx%d", rotated.Cols(), rotated.Rows(), newWidth, newHeight)
// CRITICAL: Scale the bounding box coordinates to match the scaled frame
scaledMinX := int(float64(minX) * scale)
scaledMaxX := int(float64(maxX) * scale)
scaledMinY := int(float64(minY) * scale)
scaledMaxY := int(float64(maxY) * scale)
scaledBoundingBox := image.Rect(scaledMinX, scaledMinY, scaledMaxX, scaledMaxY)
logMessage(LogFile, Debug, " Scaled bounding box: X=%d-%d, Y=%d-%d (from original: X=%d-%d, Y=%d-%d)",
scaledMinX, scaledMaxX, scaledMinY, scaledMaxY, minX, maxX, minY, maxY)
// Scale all significant boxes coordinates too
var scaledSignificantBoxes []image.Rectangle
for _, box := range significantBoxes {
scaledBox := image.Rect(
int(float64(box.Min.X)*scale),
int(float64(box.Min.Y)*scale),
int(float64(box.Max.X)*scale),
int(float64(box.Max.Y)*scale),
)
scaledSignificantBoxes = append(scaledSignificantBoxes, scaledBox)
}
// Scale all boxes too (for visualization)
var scaledAllBoxes []image.Rectangle
for _, box := range allBoxes {
scaledBox := image.Rect(
int(float64(box.Min.X)*scale),
int(float64(box.Min.Y)*scale),
int(float64(box.Max.X)*scale),
int(float64(box.Max.Y)*scale),
)
scaledAllBoxes = append(scaledAllBoxes, scaledBox)
}
// All subsequent processing now works on the SCALED frame with SCALED coordinates
// This ensures boxes are calculated in scaled coordinates
// STEP 5: Find 50% line
height := scaledBoundingBox.Dy()
line50 := scaledBoundingBox.Min.Y + height/2
logMessage(LogFile, Debug, " 50%% line at Y=%d", line50)
// DEBUG: Save step 5 visualization - bounding box and 50% line
bboxVis := scaled.Clone()
// Draw bounding box in blue
gocv.Rectangle(&bboxVis, scaledBoundingBox, color.RGBA{0, 100, 255, 255}, 3)
// Draw 50% line in red
gocv.Line(&bboxVis, image.Pt(scaledMinX, line50), image.Pt(scaledMaxX, line50), color.RGBA{255, 0, 0, 255}, 3)
// Add labels
gocv.PutText(&bboxVis, fmt.Sprintf("BBox: %d-%d, %d-%d", scaledMinX, scaledMaxX, scaledMinY, scaledMaxY),
image.Pt(scaledMinX+10, scaledMinY+30),
gocv.FontHersheyPlain, 1.2, color.RGBA{0, 100, 255, 255}, 2)
gocv.PutText(&bboxVis, fmt.Sprintf("50%% line: Y=%d", line50),
image.Pt(scaledMinX+10, line50-10),
gocv.FontHersheyPlain, 1.2, color.RGBA{255, 0, 0, 255}, 2)
gocv.IMWrite("test_output/layout_step5_bbox_and_line.jpg", bboxVis)
bboxVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step5_bbox_and_line.jpg")
// DEBUG: Save step 6 - all boxes with numbers
allBoxesVis := scaled.Clone()
for i, box := range scaledAllBoxes {
gocv.Rectangle(&allBoxesVis, box, color.RGBA{0, 255, 0, 255}, 2)
// Add box number
gocv.PutText(&allBoxesVis, fmt.Sprintf("%d", i),
image.Pt(box.Min.X+5, box.Min.Y+15),
gocv.FontHersheyPlain, 1.2, color.RGBA{0, 255, 0, 255}, 2)
}
gocv.IMWrite("test_output/layout_step6_all_boxes.jpg", allBoxesVis)
allBoxesVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step6_all_boxes.jpg")
// STEP 6: Filter to boxes crossing 50% line (center region)
var centerBoxes []image.Rectangle
for _, box := range scaledSignificantBoxes {
if box.Min.Y < line50 && box.Max.Y > line50 {
centerBoxes = append(centerBoxes, box)
}
}
if len(centerBoxes) == 0 {
logMessage(Both, Error, " ERROR: No boxes crossing 50%%%% line")
logMessage(Both, Error, " 50%%%% line at Y=%d, checked %d significant boxes", line50, len(scaledSignificantBoxes))
return nil, 0, nil
}
logMessage(LogFile, Debug, " Found %d boxes crossing 50%% line (center region)", len(centerBoxes))
// DEBUG: Save step 7 - center boxes visualization
centerBoxesVis := scaled.Clone()
// Draw 50% line
gocv.Line(&centerBoxesVis, image.Pt(scaledMinX, line50), image.Pt(scaledMaxX, line50), color.RGBA{255, 0, 0, 255}, 3)
// Draw center boxes
for _, box := range centerBoxes {
gocv.Rectangle(&centerBoxesVis, box, color.RGBA{0, 255, 255, 255}, 2)
}
gocv.IMWrite("test_output/layout_step7_center_boxes.jpg", centerBoxesVis)
centerBoxesVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step7_center_boxes.jpg")
// STEP 7: From center boxes, identify digit displays (height >= 110px)
var digitDisplays []image.Rectangle
for _, box := range centerBoxes {
logMessage(LogFile, Debug, " Center box: X=%d-%d (W=%d), Y=%d-%d (H=%d)",
box.Min.X, box.Max.X, box.Dx(),
box.Min.Y, box.Max.Y, box.Dy())
if box.Dy() >= 110 {
digitDisplays = append(digitDisplays, box)
logMessage(LogFile, Debug, " ✓ Added as digit display (H=%d >= 110)", box.Dy())
} else {
logMessage(LogFile, Debug, " ✗ Skipped (H=%d < 110)", box.Dy())
}
}
if len(digitDisplays) == 0 {
logMessage(Both, Error, " ERROR: No digit displays found (height >= 110px) in center region")
logMessage(Both, Error, " Found %d center boxes, none met height requirement", len(centerBoxes))
return nil, 0, nil
}
logMessage(LogFile, Debug, " Found %d digit displays (height > 120px) BEFORE trimming", len(digitDisplays))
// DEBUG: Save step 8 - digit displays visualization
if DEBUG_MODE {
digitDisplaysVis := scaled.Clone()
for _, box := range digitDisplays {
gocv.Rectangle(&digitDisplaysVis, box, color.RGBA{255, 0, 255, 255}, 3)
}
gocv.IMWrite("test_output/layout_step8_digit_displays.jpg", digitDisplaysVis)
digitDisplaysVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step8_digit_displays.jpg")
}
// STEP 8: Get Y range from digit displays
minDigitY := digitDisplays[0].Min.Y
maxDigitY := digitDisplays[0].Max.Y
for _, box := range digitDisplays {
if box.Min.Y < minDigitY {
minDigitY = box.Min.Y
}
if box.Max.Y > maxDigitY {
maxDigitY = box.Max.Y
}
}
logMessage(LogFile, Debug, " Digit display Y range: %d-%d", minDigitY, maxDigitY)
// STEP 9: Find ALL boxes (from scaledAllBoxes) within this Y range
var boxesInRange []image.Rectangle
for _, box := range scaledAllBoxes {
// Box overlaps with Y range if its bottom is below minDigitY and top is above maxDigitY
if box.Max.Y > minDigitY && box.Min.Y < maxDigitY {
boxesInRange = append(boxesInRange, box)
}
}
logMessage(LogFile, Debug, " Found %d boxes in Y range %d-%d", len(boxesInRange), minDigitY, maxDigitY)
// DEBUG: Save step 9 - boxes in range visualization
boxesInRangeVis := scaled.Clone()
// Draw Y range lines
gocv.Line(&boxesInRangeVis, image.Pt(0, minDigitY), image.Pt(boxesInRangeVis.Cols(), minDigitY), color.RGBA{255, 0, 0, 255}, 2)
gocv.Line(&boxesInRangeVis, image.Pt(0, maxDigitY), image.Pt(boxesInRangeVis.Cols(), maxDigitY), color.RGBA{255, 0, 0, 255}, 2)
// Draw digit displays in magenta
for _, box := range digitDisplays {
gocv.Rectangle(&boxesInRangeVis, box, color.RGBA{255, 0, 255, 255}, 3)
}
// Draw all boxes in range in cyan
for _, box := range boxesInRange {
gocv.Rectangle(&boxesInRangeVis, box, color.RGBA{0, 255, 255, 255}, 1)
}
gocv.IMWrite("test_output/layout_step9_boxes_in_range.jpg", boxesInRangeVis)
boxesInRangeVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step9_boxes_in_range.jpg")
// STEP 10: Trim digit displays based on small boxes crossing their right edge
for i := range digitDisplays {
originalMaxX := digitDisplays[i].Max.X
newMaxX := originalMaxX
// Check each box in range
for _, smallBox := range boxesInRange {
// Skip if this is the digit display itself
if smallBox == digitDisplays[i] {
continue
}
// Check if small box crosses the right edge of this digit display
// Crosses if: smallBox.Min.X < digitDisplay.Max.X AND smallBox.Max.X > digitDisplay.Max.X
if smallBox.Min.X < digitDisplays[i].Max.X && smallBox.Max.X > digitDisplays[i].Max.X {
// Use the left edge of the small box as the new right edge
if smallBox.Min.X < newMaxX {
newMaxX = smallBox.Min.X
logMessage(LogFile, Debug, " Trimming digit display %d: right edge %d -> %d (small box at X=%d-%d)",
i, originalMaxX, newMaxX, smallBox.Min.X, smallBox.Max.X)
}
}
}
digitDisplays[i].Max.X = newMaxX
}
logMessage(LogFile, Debug, " Digit displays AFTER trimming: %d", len(digitDisplays))
// DEBUG: Save step 11 - trimmed digit displays visualization
trimmedVis := scaled.Clone()
for _, box := range digitDisplays {
gocv.Rectangle(&trimmedVis, box, color.RGBA{0, 255, 0, 255}, 4)
}
gocv.IMWrite("test_output/layout_step11_trimmed_displays.jpg", trimmedVis)
trimmedVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step11_trimmed_displays.jpg")
if len(digitDisplays) < 2 {
// Save debug visualization showing what was found
debugVis := scaled.Clone()
// Draw 50% line
gocv.Line(&debugVis, image.Pt(scaledMinX, line50), image.Pt(scaledMaxX, line50), color.RGBA{255, 0, 0, 255}, 2)
gocv.PutText(&debugVis, fmt.Sprintf("50%% line: Y=%d", line50),
image.Pt(10, line50-10), gocv.FontHersheyPlain, 1.5, color.RGBA{255, 0, 0, 255}, 2)
// Draw all center boxes with labels
for i, box := range centerBoxes {
boxColor := color.RGBA{0, 255, 255, 255} // Cyan for rejected
label := fmt.Sprintf("#%d: H=%d", i, box.Dy())
// Check if this box qualified as digit display
qualified := false
for _, dd := range digitDisplays {
if box == dd {
qualified = true
break
}
}
if qualified {
boxColor = color.RGBA{0, 255, 0, 255} // Green for qualified
label += " OK"
} else if box.Dy() < 110 {
label += " TOO SHORT"
}
gocv.Rectangle(&debugVis, box, boxColor, 3)
gocv.PutText(&debugVis, label,
image.Pt(box.Min.X+5, box.Min.Y+25),
gocv.FontHersheyPlain, 1.5, boxColor, 2)
}
// Add summary at top
summary := fmt.Sprintf("Found: %d center boxes, %d digit displays (need 2)",
len(centerBoxes), len(digitDisplays))
gocv.PutText(&debugVis, summary,
image.Pt(10, 30), gocv.FontHersheyDuplex, 1.0, color.RGBA{255, 255, 255, 255}, 2)
gocv.PutText(&debugVis, "Requirement: Height >= 110px",
image.Pt(10, 60), gocv.FontHersheyDuplex, 1.0, color.RGBA{255, 255, 255, 255}, 2)
timestamp := time.Now().Format("20060102_150405")
errorFilename := fmt.Sprintf("test_output/layout_error_%s.jpg", timestamp)
gocv.IMWrite(errorFilename, debugVis)
debugVis.Close()
// Enhanced error message
logMessage(Both, Error, " ERROR: Only %d digit display(s) found (need 2)", len(digitDisplays))
logMessage(Both, Error, " ")
logMessage(Both, Error, " LIKELY CAUSE: Pulse oximeter is not centered under camera")
logMessage(Both, Error, " - Device may be at an angle or offset to one side")
logMessage(Both, Error, " - Only one display (SpO2 or HR) is in the center detection region")
logMessage(Both, Error, " ")
logMessage(Both, Error, " ACTION REQUIRED: Physically reposition camera or device")
logMessage(Both, Error, " - Move device to be centered and level under camera")
logMessage(Both, Error, " - Or adjust camera angle to capture both displays")
logMessage(Both, Error, " ")
logMessage(Both, Error, " Technical details:")
logMessage(Both, Error, " Center boxes found: %d (crossing 50%% line at Y=%d)", len(centerBoxes), line50)
for i, box := range centerBoxes {
qualified := ""
for _, dd := range digitDisplays {
if box == dd {
qualified = " ✓ QUALIFIED"
break
}
}
if qualified == "" {
if box.Dy() < 110 {
qualified = " ✗ TOO SHORT (< 110px)"
}
}
logMessage(Both, Error, " Box #%d: W=%dpx, H=%dpx at X=%d-%d, Y=%d-%d%s",
i, box.Dx(), box.Dy(), box.Min.X, box.Max.X, box.Min.Y, box.Max.Y, qualified)
}
logMessage(Both, Error, " 💾 Debug image saved: %s", errorFilename)
logMessage(Both, Error, " Green boxes = qualified, Cyan = rejected")
return nil, 0, nil
}
// STEP 11: Get Y range from digit displays
minCenterY := digitDisplays[0].Min.Y
maxCenterY := digitDisplays[0].Max.Y
for _, box := range digitDisplays {
if box.Min.Y < minCenterY {
minCenterY = box.Min.Y
}
if box.Max.Y > maxCenterY {
maxCenterY = box.Max.Y
}
}
// Create center region
centerRegion := image.Rect(scaledBoundingBox.Min.X, minCenterY, scaledBoundingBox.Max.X, maxCenterY)
// STEP 12: Find X-center to split left/right
centerX := centerRegion.Min.X + centerRegion.Dx()/2
logMessage(LogFile, Debug, " Center X: %d", centerX)
// STEP 13: Find rightmost X and Y range in each half
spo2RightX := -1
spo2MinY := 10000
spo2MaxY := 0
hrRightX := -1
hrMinY := 10000
hrMaxY := 0
for _, box := range digitDisplays {
// Determine which half this box belongs to based on its center
boxCenterX := box.Min.X + box.Dx()/2
if boxCenterX < centerX {
// Left half (SpO2)
if box.Max.X > spo2RightX {
spo2RightX = box.Max.X
}
if box.Min.Y < spo2MinY {
spo2MinY = box.Min.Y
}
if box.Max.Y > spo2MaxY {
spo2MaxY = box.Max.Y
}
} else {
// Right half (HR)
if box.Max.X > hrRightX {
hrRightX = box.Max.X
}
if box.Min.Y < hrMinY {
hrMinY = box.Min.Y
}
if box.Max.Y > hrMaxY {
hrMaxY = box.Max.Y
}
}
}
if spo2RightX == -1 || hrRightX == -1 {
logMessage(Both, Error, " ERROR: Could not find displays in both halves")
logMessage(Both, Error, " Left half (SpO2): rightX=%d, Right half (HR): rightX=%d", spo2RightX, hrRightX)
return nil, 0, nil
}
// STEP 14: Create boxes with fixed CUT_WIDTH
spo2LeftX := spo2RightX - CUT_WIDTH
spo2Box := image.Rect(spo2LeftX, spo2MinY, spo2RightX, spo2MaxY)
hrLeftX := hrRightX - CUT_WIDTH
hrBox := image.Rect(hrLeftX, hrMinY, hrRightX, hrMaxY)
logMessage(LogFile, Debug, " Final SpO2 box: X=%d-%d, Y=%d-%d", spo2Box.Min.X, spo2Box.Max.X, spo2Box.Min.Y, spo2Box.Max.Y)
logMessage(LogFile, Debug, " Final HR box: X=%d-%d, Y=%d-%d", hrBox.Min.X, hrBox.Max.X, hrBox.Min.Y, hrBox.Max.Y)
// DEBUG: Save step 15 - final boxes visualization
finalVis := scaled.Clone()
gocv.Rectangle(&finalVis, spo2Box, color.RGBA{255, 0, 0, 255}, 4)
gocv.Rectangle(&finalVis, hrBox, color.RGBA{0, 255, 255, 255}, 4)
gocv.PutText(&finalVis, "SpO2", image.Pt(spo2Box.Min.X, spo2Box.Min.Y-10),
gocv.FontHersheyDuplex, 1.2, color.RGBA{255, 0, 0, 255}, 2)
gocv.PutText(&finalVis, "HR", image.Pt(hrBox.Min.X, hrBox.Min.Y-10),
gocv.FontHersheyDuplex, 1.2, color.RGBA{0, 255, 255, 255}, 2)
gocv.IMWrite("test_output/layout_step15_final_boxes.jpg", finalVis)
finalVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step15_final_boxes.jpg")
logMessage(LogFile, Debug, " [TIMING] Total layout detection: %dms", time.Since(startTime).Milliseconds())
// Return layout and scale factor
return &ScreenLayout{
SpO2Area: spo2Box,
HRArea: hrBox,
}, scale, nil
}
// Old functions kept for backward compatibility but deprecated
func groupAdjacentContours(rects []image.Rectangle) []Display {
var displays []Display
used := make([]bool, len(rects))
logMessage(LogFile, Debug, " Grouping %d candidate contours:", len(rects))
for i := 0; i < len(rects); i++ {
if used[i] {
continue
}
// Look for an adjacent rect with similar Y coordinate
var adjacent *int
for j := i + 1; j < len(rects); j++ {
if used[j] {
continue
}
// Check if Y coordinates are similar (within 20 pixels)
yDiff := rects[i].Min.Y - rects[j].Min.Y
if yDiff < 0 {
yDiff = -yDiff
}
if yDiff > 20 {
logMessage(LogFile, Debug, " Skip pairing rect[%d] and rect[%d]: Y diff=%d > 20", i, j, yDiff)
continue
}
// Check if they're side-by-side (allow small overlap or gap)
xGap := rects[j].Min.X - rects[i].Max.X
// Allow up to 50 pixels of overlap (negative gap) or up to 200 pixels of space
if xGap >= -50 && xGap < 200 {
logMessage(LogFile, Debug, " Pairing rect[%d] and rect[%d]: Y diff=%d, X gap=%d", i, j, yDiff, xGap)
adjacent = &j
break
} else {
logMessage(LogFile, Debug, " Skip pairing rect[%d] and rect[%d]: X gap=%d not in range [-50, 200)", i, j, xGap)
}
}
if adjacent != nil {
// Two separate contours - use them as left and right
used[i] = true
used[*adjacent] = true
left := rects[i]
right := rects[*adjacent]
// Ensure left is actually on the left
if left.Min.X > right.Min.X {
left, right = right, left
}
logMessage(LogFile, Debug, " DUAL: [%dx%d @(%d,%d)] + [%dx%d @(%d,%d)]",
left.Dx(), left.Dy(), left.Min.X, left.Min.Y,
right.Dx(), right.Dy(), right.Min.X, right.Min.Y)
displays = append(displays, Display{
IsSplit: false,
LeftRect: left,
RightRect: right,
})
} else {
// Single contour - will be split in half
used[i] = true
logMessage(LogFile, Debug, " SPLIT: [%dx%d @(%d,%d)]",
rects[i].Dx(), rects[i].Dy(), rects[i].Min.X, rects[i].Min.Y)
displays = append(displays, Display{
IsSplit: true,
FullRect: rects[i],
})
}
}
return displays
}
func detectScreenLayout(rotated gocv.Mat) ([]Display, error) {
gray := gocv.NewMat()
gocv.CvtColor(rotated, &gray, gocv.ColorBGRToGray)
thresh := gocv.NewMat()
gocv.Threshold(gray, &thresh, 170, 255, gocv.ThresholdBinary)
gray.Close()
contours := gocv.FindContours(thresh, gocv.RetrievalExternal, gocv.ChainApproxSimple)
thresh.Close()
var rects []image.Rectangle
for i := 0; i < contours.Size(); i++ {
rect := gocv.BoundingRect(contours.At(i))
if rect.Dy() > MIN_BOX_HEIGHT && rect.Dy() < 200 {
rects = append(rects, rect)
}
}
contours.Close()
sort.Slice(rects, func(i, j int) bool {
return rects[i].Dy() > rects[j].Dy()
})
if len(rects) < 3 {
return nil, fmt.Errorf("found only %d contours (need 3+)", len(rects))
}
// Take up to 6 candidates to ensure we capture split digits
maxCands := 6
if len(rects) < maxCands+1 {
maxCands = len(rects) - 1
}
cands := rects[1 : 1+maxCands]
// Sort by Y position for processing (Y desc, then X asc)
sort.Slice(cands, func(i, j int) bool {
if cands[i].Min.Y != cands[j].Min.Y {
return cands[i].Min.Y > cands[j].Min.Y
}
return cands[i].Min.X < cands[j].Min.X
})
logMessage(LogFile, Debug, " Selected %d candidates (excluding largest):", len(cands))
for idx, rect := range cands {
logMessage(LogFile, Debug, " [%d]: %dx%d @(%d,%d)", idx, rect.Dx(), rect.Dy(), rect.Min.X, rect.Min.Y)
}
// Group adjacent contours
displays := groupAdjacentContours(cands)
logMessage(LogFile, Debug, " Found %d displays after grouping", len(displays))
// Group displays by Y position to find the digit row
// Displays within 30 pixels vertically are considered same row
type YGroup struct {
avgY int
displays []Display
}
var yGroups []YGroup
for _, disp := range displays {
dispY := disp.FullRect.Min.Y
if !disp.IsSplit {
dispY = disp.LeftRect.Min.Y
}
// Find existing group with similar Y
found := false
for i := range yGroups {
if math.Abs(float64(dispY-yGroups[i].avgY)) < 30 {
yGroups[i].displays = append(yGroups[i].displays, disp)
found = true
break
}
}
if !found {
yGroups = append(yGroups, YGroup{avgY: dispY, displays: []Display{disp}})
}
}
logMessage(LogFile, Debug, " Grouped into %d Y-bands:", len(yGroups))
for idx, group := range yGroups {
logMessage(LogFile, Debug, " Band %d (Y~%d): %d displays", idx, group.avgY, len(group.displays))
}
// Find the band with exactly 2 displays (SpO2 and HR)
var digitDisplays []Display
for _, group := range yGroups {
if len(group.displays) == 2 {
digitDisplays = group.displays
break
}
}
if len(digitDisplays) != 2 {
return nil, fmt.Errorf("could not find Y-band with exactly 2 displays (found %d bands)", len(yGroups))
}
// Sort by X position: left = SpO2, right = HR
sort.Slice(digitDisplays, func(i, j int) bool {
xI := digitDisplays[i].FullRect.Min.X
if !digitDisplays[i].IsSplit {
xI = digitDisplays[i].LeftRect.Min.X
}
xJ := digitDisplays[j].FullRect.Min.X
if !digitDisplays[j].IsSplit {
xJ = digitDisplays[j].LeftRect.Min.X
}
return xI < xJ
})
return digitDisplays, nil
}
func saveLayoutVisualization(rotated gocv.Mat, layout *ScreenLayout, filename string) {
visualization := rotated.Clone()
// Draw SpO2 box in red
red := color.RGBA{255, 0, 0, 255}
gocv.Rectangle(&visualization, layout.SpO2Area, red, 3)
gocv.PutText(&visualization, "SpO2", image.Pt(layout.SpO2Area.Min.X, layout.SpO2Area.Min.Y-10),
gocv.FontHersheyDuplex, 1.2, red, 2)
// Draw HR box in cyan
cyan := color.RGBA{0, 255, 255, 255}
gocv.Rectangle(&visualization, layout.HRArea, cyan, 3)
gocv.PutText(&visualization, "HR", image.Pt(layout.HRArea.Min.X, layout.HRArea.Min.Y-10),
gocv.FontHersheyDuplex, 1.2, cyan, 2)
gocv.IMWrite(filename, visualization)
visualization.Close()
}

View File

@ -0,0 +1,167 @@
package main
import (
"fmt"
"image"
"image/color"
"gocv.io/x/gocv"
)
// ContourInfo tracks a detected contour and whether it was used
type ContourInfo struct {
rect image.Rectangle
used bool
}
// detectScreenWidth finds the screen width via contours (returns 0 on failure)
// If debugPath is not empty, saves an annotated image showing the detection
func detectScreenWidth(frame gocv.Mat, debugPath string) int {
gray := gocv.NewMat()
defer gray.Close()
gocv.CvtColor(frame, &gray, gocv.ColorBGRToGray)
thresh := gocv.NewMat()
defer thresh.Close()
gocv.Threshold(gray, &thresh, 170, 255, gocv.ThresholdBinary)
contours := gocv.FindContours(thresh, gocv.RetrievalExternal, gocv.ChainApproxSimple)
defer contours.Close()
if contours.Size() == 0 {
return 0
}
// Collect all contours and track which are used
var allContours []ContourInfo
screenMinX := 999999
screenMaxX := 0
for i := 0; i < contours.Size(); i++ {
rect := gocv.BoundingRect(contours.At(i))
used := rect.Dx() > 50 || rect.Dy() > 50
allContours = append(allContours, ContourInfo{rect: rect, used: used})
if used {
if rect.Min.X < screenMinX {
screenMinX = rect.Min.X
}
if rect.Max.X > screenMaxX {
screenMaxX = rect.Max.X
}
}
}
screenWidth := screenMaxX - screenMinX
if screenWidth < 100 {
return 0
}
// Save debug image if requested
if debugPath != "" {
saveScreenWidthDebug(frame, allContours, screenMinX, screenMaxX, screenWidth, debugPath)
}
return screenWidth
}
// saveScreenWidthDebug creates an annotated image showing screen width detection
func saveScreenWidthDebug(frame gocv.Mat, contours []ContourInfo, minX, maxX, width int, path string) {
debug := frame.Clone()
defer debug.Close()
// Draw all contours
for i, c := range contours {
if c.used {
// Used contours in green
green := color.RGBA{0, 255, 0, 255}
gocv.Rectangle(&debug, c.rect, green, 2)
gocv.PutText(&debug, fmt.Sprintf("#%d", i),
image.Pt(c.rect.Min.X, c.rect.Min.Y-5),
gocv.FontHersheyPlain, 1.0, green, 1)
} else {
// Unused contours in gray
gray := color.RGBA{100, 100, 100, 255}
gocv.Rectangle(&debug, c.rect, gray, 1)
}
}
// Draw vertical lines at min and max X
red := color.RGBA{255, 0, 0, 255}
gocv.Line(&debug, image.Pt(minX, 0), image.Pt(minX, debug.Rows()), red, 3)
gocv.Line(&debug, image.Pt(maxX, 0), image.Pt(maxX, debug.Rows()), red, 3)
// Add text annotations
yellow := color.RGBA{255, 255, 0, 255}
gocv.PutText(&debug, fmt.Sprintf("Screen MinX: %d", minX),
image.Pt(minX+10, 30), gocv.FontHersheyDuplex, 0.8, yellow, 2)
gocv.PutText(&debug, fmt.Sprintf("Screen MaxX: %d", maxX),
image.Pt(maxX-200, 30), gocv.FontHersheyDuplex, 0.8, yellow, 2)
gocv.PutText(&debug, fmt.Sprintf("Screen Width: %dpx", width),
image.Pt(minX+10, 60), gocv.FontHersheyDuplex, 0.8, yellow, 2)
gocv.PutText(&debug, fmt.Sprintf("Total contours: %d", len(contours)),
image.Pt(minX+10, 90), gocv.FontHersheyDuplex, 0.8, yellow, 2)
gocv.IMWrite(path, debug)
fmt.Printf(" Saved width detection debug: %s\n", path)
}
// applyScale resizes frame by the given scale factor
func applyScale(frame gocv.Mat, scale float64) gocv.Mat {
if scale == 1.0 {
return frame // No scaling needed
}
newWidth := int(float64(frame.Cols()) * scale)
newHeight := int(float64(frame.Rows()) * scale)
scaled := gocv.NewMat()
gocv.Resize(frame, &scaled, image.Pt(newWidth, newHeight), 0, 0, gocv.InterpolationLinear)
return scaled
}
// normalizeToWidth resizes frame to make it targetWidth pixels wide
func normalizeToWidth(frame gocv.Mat, targetWidth int) gocv.Mat {
if frame.Cols() == targetWidth {
return frame // Already correct size
}
scale := float64(targetWidth) / float64(frame.Cols())
newWidth := targetWidth
newHeight := int(float64(frame.Rows()) * scale)
normalized := gocv.NewMat()
gocv.Resize(frame, &normalized, image.Pt(newWidth, newHeight), 0, 0, gocv.InterpolationLinear)
return normalized
}
// detectLayoutWithNormalization performs full layout detection with screen width normalization
// Returns: layout, scale factor, error
func detectLayoutWithNormalization(frame gocv.Mat) (*ScreenLayout, float64, error) {
// 1. Detect screen width
screenWidth := detectScreenWidth(frame, "")
if screenWidth == 0 {
return nil, 0, fmt.Errorf("failed to detect screen width")
}
// 2. Calculate and apply scale
const targetWidth = 860
scale := float64(targetWidth) / float64(screenWidth)
normalized := applyScale(frame, scale)
if normalized.Ptr() != frame.Ptr() {
defer normalized.Close()
}
// 3. Detect layout on normalized frame
layout, rescaled, err := detectScreenLayoutAreas(normalized)
if !rescaled.Empty() {
rescaled.Close()
}
if err != nil {
return nil, 0, err
}
return layout, scale, nil
}

View File

@ -0,0 +1,751 @@
package main
import (
"fmt"
"image"
"image/color"
"io"
"os"
"strings"
"time"
"gocv.io/x/gocv"
)
func loadTemplates() (map[int][]gocv.Mat, error) {
templates := make(map[int][]gocv.Mat)
files, err := os.ReadDir("training_digits")
if err != nil {
logMessage(Console, Warning, "⚠️ WARNING: training_digits directory not found or cannot be read: %v", err)
logMessage(Console, Warning, "⚠️ Starting with empty template set. Recognition will not work until templates are added.")
logMessage(Console, Info, "")
return templates, nil
}
templateCount := 0
for _, file := range files {
if !strings.HasSuffix(file.Name(), ".png") {
continue
}
name := strings.TrimSuffix(file.Name(), ".png")
parts := strings.Split(name, "_")
var digit int
_, err := fmt.Sscanf(parts[0], "%d", &digit)
if err != nil || digit < 0 || digit > 9 {
continue
}
filename := fmt.Sprintf("training_digits/%s", file.Name())
template := gocv.IMRead(filename, gocv.IMReadGrayScale)
if template.Empty() {
logMessage(Console, Warning, "Warning: Failed to load %s", filename)
continue
}
templates[digit] = append(templates[digit], template)
templateCount++
}
// Load invalid digit markers (use index -1 for invalid patterns)
invalidFiles, err := os.ReadDir("training_digits/invalid")
if err == nil {
invalidCount := 0
for _, file := range invalidFiles {
if !strings.HasSuffix(file.Name(), ".png") {
continue
}
filename := fmt.Sprintf("training_digits/invalid/%s", file.Name())
template := gocv.IMRead(filename, gocv.IMReadGrayScale)
if template.Empty() {
logMessage(Console, Warning, "Warning: Failed to load invalid template %s", filename)
continue
}
templates[-1] = append(templates[-1], template)
invalidCount++
}
if invalidCount > 0 {
logMessage(Console, Info, "✓ Loaded %d invalid digit markers", invalidCount)
}
}
for digit := 0; digit <= 9; digit++ {
if len(templates[digit]) == 0 {
logMessage(Console, Warning, "⚠️ WARNING: No templates found for digit %d", digit)
}
}
logMessage(Console, Info, "✓ Loaded %d digit templates (0-9)", templateCount)
return templates, nil
}
func matchDigit(digitImg gocv.Mat, templates map[int][]gocv.Mat) (int, float64) {
bestDigit := -1
bestScore := 0.0
bestInvalidScore := 0.0
// Check invalid patterns first (stored at index -1)
invalidTemplates := templates[-1]
for _, template := range invalidTemplates {
compareImg := digitImg
if digitImg.Rows() != template.Rows() || digitImg.Cols() != template.Cols() {
resized := gocv.NewMat()
gocv.Resize(digitImg, &resized, image.Pt(template.Cols(), template.Rows()), 0, 0, gocv.InterpolationLinear)
defer resized.Close()
compareImg = resized
}
diff := gocv.NewMat()
gocv.AbsDiff(compareImg, template, &diff)
totalPixels := compareImg.Rows() * compareImg.Cols()
diffPixels := gocv.CountNonZero(diff)
samePixels := totalPixels - diffPixels
score := (float64(samePixels) / float64(totalPixels)) * 100.0
diff.Close()
if score > bestInvalidScore {
bestInvalidScore = score
}
}
// Check regular digits (0-9)
for digit := 0; digit <= 9; digit++ {
digitTemplates := templates[digit]
if len(digitTemplates) == 0 {
// No templates for this digit, skip
continue
}
for _, template := range digitTemplates {
compareImg := digitImg
if digitImg.Rows() != template.Rows() || digitImg.Cols() != template.Cols() {
resized := gocv.NewMat()
gocv.Resize(digitImg, &resized, image.Pt(template.Cols(), template.Rows()), 0, 0, gocv.InterpolationLinear)
defer resized.Close()
compareImg = resized
}
diff := gocv.NewMat()
gocv.AbsDiff(compareImg, template, &diff)
totalPixels := compareImg.Rows() * compareImg.Cols()
diffPixels := gocv.CountNonZero(diff)
samePixels := totalPixels - diffPixels
score := (float64(samePixels) / float64(totalPixels)) * 100.0
diff.Close()
if score > bestScore {
bestScore = score
bestDigit = digit
}
}
}
// If invalid pattern matches better than any digit (and score > 70%), return -1
if bestInvalidScore > bestScore && bestInvalidScore > 70 {
return -1, bestInvalidScore
}
return bestDigit, bestScore
}
// Helper function: check if there's a '1' digit by cutting at the expected width and matching templates
// ONLY checks against '1' templates to prevent false matches with '4', '7', etc.
func hasOneAt(thresh gocv.Mat, x int, templates map[int][]gocv.Mat, logger io.Writer) bool {
h := thresh.Rows()
w := thresh.Cols()
// Calculate the region where a '1' would be if it exists
// '1' is 72px wide, so extract from (x - 72) to x
leftEdge := x - DIGIT_ONE_WIDTH
if leftEdge < 0 {
leftEdge = 0
}
if x > w {
x = w
}
logMessage(Both, Debug, " hasOneAt(x=%d): extracting region [%d..%d] (width=%d) from display width=%d",
x, leftEdge, x, x-leftEdge, w)
// Extract the potential '1' region
region := thresh.Region(image.Rect(leftEdge, 0, x, h))
digitImg := region.Clone()
region.Close()
// Match ONLY against '1' templates (don't check other digits)
oneTemplates := templates[1]
if len(oneTemplates) == 0 {
logMessage(LogFile, Warning, " hasOneAt(x=%d): No '1' templates loaded -> NO", x)
digitImg.Close()
return false
}
bestScore := 0.0
for _, template := range oneTemplates {
compareImg := digitImg
if digitImg.Rows() != template.Rows() || digitImg.Cols() != template.Cols() {
resized := gocv.NewMat()
gocv.Resize(digitImg, &resized, image.Pt(template.Cols(), template.Rows()), 0, 0, gocv.InterpolationLinear)
defer resized.Close()
compareImg = resized
}
diff := gocv.NewMat()
gocv.AbsDiff(compareImg, template, &diff)
totalPixels := compareImg.Rows() * compareImg.Cols()
diffPixels := gocv.CountNonZero(diff)
samePixels := totalPixels - diffPixels
score := (float64(samePixels) / float64(totalPixels)) * 100.0
diff.Close()
if score > bestScore {
bestScore = score
}
}
digitImg.Close()
// If it matches '1' with >85% confidence, we have a '1'
if bestScore > 85.0 {
logMessage(Both, Debug, " hasOneAt(x=%d): matched '1' with %.1f%% confidence -> YES", x, bestScore)
return true
}
logMessage(Both, Debug, " hasOneAt(x=%d): best '1' match %.1f%% confidence -> NO", x, bestScore)
return false
}
// validateEight checks if a digit image has the two characteristic holes of an '8'
// by checking for black pixels along horizontal lines where holes should be
func validateEight(digitImg gocv.Mat, logger io.Writer) bool {
h := digitImg.Rows()
w := digitImg.Cols()
// Upper hole: 30% of height
// Lower hole: 70% of height
// Horizontal range: 40%-50% of width
topHoleY := (h * 30) / 100
bottomHoleY := (h * 70) / 100
xStart := (w * 40) / 100
xEnd := (w * 50) / 100
// Check for ANY black pixel in the horizontal range
// In thresholded image: black (0) = hole, white (255) = digit
topHoleFound := false
for x := xStart; x <= xEnd; x++ {
pixel := digitImg.GetUCharAt(topHoleY, x)
if pixel < 128 {
topHoleFound = true
break
}
}
bottomHoleFound := false
for x := xStart; x <= xEnd; x++ {
pixel := digitImg.GetUCharAt(bottomHoleY, x)
if pixel < 128 {
bottomHoleFound = true
break
}
}
logMessage(LogFile, Debug, " validateEight: top hole @ y=%d (30%%) x=%d-%d (%v), bottom hole @ y=%d (70%%) x=%d-%d (%v)",
topHoleY, xStart, xEnd, topHoleFound,
bottomHoleY, xStart, xEnd, bottomHoleFound)
return topHoleFound && bottomHoleFound
}
// validateZero checks if a digit image has the characteristic hole of a '0'
// by checking for black pixels along a horizontal line at the center
func validateZero(digitImg gocv.Mat, logger io.Writer) bool {
h := digitImg.Rows()
w := digitImg.Cols()
// Center hole: 50% of height
// Horizontal range: 30%-70% of width (wider range than '8' since '0' hole is larger)
centerY := h / 2
xStart := (w * 30) / 100
xEnd := (w * 70) / 100
// Check for ANY black pixel in the horizontal range
// In thresholded image: black (0) = hole, white (255) = digit
holeFound := false
for x := xStart; x <= xEnd; x++ {
pixel := digitImg.GetUCharAt(centerY, x)
if pixel < 128 {
holeFound = true
break
}
}
logMessage(LogFile, Debug, " validateZero: center hole @ y=%d (50%%) x=%d-%d (%v)",
centerY, xStart, xEnd, holeFound)
return holeFound
}
// decolorizeAlarmMode converts colored alarm backgrounds (yellow/orange, cyan/blue) to black
// while preserving white digits, so normal OCR thresholding works correctly
func decolorizeAlarmMode(displayImg gocv.Mat) gocv.Mat {
// Create output image
decolorized := gocv.NewMatWithSize(displayImg.Rows(), displayImg.Cols(), displayImg.Type())
// Process each pixel
for y := 0; y < displayImg.Rows(); y++ {
for x := 0; x < displayImg.Cols(); x++ {
// Get BGR values
b := displayImg.GetUCharAt(y, x*3) // Blue
g := displayImg.GetUCharAt(y, x*3+1) // Green
r := displayImg.GetUCharAt(y, x*3+2) // Red
// If pixel is "white-ish" (all channels > 240), keep it white
// Otherwise (colored background, borders, etc.), set to black
if r > 240 && g > 240 && b > 240 {
// White digit - keep as white
decolorized.SetUCharAt(y, x*3, 255)
decolorized.SetUCharAt(y, x*3+1, 255)
decolorized.SetUCharAt(y, x*3+2, 255)
} else {
// Colored background or dark area - set to black
decolorized.SetUCharAt(y, x*3, 0)
decolorized.SetUCharAt(y, x*3+1, 0)
decolorized.SetUCharAt(y, x*3+2, 0)
}
}
}
return decolorized
}
// New simplified version that works with fixed-width rectangles
func recognizeDisplayArea(rotated gocv.Mat, area image.Rectangle, templates map[int][]gocv.Mat, displayName string, frameCount int, logger io.Writer) (int, int, float64, int, float64) {
startTime := time.Now()
cleanName := strings.TrimSpace(strings.ToLower(displayName))
// Extract the display area (ALREADY THRESHOLDED BINARY IMAGE)
region := rotated.Region(area)
thresh := region.Clone()
region.Close()
// DEBUG: Save extracted region - already thresholded
if DEBUG_MODE {
step1Filename := fmt.Sprintf("review/f%d_%s_step1_extracted.png", frameCount, cleanName)
gocv.IMWrite(step1Filename, thresh)
}
w := thresh.Cols()
h := thresh.Rows()
// Log display dimensions
logMessage(LogFile, Debug, " %s display: w=%d, h=%d", displayName, w, h)
// Sequential check for '1' patterns to determine digit widths
// hasOneAt checks if column X is white and column X+10 is black (detects RIGHT EDGE where '1' ends)
// Digit detection (right to left: digit3, digit2, digit1)
// D stores cut positions, digitIsOne stores detection results
D := make([]int, 4) // indices 1,2,3 (0 unused)
digitIsOne := make([]bool, 4) // indices 1,2,3 (0 unused)
totalWidth := 0
logMessage(Both, Debug, " [%s] Display width=%d, starting digit detection...", displayName, w)
detectionStart := time.Now()
for i := 3; i >= 1; i-- {
var checkPos int
if i == 3 {
checkPos = w // Check at right edge, not w-8
} else {
checkPos = w - totalWidth - 5
}
logMessage(Both, Debug, " [%s] Digit %d: checkPos=%d (w=%d, totalWidth=%d)", displayName, i, checkPos, w, totalWidth)
digitIsOne[i] = hasOneAt(thresh, checkPos, templates, logger)
// Add width to accumulator
var widthUsed int
if digitIsOne[i] {
widthUsed = DIGIT_ONE_WIDTH
totalWidth += DIGIT_ONE_WIDTH
logMessage(Both, Debug, " [%s] Digit %d: IS ONE -> using width=%d (totalWidth now %d)", displayName, i, widthUsed, totalWidth)
} else {
widthUsed = DIGIT_NON_ONE_WIDTH
totalWidth += DIGIT_NON_ONE_WIDTH
logMessage(Both, Debug, " [%s] Digit %d: NOT ONE -> using width=%d (totalWidth now %d)", displayName, i, widthUsed, totalWidth)
}
D[i] = w - totalWidth
logMessage(Both, Debug, " [%s] Digit %d: CUT position D[%d]=%d", displayName, i, i, D[i])
}
logMessage(LogFile, Debug, " [TIMING][%s] Digit detection (hasOneAt x3): %dms", displayName, time.Since(detectionStart).Milliseconds())
logMessage(Both, Debug, " [%s] Final cut positions: D[1]=%d, D[2]=%d, D[3]=%d", displayName, D[1], D[2], D[3])
// Create visualization image showing check positions AND cut positions
visImg := gocv.NewMat()
gocv.CvtColor(thresh, &visImg, gocv.ColorGrayToBGR)
// Draw vertical lines at check positions with labels
greenColor := color.RGBA{0, 255, 0, 255} // Green for '1' detected
redColor := color.RGBA{0, 0, 255, 255} // Red for no '1'
yellowColor := color.RGBA{0, 255, 255, 255} // Yellow for CUT lines
// Digit 3 check line
digit3Color := redColor
if digitIsOne[3] {
digit3Color = greenColor
}
gocv.Line(&visImg, image.Pt(w-8, 0), image.Pt(w-8, h), digit3Color, 2)
gocv.PutText(&visImg, "D3", image.Pt(w-8+5, 20), gocv.FontHersheyDuplex, 0.5, digit3Color, 1)
// Digit 2 check line (calculate position from accumulated width)
var digit2Width int
if digitIsOne[3] {
digit2Width = DIGIT_ONE_WIDTH
} else {
digit2Width = DIGIT_NON_ONE_WIDTH
}
digit2CheckPos := w - digit2Width - 5
digit2Color := redColor
if digitIsOne[2] {
digit2Color = greenColor
}
gocv.Line(&visImg, image.Pt(digit2CheckPos, 0), image.Pt(digit2CheckPos, h), digit2Color, 2)
gocv.PutText(&visImg, "D2", image.Pt(digit2CheckPos+5, 40), gocv.FontHersheyDuplex, 0.5, digit2Color, 1)
// Digit 1 check line (calculate position from accumulated width)
var digit3Width int
if digitIsOne[3] {
digit3Width = DIGIT_ONE_WIDTH
} else {
digit3Width = DIGIT_NON_ONE_WIDTH
}
var digit2WidthForD1 int
if digitIsOne[2] {
digit2WidthForD1 = DIGIT_ONE_WIDTH
} else {
digit2WidthForD1 = DIGIT_NON_ONE_WIDTH
}
digit1CheckPos := w - digit3Width - digit2WidthForD1 - 5
digit1Color := redColor
if digitIsOne[1] {
digit1Color = greenColor
}
gocv.Line(&visImg, image.Pt(digit1CheckPos, 0), image.Pt(digit1CheckPos, h), digit1Color, 2)
gocv.PutText(&visImg, "D1", image.Pt(digit1CheckPos+5, 60), gocv.FontHersheyDuplex, 0.5, digit1Color, 1)
// Draw CUT lines in yellow showing where we actually split the digits
gocv.Line(&visImg, image.Pt(D[2], 0), image.Pt(D[2], h), yellowColor, 3)
gocv.PutText(&visImg, "CUT1", image.Pt(D[2]+5, h-10), gocv.FontHersheyDuplex, 0.6, yellowColor, 2)
gocv.Line(&visImg, image.Pt(D[3], 0), image.Pt(D[3], h), yellowColor, 3)
gocv.PutText(&visImg, "CUT2", image.Pt(D[3]+5, h-30), gocv.FontHersheyDuplex, 0.6, yellowColor, 2)
// Save visualization
if DEBUG_MODE {
visFilename := fmt.Sprintf("review/f%d_%s_checks.png", frameCount, cleanName)
gocv.IMWrite(visFilename, visImg)
}
visImg.Close()
// Clamp cut positions to valid range
if D[3] < 0 {
D[3] = 0
}
if D[2] < 0 {
D[2] = 0
}
// Extract three digit regions
logMessage(Both, Debug, " [%s] Extracting digit1: region [0..%d] (width=%d)", displayName, D[2], D[2])
digit1Region := thresh.Region(image.Rect(0, 0, D[2], h))
digit1 := digit1Region.Clone()
digit1Region.Close()
logMessage(Both, Debug, " [%s] Extracting digit2: region [%d..%d] (width=%d)", displayName, D[2], D[3], D[3]-D[2])
digit2Region := thresh.Region(image.Rect(D[2], 0, D[3], h))
digit2 := digit2Region.Clone()
digit2Region.Close()
logMessage(Both, Debug, " [%s] Extracting digit3: region [%d..%d] (width=%d)", displayName, D[3], w, w-D[3])
digit3Region := thresh.Region(image.Rect(D[3], 0, w, h))
digit3 := digit3Region.Clone()
digit3Region.Close()
// Match all three digits
matchStart := time.Now()
num1, score1 := matchDigit(digit1, templates)
num2, score2 := matchDigit(digit2, templates)
num3, score3 := matchDigit(digit3, templates)
logMessage(LogFile, Debug, " [TIMING][%s] matchDigit x3: %dms", displayName, time.Since(matchStart).Milliseconds())
// Validate '8' digits - check for two holes
if num1 == 8 && !validateEight(digit1, logger) {
logMessage(LogFile, Debug, " ⚠️ Digit1 recognized as 8 but failed hole validation - marking invalid")
num1 = -1
score1 = 0.0
}
if num2 == 8 && !validateEight(digit2, logger) {
logMessage(LogFile, Debug, " ⚠️ Digit2 recognized as 8 but failed hole validation - marking invalid")
num2 = -1
score2 = 0.0
}
if num3 == 8 && !validateEight(digit3, logger) {
logMessage(LogFile, Debug, " ⚠️ Digit3 recognized as 8 but failed hole validation - marking invalid")
num3 = -1
score3 = 0.0
}
// Validate '0' digits - check for center hole
if num1 == 0 && !validateZero(digit1, logger) {
logMessage(LogFile, Debug, " ⚠️ Digit1 recognized as 0 but failed hole validation - marking invalid")
num1 = -1
score1 = 0.0
}
if num2 == 0 && !validateZero(digit2, logger) {
logMessage(LogFile, Debug, " ⚠️ Digit2 recognized as 0 but failed hole validation - marking invalid")
num2 = -1
score2 = 0.0
}
if num3 == 0 && !validateZero(digit3, logger) {
logMessage(LogFile, Debug, " ⚠️ Digit3 recognized as 0 but failed hole validation - marking invalid")
num3 = -1
score3 = 0.0
}
// Calculate final number
var number int
if digitIsOne[1] {
// 3-digit number: 1XX
number = 100 + num2*10 + num3
} else {
// 2-digit number: XX (digit1 region is mostly empty/padding)
number = num2*10 + num3
}
// Save individual digits
if DEBUG_MODE {
saveStart := time.Now()
digit1Filename := fmt.Sprintf("review/f%d_%s_digit1.png", frameCount, cleanName)
digit2Filename := fmt.Sprintf("review/f%d_%s_digit2.png", frameCount, cleanName)
digit3Filename := fmt.Sprintf("review/f%d_%s_digit3.png", frameCount, cleanName)
gocv.IMWrite(digit1Filename, digit1)
gocv.IMWrite(digit2Filename, digit2)
gocv.IMWrite(digit3Filename, digit3)
logMessage(LogFile, Debug, " [TIMING][%s] Save digit images: %dms", displayName, time.Since(saveStart).Milliseconds())
}
// Create labeled full image (combine all three digits)
combinedWidth := digit1.Cols() + digit2.Cols() + digit3.Cols()
combinedHeight := digit1.Rows()
if digit2.Rows() > combinedHeight {
combinedHeight = digit2.Rows()
}
if digit3.Rows() > combinedHeight {
combinedHeight = digit3.Rows()
}
fullForLabel := gocv.NewMatWithSize(combinedHeight, combinedWidth, digit1.Type())
// Copy digit1
digit1ROI := fullForLabel.Region(image.Rect(0, 0, digit1.Cols(), digit1.Rows()))
digit1.CopyTo(&digit1ROI)
digit1ROI.Close()
// Copy digit2
digit2ROI := fullForLabel.Region(image.Rect(digit1.Cols(), 0, digit1.Cols()+digit2.Cols(), digit2.Rows()))
digit2.CopyTo(&digit2ROI)
digit2ROI.Close()
// Copy digit3
digit3ROI := fullForLabel.Region(image.Rect(digit1.Cols()+digit2.Cols(), 0, combinedWidth, digit3.Rows()))
digit3.CopyTo(&digit3ROI)
digit3ROI.Close()
// Add padding at top for label
labeledFull := gocv.NewMat()
gocv.CopyMakeBorder(fullForLabel, &labeledFull, 60, 0, 0, 0, gocv.BorderConstant, color.RGBA{0, 0, 0, 255})
fullForLabel.Close()
// Draw label
label := fmt.Sprintf("%d", number)
textColor := color.RGBA{255, 255, 255, 255}
gocv.PutText(&labeledFull, label, image.Pt(10, 45), gocv.FontHersheyDuplex, 1.8, textColor, 3)
// Save labeled full image
if DEBUG_MODE {
fullFilename := fmt.Sprintf("review/f%d_%s_full.png", frameCount, cleanName)
gocv.IMWrite(fullFilename, labeledFull)
}
labeledFull.Close()
digit1.Close()
digit2.Close()
digit3.Close()
thresh.Close()
// Calculate widths for logging
digit3Width = DIGIT_ONE_WIDTH
if !digitIsOne[3] {
digit3Width = DIGIT_NON_ONE_WIDTH
}
digit2Width = DIGIT_ONE_WIDTH
if !digitIsOne[2] {
digit2Width = DIGIT_NON_ONE_WIDTH
}
// Print debug info to log only
if digitIsOne[1] {
logMessage(LogFile, Info, " %s: 1%d%d [3-DIGIT: d1=1@%dpx (x=0-%d), d2=%s@%dpx (x=%d-%d), d3=%s@%dpx (x=%d-%d)] (scores: %d=%.0f%%, %d=%.0f%%, %d=%.0f%%)",
displayName, num2, num3, D[2], D[2],
map[bool]string{true: "1", false: "X"}[digitIsOne[2]], digit2Width, D[2], D[3],
map[bool]string{true: "1", false: "X"}[digitIsOne[3]], digit3Width, D[3], w,
num1, score1, num2, score2, num3, score3)
} else {
logMessage(LogFile, Info, " %s: %d%d [2-DIGIT: d2=%s@%dpx (x=%d-%d), d3=%s@%dpx (x=%d-%d)] (scores: %d=%.0f%%, %d=%.0f%%)",
displayName, num2, num3,
map[bool]string{true: "1", false: "X"}[digitIsOne[2]], digit2Width, D[2], D[3],
map[bool]string{true: "1", false: "X"}[digitIsOne[3]], digit3Width, D[3], w,
num2, score2, num3, score3)
}
logMessage(LogFile, Debug, " [TIMING][%s] Total recognizeDisplayArea: %dms", displayName, time.Since(startTime).Milliseconds())
// Return using middle two digits for compatibility with existing code
return number, num2, score2, num3, score3
}
// Old version - kept for backward compatibility
func recognizeDisplay(rotated gocv.Mat, display Display, templates map[int][]gocv.Mat, displayName string, frameCount int) (int, int, float64, int, float64) {
cleanName := strings.TrimSpace(strings.ToLower(displayName))
var leftDigit, rightDigit gocv.Mat
var isThreeDigit bool
if display.IsSplit {
// Single contour - split in half
region := rotated.Region(display.FullRect)
displayImg := region.Clone()
region.Close()
gray := gocv.NewMat()
gocv.CvtColor(displayImg, &gray, gocv.ColorBGRToGray)
displayImg.Close()
thresh := gocv.NewMat()
gocv.Threshold(gray, &thresh, 128, 255, gocv.ThresholdBinary)
gray.Close()
w := thresh.Cols()
h := thresh.Rows()
// Check if this is a 3-digit display (significantly wider)
aspectRatio := float64(w) / float64(h)
if aspectRatio > 1.55 { // 3-digit displays are much wider
// 3-digit display: assume first digit is 1, crop it out
isThreeDigit = true
// Cut off left ~8% (the narrow "1" digit)
cutPoint := w / 12 // Approximately 8.3%
croppedRegion := thresh.Region(image.Rect(cutPoint, 0, w, h))
cropped := croppedRegion.Clone()
croppedRegion.Close()
thresh.Close()
// Now split the remaining 2 digits
w2 := cropped.Cols()
h2 := cropped.Rows()
mid := w2 / 2
leftRegion := cropped.Region(image.Rect(0, 0, mid, h2))
leftDigit = leftRegion.Clone()
leftRegion.Close()
rightRegion := cropped.Region(image.Rect(mid, 0, w2, h2))
rightDigit = rightRegion.Clone()
rightRegion.Close()
cropped.Close()
} else {
// Normal 2-digit display
isThreeDigit = false
mid := w / 2
leftRegion := thresh.Region(image.Rect(0, 0, mid, h))
leftDigit = leftRegion.Clone()
leftRegion.Close()
rightRegion := thresh.Region(image.Rect(mid, 0, w, h))
rightDigit = rightRegion.Clone()
rightRegion.Close()
thresh.Close()
}
} else {
// Two separate contours - use directly
leftRegion := rotated.Region(display.LeftRect)
leftImg := leftRegion.Clone()
leftRegion.Close()
leftGray := gocv.NewMat()
gocv.CvtColor(leftImg, &leftGray, gocv.ColorBGRToGray)
leftImg.Close()
leftDigit = gocv.NewMat()
gocv.Threshold(leftGray, &leftDigit, 128, 255, gocv.ThresholdBinary)
leftGray.Close()
rightRegion := rotated.Region(display.RightRect)
rightImg := rightRegion.Clone()
rightRegion.Close()
rightGray := gocv.NewMat()
gocv.CvtColor(rightImg, &rightGray, gocv.ColorBGRToGray)
rightImg.Close()
rightDigit = gocv.NewMat()
gocv.Threshold(rightGray, &rightDigit, 128, 255, gocv.ThresholdBinary)
rightGray.Close()
}
leftFilename := fmt.Sprintf("review/f%d_%s_left.png", frameCount, cleanName)
rightFilename := fmt.Sprintf("review/f%d_%s_right.png", frameCount, cleanName)
gocv.IMWrite(leftFilename, leftDigit)
gocv.IMWrite(rightFilename, rightDigit)
leftNum, leftScore := matchDigit(leftDigit, templates)
rightNum, rightScore := matchDigit(rightDigit, templates)
var number int
if isThreeDigit {
number = 100 + leftNum*10 + rightNum
} else {
number = leftNum*10 + rightNum
}
// Create labeled full image with recognized value at top
var fullForLabel gocv.Mat
if display.IsSplit {
// Recreate thresh for SPLIT mode
region := rotated.Region(display.FullRect)
displayImg := region.Clone()
region.Close()
gray := gocv.NewMat()
gocv.CvtColor(displayImg, &gray, gocv.ColorBGRToGray)
displayImg.Close()
fullForLabel = gocv.NewMat()
gocv.Threshold(gray, &fullForLabel, 128, 255, gocv.ThresholdBinary)
gray.Close()
} else {
// Combine left and right for DUAL mode
combinedWidth := leftDigit.Cols() + rightDigit.Cols()
combinedHeight := leftDigit.Rows()
if rightDigit.Rows() > combinedHeight {
combinedHeight = rightDigit.Rows()
}
fullForLabel = gocv.NewMatWithSize(combinedHeight, combinedWidth, leftDigit.Type())
leftROI := fullForLabel.Region(image.Rect(0, 0, leftDigit.Cols(), leftDigit.Rows()))
leftDigit.CopyTo(&leftROI)
leftROI.Close()
rightROI := fullForLabel.Region(image.Rect(leftDigit.Cols(), 0, combinedWidth, rightDigit.Rows()))
rightDigit.CopyTo(&rightROI)
rightROI.Close()
}
// Add padding at top for label
labeledFull := gocv.NewMat()
gocv.CopyMakeBorder(fullForLabel, &labeledFull, 60, 0, 0, 0, gocv.BorderConstant, color.RGBA{0, 0, 0, 255})
fullForLabel.Close()
// Draw label with larger font
label := fmt.Sprintf("%d", number)
textColor := color.RGBA{255, 255, 255, 255}
gocv.PutText(&labeledFull, label, image.Pt(10, 45), gocv.FontHersheyDuplex, 1.8, textColor, 3)
// Save labeled full image
fullFilename := fmt.Sprintf("review/f%d_%s_full.png", frameCount, cleanName)
gocv.IMWrite(fullFilename, labeledFull)
labeledFull.Close()
leftDigit.Close()
rightDigit.Close()
// Note: This old function is kept for backward compatibility but should use logger if needed
// For now, keeping console output since this isn't actively used
return number, leftNum, leftScore, rightNum, rightScore
}

View File

@ -0,0 +1,930 @@
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")
}
}

View File

@ -0,0 +1,530 @@
package main
import (
"fmt"
"image"
"image/color"
"math"
"sort"
"gocv.io/x/gocv"
)
// Global DEBUG flag is declared in test_rotation.go
// Band represents a vertical band of contours
type Band struct {
minX int
maxX int
minY int
maxY int
}
// DetectBands finds and groups contours into vertical bands
// Only returns bands with height >= minHeight pixels
func DetectBands(binary gocv.Mat, minHeight int) []Band {
type Box struct {
minX int
maxX int
minY int
maxY int
}
// Find contours
contours := gocv.FindContours(binary, gocv.RetrievalExternal, gocv.ChainApproxSimple)
// Get all bounding boxes
boxes := []Box{}
for i := 0; i < contours.Size(); i++ {
rect := gocv.BoundingRect(contours.At(i))
boxes = append(boxes, Box{
minX: rect.Min.X,
maxX: rect.Max.X,
minY: rect.Min.Y,
maxY: rect.Max.Y,
})
}
// Sort boxes by minY
sort.Slice(boxes, func(i, j int) bool {
return boxes[i].minY < boxes[j].minY
})
// Group overlapping/adjacent boxes into vertical bands
bands := []Band{}
for _, box := range boxes {
// Skip boxes shorter than minHeight
height := box.maxY - box.minY
if height < minHeight {
continue
}
if len(bands) == 0 {
// First band
bands = append(bands, Band{
minX: box.minX,
maxX: box.maxX,
minY: box.minY,
maxY: box.maxY,
})
} else {
lastBand := &bands[len(bands)-1]
// Check if this box overlaps or is close to last band (within 5px)
if box.minY <= lastBand.maxY+5 {
// Merge: extend the band in both X and Y
if box.minX < lastBand.minX {
lastBand.minX = box.minX
}
if box.maxX > lastBand.maxX {
lastBand.maxX = box.maxX
}
if box.maxY > lastBand.maxY {
lastBand.maxY = box.maxY
}
} else {
// New band
bands = append(bands, Band{
minX: box.minX,
maxX: box.maxX,
minY: box.minY,
maxY: box.maxY,
})
}
}
}
return bands
}
// findBaseline analyzes the baseline in the graph band
// Returns left point, right point, width, number of sections, and error
func findBaseline(binary gocv.Mat, graphBand Band) (*image.Point, *image.Point, int, int, error) {
// Extract just the graph band region
graphRegion := binary.Region(image.Rect(0, graphBand.minY, binary.Cols(), graphBand.maxY+1))
// Find all contours in graph region
contours := gocv.FindContours(graphRegion, gocv.RetrievalExternal, gocv.ChainApproxSimple)
// Get sections (contours) with width >= 5px
type Section struct {
minX int
maxX int
minY int
maxY int
}
sections := []Section{}
for i := 0; i < contours.Size(); i++ {
rect := gocv.BoundingRect(contours.At(i))
width := rect.Max.X - rect.Min.X
height := rect.Max.Y - rect.Min.Y
// Filter: width >= 5px AND height >= 80px
if width >= 5 && height >= 80 {
sections = append(sections, Section{
minX: rect.Min.X,
maxX: rect.Max.X,
minY: rect.Min.Y + graphBand.minY, // Adjust to full image coordinates
maxY: rect.Max.Y + graphBand.minY,
})
}
}
// Check if we have exactly 2 sections
if len(sections) != 2 {
return nil, nil, 0, len(sections), fmt.Errorf("found %d sections (need exactly 2)", len(sections))
}
// Sort sections by X position (left to right)
sort.Slice(sections, func(i, j int) bool {
return sections[i].minX < sections[j].minX
})
// Horizontal width (left edge of left section to right edge of right section)
horizontalWidth := sections[1].maxX - sections[0].minX
// Find rotation points
// Left section: lowest white pixel 3px from left edge
leftSection := sections[0]
leftX := leftSection.minX + 3
leftPoint := findLowestWhitePixel(binary, leftX, graphBand.minY, graphBand.maxY)
// Right section: lowest white pixel 3px from right edge
rightSection := sections[1]
rightX := rightSection.maxX - 3
rightPoint := findLowestWhitePixel(binary, rightX, graphBand.minY, graphBand.maxY)
if leftPoint == nil || rightPoint == nil {
return nil, nil, 0, len(sections), fmt.Errorf("could not find rotation endpoints")
}
return leftPoint, rightPoint, horizontalWidth, len(sections), nil
}
// findLowestWhitePixel finds the lowest (max Y) white pixel at given X within Y range
func findLowestWhitePixel(binary gocv.Mat, x int, minY int, maxY int) *image.Point {
lowestY := -1
for y := minY; y <= maxY; y++ {
if binary.GetUCharAt(y, x) == 255 {
lowestY = y
}
}
if lowestY == -1 {
return nil
}
return &image.Point{X: x, Y: lowestY}
}
// DetectionResult contains rotation and width detection results
type DetectionResult struct {
Rotation float64
Width int
Success bool
}
// PreprocessFrame takes a raw frame and prepares it for detection
// Returns the processed binary image
func PreprocessFrame(frame gocv.Mat) gocv.Mat {
// Crop top 68 pixels (timestamp)
cropped := frame.Region(image.Rect(0, 68, frame.Cols(), frame.Rows()))
// Rotate 90° clockwise
rotated := gocv.NewMat()
gocv.Rotate(cropped, &rotated, gocv.Rotate90Clockwise)
// Convert to grayscale
gray := gocv.NewMat()
gocv.CvtColor(rotated, &gray, gocv.ColorBGRToGray)
rotated.Close()
// Threshold to binary
binary := gocv.NewMat()
gocv.Threshold(gray, &binary, 170, 255, gocv.ThresholdBinary)
gray.Close()
return binary
}
// DetectRotationAndWidth analyzes a binary frame and returns rotation angle and width
func DetectRotationAndWidth(binary gocv.Mat) DetectionResult {
// Detect bands (using 80px minimum height for original scale)
bands := DetectBands(binary, 80)
// Check if we have enough bands
if len(bands) < 2 {
if DEBUG {
fmt.Printf("DEBUG: Detection failed - only %d bands found (need at least 2)\n", len(bands))
}
return DetectionResult{Success: false}
}
// Find TALLEST band (graph/waveform display)
tallestIdx := 0
tallestHeight := 0
for i, band := range bands {
height := band.maxY - band.minY
if height > tallestHeight {
tallestHeight = height
tallestIdx = i
}
}
// Check if there's a band after the tallest (for digits)
if tallestIdx >= len(bands)-1 {
if DEBUG {
fmt.Printf("DEBUG: Detection failed - tallest band is last (idx=%d, total=%d)\n", tallestIdx, len(bands))
}
return DetectionResult{Success: false}
}
graphBand := bands[tallestIdx] // Tallest = graph/waveform
// Find baseline
leftPoint, rightPoint, width, sections, err := findBaseline(binary, graphBand)
if err != nil || sections != 2 {
if DEBUG {
if err != nil {
fmt.Printf("DEBUG: Detection failed - baseline error: %v\n", err)
} else {
fmt.Printf("DEBUG: Detection failed - found %d sections (need exactly 2)\n", sections)
}
}
return DetectionResult{Success: false}
}
// Calculate rotation angle
deltaY := float64(rightPoint.Y - leftPoint.Y)
deltaX := float64(rightPoint.X - leftPoint.X)
rotation := math.Atan2(deltaY, deltaX) * 180 / math.Pi
return DetectionResult{
Rotation: rotation,
Width: width,
Success: true,
}
}
// VisualizeBands draws bands on an image with labels (only in DEBUG mode)
func VisualizeBands(binary gocv.Mat, bands []Band, tallestIdx int, filename string) {
if !DEBUG {
return
}
viz := gocv.NewMat()
gocv.CvtColor(binary, &viz, gocv.ColorGrayToBGR)
defer viz.Close()
red := color.RGBA{R: 255, G: 0, B: 0, A: 255}
green := color.RGBA{R: 0, G: 255, B: 0, A: 255}
blue := color.RGBA{R: 0, G: 0, B: 255, A: 255}
yellow := color.RGBA{R: 255, G: 255, B: 0, A: 255}
// Draw all bands
for i, band := range bands {
bandColor := red
if i == tallestIdx {
bandColor = green // Graph band in green
} else if tallestIdx < len(bands)-1 && i == tallestIdx+1 {
bandColor = blue // Numbers band in blue
}
rect := image.Rect(0, band.minY, viz.Cols(), band.maxY)
gocv.Rectangle(&viz, rect, bandColor, 2)
label := fmt.Sprintf("Band #%d", i+1)
gocv.PutText(&viz, label, image.Pt(10, band.minY+20),
gocv.FontHersheyPlain, 1.5, yellow, 2)
}
gocv.IMWrite(filename, viz)
fmt.Printf("DEBUG: Saved visualization to %s\n", filename)
}
// VisualizeDigitBand highlights the digit band specifically
func VisualizeDigitBand(binary gocv.Mat, bands []Band, tallestIdx int, filename string) {
if !DEBUG || tallestIdx >= len(bands)-1 {
return
}
viz := gocv.NewMat()
gocv.CvtColor(binary, &viz, gocv.ColorGrayToBGR)
defer viz.Close()
red := color.RGBA{R: 255, G: 0, B: 0, A: 255}
green := color.RGBA{R: 0, G: 255, B: 0, A: 255}
blue := color.RGBA{R: 0, G: 0, B: 255, A: 255}
yellow := color.RGBA{R: 255, G: 255, B: 0, A: 255}
cyan := color.RGBA{R: 0, G: 255, B: 255, A: 255}
// Draw all bands with specific highlighting
for i, band := range bands {
bandColor := red
lineThickness := 1
labelText := fmt.Sprintf("Band #%d", i+1)
if i == tallestIdx {
bandColor = green
labelText = "GRAPH BAND"
} else if i == tallestIdx+1 {
// DIGIT BAND - highlight strongly
bandColor = cyan
lineThickness = 3
labelText = fmt.Sprintf("DIGIT BAND [Y: %d-%d]", band.minY, band.maxY)
}
rect := image.Rect(0, band.minY, viz.Cols(), band.maxY)
gocv.Rectangle(&viz, rect, bandColor, lineThickness)
// Add label
labelColor := yellow
if i == tallestIdx+1 {
labelColor = cyan
}
gocv.PutText(&viz, labelText, image.Pt(10, band.minY+20),
gocv.FontHersheyPlain, 1.5, labelColor, 2)
}
// Draw additional markers for digit band
if tallestIdx < len(bands)-1 {
digitBand := bands[tallestIdx+1]
// Draw vertical lines at digit band boundaries
for x := 50; x < viz.Cols(); x += 100 {
gocv.Line(&viz,
image.Pt(x, digitBand.minY),
image.Pt(x, digitBand.maxY),
blue, 1)
}
// Add dimension text
height := digitBand.maxY - digitBand.minY
dimText := fmt.Sprintf("Height: %dpx, Width: 860px", height)
gocv.PutText(&viz, dimText, image.Pt(10, digitBand.maxY+30),
gocv.FontHersheyPlain, 1.2, cyan, 2)
}
gocv.IMWrite(filename, viz)
fmt.Printf("DEBUG: Saved digit band visualization to %s\n", filename)
}
// ValidateBaselineTwoSegments checks if the baseline consists of two separate segments
// by counting black-to-white transitions along the baseline (should be exactly 1)
func ValidateBaselineTwoSegments(binary gocv.Mat, leftPoint, rightPoint *image.Point) bool {
// Calculate slope for Y interpolation along the baseline
deltaY := float64(rightPoint.Y - leftPoint.Y)
deltaX := float64(rightPoint.X - leftPoint.X)
slope := deltaY / deltaX
if DEBUG {
fmt.Printf("DEBUG: Validating baseline segments (slope=%.4f) from X=%d to X=%d\n",
slope, leftPoint.X, rightPoint.X)
}
// Count black-to-white transitions
transitions := 0
lastWasWhite := false
for x := leftPoint.X; x <= rightPoint.X; x++ {
// Interpolate Y position along the baseline, then go 2px up (away from thick edge)
y := leftPoint.Y + int(float64(x-leftPoint.X)*slope) - 2
isWhite := binary.GetUCharAt(y, x) == 255
// Detect black-to-white transition
if isWhite && !lastWasWhite {
transitions++
}
lastWasWhite = isWhite
}
if DEBUG {
fmt.Printf("DEBUG: Found %d black-to-white transitions\n", transitions)
}
// We need exactly 1 black-to-white transition for 2 segments
// (entering the second segment after the gap)
if transitions != 1 {
if DEBUG {
fmt.Printf("DEBUG: INVALID - Need exactly 1 transition, found %d\n", transitions)
}
return false
}
if DEBUG {
fmt.Println("DEBUG: VALID - Baseline has two distinct segments")
}
return true
}
// RotateImage rotates an image by the given angle in degrees
// The angle is the detected rotation that needs to be corrected
func RotateImage(img gocv.Mat, angleDegrees float64) gocv.Mat {
// Get image center
center := image.Point{
X: img.Cols() / 2,
Y: img.Rows() / 2,
}
// Apply rotation to correct the tilt
// NOTE: If image rotates wrong direction, change to: -angleDegrees
rotMat := gocv.GetRotationMatrix2D(center, angleDegrees, 1.0)
defer rotMat.Close()
// Apply rotation
rotated := gocv.NewMat()
gocv.WarpAffine(img, &rotated, rotMat, image.Point{X: img.Cols(), Y: img.Rows()})
if DEBUG {
fmt.Printf("DEBUG: Applied rotation: %.3f°\n", angleDegrees)
}
return rotated
}
// ScaleToWidth scales an image to the specified width while maintaining aspect ratio
func ScaleToWidth(img gocv.Mat, targetWidth int) gocv.Mat {
// Calculate scale factor
currentWidth := img.Cols()
scale := float64(targetWidth) / float64(currentWidth)
// Calculate new dimensions
newHeight := int(float64(img.Rows()) * scale)
// Resize image
scaled := gocv.NewMat()
gocv.Resize(img, &scaled, image.Point{X: targetWidth, Y: newHeight}, 0, 0, gocv.InterpolationLinear)
if DEBUG {
fmt.Printf("DEBUG: Scaled image from %dx%d to %dx%d (scale factor: %.3f)\n",
currentWidth, img.Rows(), targetWidth, newHeight, scale)
}
return scaled
}
// VisualizeSimpleBands draws just the graph and digit bands clearly
func VisualizeSimpleBands(binary gocv.Mat, graphBand, digitBand Band, filename string) {
if !DEBUG {
return
}
viz := gocv.NewMat()
gocv.CvtColor(binary, &viz, gocv.ColorGrayToBGR)
defer viz.Close()
green := color.RGBA{R: 0, G: 255, B: 0, A: 255}
cyan := color.RGBA{R: 0, G: 255, B: 255, A: 255}
// Draw graph band in green
graphRect := image.Rect(0, graphBand.minY, viz.Cols(), graphBand.maxY)
gocv.Rectangle(&viz, graphRect, green, 2)
gocv.PutText(&viz, "GRAPH BAND", image.Pt(10, graphBand.minY+20),
gocv.FontHersheyPlain, 1.5, green, 2)
// Draw digit band in cyan with thick border
digitRect := image.Rect(0, digitBand.minY, viz.Cols(), digitBand.maxY)
gocv.Rectangle(&viz, digitRect, cyan, 3)
label := fmt.Sprintf("DIGIT BAND [Y: %d-%d, H: %dpx]",
digitBand.minY, digitBand.maxY, digitBand.maxY-digitBand.minY)
gocv.PutText(&viz, label, image.Pt(10, digitBand.minY+20),
gocv.FontHersheyPlain, 1.5, cyan, 2)
gocv.IMWrite(filename, viz)
fmt.Printf("DEBUG: Saved 2-band visualization to %s\n", filename)
}
// ExtractBandRegion extracts a specific band region from an image
func ExtractBandRegion(img gocv.Mat, band Band) gocv.Mat {
// Create a region of interest (ROI) for the band
rect := image.Rect(0, band.minY, img.Cols(), band.maxY)
region := img.Region(rect)
// Clone the region to create an independent Mat
extracted := gocv.NewMat()
region.CopyTo(&extracted)
if DEBUG {
fmt.Printf("DEBUG: Extracted band region [%d-%d], size: %dx%d\n",
band.minY, band.maxY, extracted.Cols(), extracted.Rows())
}
return extracted
}
// ScaleByFactor scales an image by a specific scale factor
func ScaleByFactor(img gocv.Mat, scaleFactor float64) gocv.Mat {
// Calculate new dimensions
newWidth := int(float64(img.Cols()) * scaleFactor)
newHeight := int(float64(img.Rows()) * scaleFactor)
// Resize image
scaled := gocv.NewMat()
gocv.Resize(img, &scaled, image.Point{X: newWidth, Y: newHeight}, 0, 0, gocv.InterpolationLinear)
if DEBUG {
fmt.Printf("DEBUG: Scaled image from %dx%d to %dx%d (scale factor: %.3f)\n",
img.Cols(), img.Rows(), newWidth, newHeight, scaleFactor)
}
return scaled
}

View File

@ -0,0 +1,957 @@
package main
import (
"fmt"
"image"
"image/color"
"math"
"sort"
"time"
"gocv.io/x/gocv"
)
type Display struct {
IsSplit bool // true = one box split in half, false = two separate boxes
FullRect image.Rectangle // used when IsSplit=true
LeftRect image.Rectangle // used when IsSplit=false
RightRect image.Rectangle // used when IsSplit=false
}
type ScreenLayout struct {
SpO2Area image.Rectangle
HRArea image.Rectangle
}
// rotateImage rotates an image by the specified angle (in degrees)
// Positive angle = counter-clockwise, negative = clockwise
func rotateImage(src gocv.Mat, angleDegrees float64) gocv.Mat {
logMessage(Both, Info, " [rotateImage] Input: %dx%d, Angle: %.3f°", src.Cols(), src.Rows(), angleDegrees)
// Get image center
center := image.Pt(src.Cols()/2, src.Rows()/2)
logMessage(Both, Info, " [rotateImage] Center: (%d,%d)", center.X, center.Y)
// Get rotation matrix (angle in degrees, scale=1.0)
rotMat := gocv.GetRotationMatrix2D(center, angleDegrees, 1.0)
defer rotMat.Close()
logMessage(Both, Info, " [rotateImage] Rotation matrix created")
// Apply rotation
rotated := gocv.NewMat()
gocv.WarpAffineWithParams(src, &rotated, rotMat, image.Pt(src.Cols(), src.Rows()),
gocv.InterpolationLinear, gocv.BorderConstant, color.RGBA{0, 0, 0, 0})
logMessage(Both, Info, " [rotateImage] Output: %dx%d, Empty: %v", rotated.Cols(), rotated.Rows(), rotated.Empty())
return rotated
}
// calculateRotationAngle determines the rotation angle needed to straighten the frame
// by analyzing the bottom horizontal boxes (status indicators below 80% Y)
func calculateRotationAngle(preprocessed gocv.Mat) float64 {
const ROTATION_THRESHOLD = 0.5 // Only rotate if angle > 0.5 degrees
// Threshold to find contours
gray := gocv.NewMat()
gocv.CvtColor(preprocessed, &gray, gocv.ColorBGRToGray)
defer gray.Close()
thresh := gocv.NewMat()
gocv.Threshold(gray, &thresh, 240, 255, gocv.ThresholdBinary)
defer thresh.Close()
// Find all contours
contours := gocv.FindContours(thresh, gocv.RetrievalList, gocv.ChainApproxSimple)
defer contours.Close()
if contours.Size() == 0 {
logMessage(Both, Warning, " [ROTATION] No contours found - no rotation")
return 0.0
}
logMessage(Both, Info, " [ROTATION] Found %d contours", contours.Size())
// FIRST: Calculate bounding box from ALL contours (no filtering)
// This gives us the true extent of the display
if contours.Size() == 0 {
logMessage(Both, Warning, " [ROTATION] No contours - cannot calculate bounding box")
return 0.0
}
firstRect := gocv.BoundingRect(contours.At(0))
minX := firstRect.Min.X
minY := firstRect.Min.Y
maxX := firstRect.Max.X
maxY := firstRect.Max.Y
for i := 1; i < contours.Size(); i++ {
rect := gocv.BoundingRect(contours.At(i))
if rect.Min.X < minX { minX = rect.Min.X }
if rect.Min.Y < minY { minY = rect.Min.Y }
if rect.Max.X > maxX { maxX = rect.Max.X }
if rect.Max.Y > maxY { maxY = rect.Max.Y }
}
boundingBoxHeight := maxY - minY
logMessage(Both, Info, " [ROTATION] Bounding box from ALL contours: Y=%d-%d (H=%d)", minY, maxY, boundingBoxHeight)
// THEN: Get bounding boxes for filtered contours (for rotation calculation)
var boxes []image.Rectangle
for i := 0; i < contours.Size(); i++ {
rect := gocv.BoundingRect(contours.At(i))
// Filter out tiny boxes
if rect.Dx() > 20 && rect.Dy() > 10 {
boxes = append(boxes, rect)
}
}
logMessage(Both, Info, " [ROTATION] %d boxes after size filter (W>20, H>10)", len(boxes))
if len(boxes) < 3 {
logMessage(Both, Warning, " [ROTATION] Only %d boxes found - need at least 3", len(boxes))
return 0.0
}
// Calculate 80% Y threshold relative to bounding box (bottom 20% of bounding box)
threshold80 := minY + int(float64(boundingBoxHeight)*0.8)
logMessage(Both, Info, " [ROTATION] 80%%%% threshold: Y>%d", threshold80)
// Filter to boxes in bottom 20% (Y > 80% of height)
var bottomBoxes []image.Rectangle
for _, box := range boxes {
// Use box center Y for filtering
centerY := box.Min.Y + box.Dy()/2
if centerY > threshold80 {
bottomBoxes = append(bottomBoxes, box)
}
}
logMessage(Both, Info, " [ROTATION] %d boxes in bottom 20%%%% (Y>%d)", len(bottomBoxes), threshold80)
if len(bottomBoxes) < 3 {
logMessage(Both, Warning, " [ROTATION] Only %d boxes in bottom 20%%%% - need at least 3", len(bottomBoxes))
return 0.0
}
// Sort by Y coordinate (descending = bottom to top) to get lowest boxes
sort.Slice(bottomBoxes, func(i, j int) bool {
return bottomBoxes[i].Min.Y > bottomBoxes[j].Min.Y
})
// Take bottom 3 boxes (highest Y coordinates)
topBoxes := bottomBoxes[:3]
logMessage(Both, Info, " [ROTATION] Using 3 lowest boxes:")
for i, box := range topBoxes {
centerY := box.Min.Y + box.Dy()/2
logMessage(Both, Info, " Box %d: X=%d-%d, Y=%d-%d (centerY=%d)",
i, box.Min.X, box.Max.X, box.Min.Y, box.Max.Y, centerY)
}
// DEBUG: Save visualization of the three boxes used for rotation
if DEBUG_MODE {
rotDebugVis := preprocessed.Clone() // Clone the colored frame
// Create bounding box rectangle from calculated values
boundingBox := image.Rect(minX, minY, maxX, maxY)
// Draw bounding box in blue
gocv.Rectangle(&rotDebugVis, boundingBox, color.RGBA{0, 100, 255, 255}, 3)
// Draw 80% threshold line in red (this is what we use to filter boxes)
gocv.Line(&rotDebugVis,
image.Pt(0, threshold80),
image.Pt(preprocessed.Cols(), threshold80),
color.RGBA{255, 0, 0, 255}, 3) // Red line at threshold
// Draw the three selected boxes in green
for _, box := range topBoxes {
gocv.Rectangle(&rotDebugVis, box, color.RGBA{0, 255, 0, 255}, 3)
}
// Add labels
gocv.PutText(&rotDebugVis,
fmt.Sprintf("80%%%% threshold: Y=%d", threshold80),
image.Pt(10, threshold80-10),
gocv.FontHersheyPlain, 1.5, color.RGBA{255, 0, 0, 255}, 2)
gocv.PutText(&rotDebugVis,
fmt.Sprintf("BBox: %dx%d (Y=%d-%d)", boundingBox.Dx(), boundingBox.Dy(), minY, maxY),
image.Pt(minX+10, minY+30),
gocv.FontHersheyPlain, 1.5, color.RGBA{0, 100, 255, 255}, 2)
gocv.IMWrite("test_output/rotation_boxes_BEFORE.png", rotDebugVis)
rotDebugVis.Close()
logMessage(Both, Info, " [ROTATION] Saved rotation_boxes_BEFORE.png")
}
// Find leftmost and rightmost boxes
var leftBox, rightBox image.Rectangle
leftBox = topBoxes[0]
rightBox = topBoxes[0]
for _, box := range topBoxes[1:] {
if box.Min.X < leftBox.Min.X {
leftBox = box
}
if box.Min.X > rightBox.Min.X {
rightBox = box
}
}
// Get center Y of each box
leftY := leftBox.Min.Y + leftBox.Dy()/2
rightY := rightBox.Min.Y + rightBox.Dy()/2
leftX := leftBox.Min.X + leftBox.Dx()/2
rightX := rightBox.Min.X + rightBox.Dx()/2
logMessage(Both, Info, " [ROTATION] Left box center: (%d,%d), Right box center: (%d,%d)", leftX, leftY, rightX, rightY)
// Calculate rotation angle
if rightX == leftX {
logMessage(Both, Info, " [ROTATION] Boxes vertically aligned - no rotation")
return 0.0
}
slope := float64(rightY-leftY) / float64(rightX-leftX)
angleDeg := math.Atan(slope) * 180.0 / math.Pi
logMessage(Both, Info, " [ROTATION] Slope=%.4f, Angle=%.3f°", slope, angleDeg)
// Apply threshold
if angleDeg > -ROTATION_THRESHOLD && angleDeg < ROTATION_THRESHOLD {
logMessage(Both, Info, " [ROTATION] Angle %.3f° below threshold %.1f° - no rotation", angleDeg, ROTATION_THRESHOLD)
return 0.0
}
logMessage(Both, Info, " [ROTATION] Will rotate by %.3f°", angleDeg)
return angleDeg
}
func detectScreenLayoutAreas(rotated gocv.Mat) (*ScreenLayout, float64, error) {
// Correct order: Find center region FIRST (using all contours), THEN find digit displays
startTime := time.Now()
// Input is already thresholded binary image - work directly on it
// No grayscale, no threshold - just find contours
// DEBUG: Save input to see what we're working with
if DEBUG_MODE {
gocv.IMWrite("test_output/layout_step1_input.png", rotated)
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step1_input.png")
}
// STEP 3: Apply erosion to separate nearby elements (5x5 kernel) - COMMENTED OUT
// step3Start := time.Now()
// kernel := gocv.GetStructuringElement(gocv.MorphRect, image.Pt(5, 5))
// eroded := gocv.NewMat()
// gocv.Erode(thresh, &eroded, kernel)
// kernel.Close()
// defer eroded.Close()
// fmt.Printf(" [TIMING] Step 3 (Erosion): %dms\n", time.Since(step3Start).Milliseconds())
// STEP 2: Find contours - use RetrievalList to get ALL contours including nested
step2Start := time.Now()
contours := gocv.FindContours(rotated, gocv.RetrievalList, gocv.ChainApproxSimple)
defer contours.Close()
logMessage(Both, Info, " Found %d total contours", contours.Size())
if contours.Size() == 0 {
logMessage(Both, Error, " ERROR: No contours found in thresholded frame")
logMessage(Both, Error, " Frame dimensions: %dx%d", rotated.Cols(), rotated.Rows())
// Save debug image
gocv.IMWrite("test_output/error_input.png", rotated)
logMessage(Both, Error, " Saved debug image to test_output/error_input.png")
return nil, 0, nil
}
logMessage(LogFile, Debug, " [TIMING] Step 2 (Find contours): %dms", time.Since(step2Start).Milliseconds())
logMessage(LogFile, Debug, " [TIMING] Step 2 (Total): %dms", time.Since(startTime).Milliseconds())
// STEP 1: Collect ALL boxes (no filter) and print details
var allBoxes []image.Rectangle
for i := 0; i < contours.Size(); i++ {
rect := gocv.BoundingRect(contours.At(i))
allBoxes = append(allBoxes, rect)
// fmt.Printf(" Box %d: X=%d-%d (W=%d), Y=%d-%d (H=%d)\n",
// i, rect.Min.X, rect.Max.X, rect.Dx(),
// rect.Min.Y, rect.Max.Y, rect.Dy())
}
if len(allBoxes) == 0 {
logMessage(Both, Error, " ERROR: No bounding boxes found from contours")
return nil, 0, nil
}
logMessage(LogFile, Debug, " Found %d boxes (all, no filter)", len(allBoxes))
logMessage(LogFile, Debug, "")
// DEBUG: Skip step 6a and 6b visualizations
// rawContoursVis := rotated.Clone()
// ...
// allBoxesVis := rotated.Clone()
// ...
// STEP 2: Filter to significant boxes (width or height > 30px) for processing
var significantBoxes []image.Rectangle
for _, box := range allBoxes {
if box.Dx() > 30 || box.Dy() > 30 {
significantBoxes = append(significantBoxes, box)
}
}
if len(significantBoxes) == 0 {
logMessage(Both, Error, " ERROR: No significant boxes found (>30px)")
logMessage(Both, Error, " All %d boxes were too small", len(allBoxes))
return nil, 0, nil
}
logMessage(LogFile, Debug, " Found %d significant boxes (>30px) for processing", len(significantBoxes))
// STEP 3: Calculate bounding box from ALL boxes (not just significant ones)
// This ensures bottom small numbers are included in the height calculation
minX := allBoxes[0].Min.X
minY := allBoxes[0].Min.Y
maxX := allBoxes[0].Max.X
maxY := allBoxes[0].Max.Y
for _, box := range allBoxes {
if box.Min.X < minX {
minX = box.Min.X
}
if box.Min.Y < minY {
minY = box.Min.Y
}
if box.Max.X > maxX {
maxX = box.Max.X
}
if box.Max.Y > maxY {
maxY = box.Max.Y
}
}
boundingBox := image.Rect(minX, minY, maxX, maxY)
boundingBoxWidth := boundingBox.Dx()
logMessage(LogFile, Debug, " Bounding box from all boxes: X=%d-%d, Y=%d-%d, Width=%d", minX, maxX, minY, maxY, boundingBoxWidth)
// Calculate required scale to normalize to 860px width
const TARGET_WIDTH = 860
scale := float64(TARGET_WIDTH) / float64(boundingBoxWidth)
logMessage(Both, Info, " 📊 Bounding box width: %dpx, Scale factor: %.3f (target: %dpx)", boundingBoxWidth, scale, TARGET_WIDTH)
// STEP 4: Scale the frame to 860px width
step4Start := time.Now()
newWidth := int(float64(rotated.Cols()) * scale)
newHeight := int(float64(rotated.Rows()) * scale)
scaled := gocv.NewMat()
gocv.Resize(rotated, &scaled, image.Pt(newWidth, newHeight), 0, 0, gocv.InterpolationLinear)
defer scaled.Close()
logMessage(LogFile, Debug, " [TIMING] Step 4 (Scale frame): %dms", time.Since(step4Start).Milliseconds())
logMessage(LogFile, Debug, " Scaled frame: %dx%d -> %dx%d", rotated.Cols(), rotated.Rows(), newWidth, newHeight)
// CRITICAL: Scale the bounding box coordinates to match the scaled frame
scaledMinX := int(float64(minX) * scale)
scaledMaxX := int(float64(maxX) * scale)
scaledMinY := int(float64(minY) * scale)
scaledMaxY := int(float64(maxY) * scale)
scaledBoundingBox := image.Rect(scaledMinX, scaledMinY, scaledMaxX, scaledMaxY)
logMessage(LogFile, Debug, " Scaled bounding box: X=%d-%d, Y=%d-%d (from original: X=%d-%d, Y=%d-%d)",
scaledMinX, scaledMaxX, scaledMinY, scaledMaxY, minX, maxX, minY, maxY)
// Scale all significant boxes coordinates too
var scaledSignificantBoxes []image.Rectangle
for _, box := range significantBoxes {
scaledBox := image.Rect(
int(float64(box.Min.X)*scale),
int(float64(box.Min.Y)*scale),
int(float64(box.Max.X)*scale),
int(float64(box.Max.Y)*scale),
)
scaledSignificantBoxes = append(scaledSignificantBoxes, scaledBox)
}
// Scale all boxes too (for visualization)
var scaledAllBoxes []image.Rectangle
for _, box := range allBoxes {
scaledBox := image.Rect(
int(float64(box.Min.X)*scale),
int(float64(box.Min.Y)*scale),
int(float64(box.Max.X)*scale),
int(float64(box.Max.Y)*scale),
)
scaledAllBoxes = append(scaledAllBoxes, scaledBox)
}
// All subsequent processing now works on the SCALED frame with SCALED coordinates
// This ensures boxes are calculated in scaled coordinates
// STEP 5: Find 50% line
height := scaledBoundingBox.Dy()
line50 := scaledBoundingBox.Min.Y + height/2
logMessage(LogFile, Debug, " 50%% line at Y=%d", line50)
// DEBUG: Save step 5 visualization - bounding box and 50% line
bboxVis := gocv.NewMat()
gocv.CvtColor(scaled, &bboxVis, gocv.ColorGrayToBGR)
// Draw bounding box in blue
gocv.Rectangle(&bboxVis, scaledBoundingBox, color.RGBA{0, 100, 255, 255}, 3)
// Draw 50% line in red
gocv.Line(&bboxVis, image.Pt(scaledMinX, line50), image.Pt(scaledMaxX, line50), color.RGBA{255, 0, 0, 255}, 3)
// Add labels
gocv.PutText(&bboxVis, fmt.Sprintf("BBox: %d-%d, %d-%d", scaledMinX, scaledMaxX, scaledMinY, scaledMaxY),
image.Pt(scaledMinX+10, scaledMinY+30),
gocv.FontHersheyPlain, 1.2, color.RGBA{0, 100, 255, 255}, 2)
gocv.PutText(&bboxVis, fmt.Sprintf("50%% line: Y=%d", line50),
image.Pt(scaledMinX+10, line50-10),
gocv.FontHersheyPlain, 1.2, color.RGBA{255, 0, 0, 255}, 2)
gocv.IMWrite("test_output/layout_step5_bbox_and_line.png", bboxVis)
bboxVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step5_bbox_and_line.png")
// DEBUG: Save step 6 - all boxes with numbers
allBoxesVis := gocv.NewMat()
gocv.CvtColor(scaled, &allBoxesVis, gocv.ColorGrayToBGR)
for i, box := range scaledAllBoxes {
gocv.Rectangle(&allBoxesVis, box, color.RGBA{0, 255, 0, 255}, 2)
// Add box number
gocv.PutText(&allBoxesVis, fmt.Sprintf("%d", i),
image.Pt(box.Min.X+5, box.Min.Y+15),
gocv.FontHersheyPlain, 1.2, color.RGBA{0, 255, 0, 255}, 2)
}
gocv.IMWrite("test_output/layout_step6_all_boxes.png", allBoxesVis)
allBoxesVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step6_all_boxes.png")
// STEP 6: Filter to boxes crossing 50% line (center region)
var centerBoxes []image.Rectangle
for _, box := range scaledSignificantBoxes {
if box.Min.Y < line50 && box.Max.Y > line50 {
centerBoxes = append(centerBoxes, box)
}
}
if len(centerBoxes) == 0 {
logMessage(Both, Error, " ERROR: No boxes crossing 50%%%% line")
logMessage(Both, Error, " 50%%%% line at Y=%d, checked %d significant boxes", line50, len(scaledSignificantBoxes))
return nil, 0, nil
}
logMessage(LogFile, Debug, " Found %d boxes crossing 50%% line (center region)", len(centerBoxes))
// DEBUG: Save step 7 - center boxes visualization
centerBoxesVis := gocv.NewMat()
gocv.CvtColor(scaled, &centerBoxesVis, gocv.ColorGrayToBGR)
// Draw 50% line
gocv.Line(&centerBoxesVis, image.Pt(scaledMinX, line50), image.Pt(scaledMaxX, line50), color.RGBA{255, 0, 0, 255}, 3)
// Draw center boxes
for _, box := range centerBoxes {
gocv.Rectangle(&centerBoxesVis, box, color.RGBA{0, 255, 255, 255}, 2)
}
gocv.IMWrite("test_output/layout_step7_center_boxes.png", centerBoxesVis)
centerBoxesVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step7_center_boxes.png")
// STEP 7: From center boxes, identify digit displays (height >= 110px)
var digitDisplays []image.Rectangle
for _, box := range centerBoxes {
logMessage(LogFile, Debug, " Center box: X=%d-%d (W=%d), Y=%d-%d (H=%d)",
box.Min.X, box.Max.X, box.Dx(),
box.Min.Y, box.Max.Y, box.Dy())
if box.Dy() >= 110 {
digitDisplays = append(digitDisplays, box)
logMessage(LogFile, Debug, " ✓ Added as digit display (H=%d >= 110)", box.Dy())
} else {
logMessage(LogFile, Debug, " ✗ Skipped (H=%d < 110)", box.Dy())
}
}
if len(digitDisplays) == 0 {
logMessage(Both, Error, " ERROR: No digit displays found (height >= 110px) in center region")
logMessage(Both, Error, " Found %d center boxes, none met height requirement", len(centerBoxes))
return nil, 0, nil
}
logMessage(LogFile, Debug, " Found %d digit displays (height > 120px) BEFORE trimming", len(digitDisplays))
// DEBUG: Save step 8 - digit displays visualization
if DEBUG_MODE {
digitDisplaysVis := gocv.NewMat()
gocv.CvtColor(scaled, &digitDisplaysVis, gocv.ColorGrayToBGR)
for _, box := range digitDisplays {
gocv.Rectangle(&digitDisplaysVis, box, color.RGBA{255, 0, 255, 255}, 3)
}
gocv.IMWrite("test_output/layout_step8_digit_displays.png", digitDisplaysVis)
digitDisplaysVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step8_digit_displays.png")
}
// STEP 8: Get Y range from digit displays
minDigitY := digitDisplays[0].Min.Y
maxDigitY := digitDisplays[0].Max.Y
for _, box := range digitDisplays {
if box.Min.Y < minDigitY {
minDigitY = box.Min.Y
}
if box.Max.Y > maxDigitY {
maxDigitY = box.Max.Y
}
}
logMessage(LogFile, Debug, " Digit display Y range: %d-%d", minDigitY, maxDigitY)
// STEP 9: Find ALL boxes (from scaledAllBoxes) within this Y range
var boxesInRange []image.Rectangle
for _, box := range scaledAllBoxes {
// Box overlaps with Y range if its bottom is below minDigitY and top is above maxDigitY
if box.Max.Y > minDigitY && box.Min.Y < maxDigitY {
boxesInRange = append(boxesInRange, box)
}
}
logMessage(LogFile, Debug, " Found %d boxes in Y range %d-%d", len(boxesInRange), minDigitY, maxDigitY)
// DEBUG: Save step 9 - boxes in range visualization
boxesInRangeVis := gocv.NewMat()
gocv.CvtColor(scaled, &boxesInRangeVis, gocv.ColorGrayToBGR)
// Draw Y range lines
gocv.Line(&boxesInRangeVis, image.Pt(0, minDigitY), image.Pt(boxesInRangeVis.Cols(), minDigitY), color.RGBA{255, 0, 0, 255}, 2)
gocv.Line(&boxesInRangeVis, image.Pt(0, maxDigitY), image.Pt(boxesInRangeVis.Cols(), maxDigitY), color.RGBA{255, 0, 0, 255}, 2)
// Draw digit displays in magenta
for _, box := range digitDisplays {
gocv.Rectangle(&boxesInRangeVis, box, color.RGBA{255, 0, 255, 255}, 3)
}
// Draw all boxes in range in cyan
for _, box := range boxesInRange {
gocv.Rectangle(&boxesInRangeVis, box, color.RGBA{0, 255, 255, 255}, 1)
}
gocv.IMWrite("test_output/layout_step9_boxes_in_range.png", boxesInRangeVis)
boxesInRangeVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step9_boxes_in_range.png")
// STEP 10: Trim digit displays based on small boxes crossing their right edge
for i := range digitDisplays {
originalMaxX := digitDisplays[i].Max.X
newMaxX := originalMaxX
// Check each box in range
for _, smallBox := range boxesInRange {
// Skip if this is the digit display itself
if smallBox == digitDisplays[i] {
continue
}
// Check if small box crosses the right edge of this digit display
// Crosses if: smallBox.Min.X < digitDisplay.Max.X AND smallBox.Max.X > digitDisplay.Max.X
if smallBox.Min.X < digitDisplays[i].Max.X && smallBox.Max.X > digitDisplays[i].Max.X {
// Use the left edge of the small box as the new right edge
if smallBox.Min.X < newMaxX {
newMaxX = smallBox.Min.X
logMessage(LogFile, Debug, " Trimming digit display %d: right edge %d -> %d (small box at X=%d-%d)",
i, originalMaxX, newMaxX, smallBox.Min.X, smallBox.Max.X)
}
}
}
digitDisplays[i].Max.X = newMaxX
}
logMessage(LogFile, Debug, " Digit displays AFTER trimming: %d", len(digitDisplays))
// DEBUG: Save step 11 - trimmed digit displays visualization
trimmedVis := gocv.NewMat()
gocv.CvtColor(scaled, &trimmedVis, gocv.ColorGrayToBGR)
for _, box := range digitDisplays {
gocv.Rectangle(&trimmedVis, box, color.RGBA{0, 255, 0, 255}, 4)
}
gocv.IMWrite("test_output/layout_step11_trimmed_displays.png", trimmedVis)
trimmedVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step11_trimmed_displays.png")
if len(digitDisplays) < 2 {
// Save debug visualization showing what was found
debugVis := gocv.NewMat()
gocv.CvtColor(scaled, &debugVis, gocv.ColorGrayToBGR)
// Draw 50% line
gocv.Line(&debugVis, image.Pt(scaledMinX, line50), image.Pt(scaledMaxX, line50), color.RGBA{255, 0, 0, 255}, 2)
gocv.PutText(&debugVis, fmt.Sprintf("50%% line: Y=%d", line50),
image.Pt(10, line50-10), gocv.FontHersheyPlain, 1.5, color.RGBA{255, 0, 0, 255}, 2)
// Draw all center boxes with labels
for i, box := range centerBoxes {
boxColor := color.RGBA{0, 255, 255, 255} // Cyan for rejected
label := fmt.Sprintf("#%d: H=%d", i, box.Dy())
// Check if this box qualified as digit display
qualified := false
for _, dd := range digitDisplays {
if box == dd {
qualified = true
break
}
}
if qualified {
boxColor = color.RGBA{0, 255, 0, 255} // Green for qualified
label += " OK"
} else if box.Dy() < 110 {
label += " TOO SHORT"
}
gocv.Rectangle(&debugVis, box, boxColor, 3)
gocv.PutText(&debugVis, label,
image.Pt(box.Min.X+5, box.Min.Y+25),
gocv.FontHersheyPlain, 1.5, boxColor, 2)
}
// Add summary at top
summary := fmt.Sprintf("Found: %d center boxes, %d digit displays (need 2)",
len(centerBoxes), len(digitDisplays))
gocv.PutText(&debugVis, summary,
image.Pt(10, 30), gocv.FontHersheyDuplex, 1.0, color.RGBA{255, 255, 255, 255}, 2)
gocv.PutText(&debugVis, "Requirement: Height >= 110px",
image.Pt(10, 60), gocv.FontHersheyDuplex, 1.0, color.RGBA{255, 255, 255, 255}, 2)
timestamp := time.Now().Format("20060102_150405")
errorFilename := fmt.Sprintf("test_output/layout_error_%s.png", timestamp)
gocv.IMWrite(errorFilename, debugVis)
debugVis.Close()
// Enhanced error message
logMessage(Both, Error, " ERROR: Only %d digit display(s) found (need 2)", len(digitDisplays))
logMessage(Both, Error, " ")
logMessage(Both, Error, " LIKELY CAUSE: Pulse oximeter is not centered under camera")
logMessage(Both, Error, " - Device may be at an angle or offset to one side")
logMessage(Both, Error, " - Only one display (SpO2 or HR) is in the center detection region")
logMessage(Both, Error, " ")
logMessage(Both, Error, " ACTION REQUIRED: Physically reposition camera or device")
logMessage(Both, Error, " - Move device to be centered and level under camera")
logMessage(Both, Error, " - Or adjust camera angle to capture both displays")
logMessage(Both, Error, " ")
logMessage(Both, Error, " Technical details:")
logMessage(Both, Error, " Center boxes found: %d (crossing 50%% line at Y=%d)", len(centerBoxes), line50)
for i, box := range centerBoxes {
qualified := ""
for _, dd := range digitDisplays {
if box == dd {
qualified = " ✓ QUALIFIED"
break
}
}
if qualified == "" {
if box.Dy() < 110 {
qualified = " ✗ TOO SHORT (< 110px)"
}
}
logMessage(Both, Error, " Box #%d: W=%dpx, H=%dpx at X=%d-%d, Y=%d-%d%s",
i, box.Dx(), box.Dy(), box.Min.X, box.Max.X, box.Min.Y, box.Max.Y, qualified)
}
logMessage(Both, Error, " 💾 Debug image saved: %s", errorFilename)
logMessage(Both, Error, " Green boxes = qualified, Cyan = rejected")
return nil, 0, nil
}
// STEP 11: Get Y range from digit displays
minCenterY := digitDisplays[0].Min.Y
maxCenterY := digitDisplays[0].Max.Y
for _, box := range digitDisplays {
if box.Min.Y < minCenterY {
minCenterY = box.Min.Y
}
if box.Max.Y > maxCenterY {
maxCenterY = box.Max.Y
}
}
// Create center region
centerRegion := image.Rect(scaledBoundingBox.Min.X, minCenterY, scaledBoundingBox.Max.X, maxCenterY)
// STEP 12: Find X-center to split left/right
centerX := centerRegion.Min.X + centerRegion.Dx()/2
logMessage(LogFile, Debug, " Center X: %d", centerX)
// STEP 13: Find rightmost X and Y range in each half
spo2RightX := -1
spo2MinY := 10000
spo2MaxY := 0
hrRightX := -1
hrMinY := 10000
hrMaxY := 0
for _, box := range digitDisplays {
// Determine which half this box belongs to based on its center
boxCenterX := box.Min.X + box.Dx()/2
if boxCenterX < centerX {
// Left half (SpO2)
if box.Max.X > spo2RightX {
spo2RightX = box.Max.X
}
if box.Min.Y < spo2MinY {
spo2MinY = box.Min.Y
}
if box.Max.Y > spo2MaxY {
spo2MaxY = box.Max.Y
}
} else {
// Right half (HR)
if box.Max.X > hrRightX {
hrRightX = box.Max.X
}
if box.Min.Y < hrMinY {
hrMinY = box.Min.Y
}
if box.Max.Y > hrMaxY {
hrMaxY = box.Max.Y
}
}
}
if spo2RightX == -1 || hrRightX == -1 {
logMessage(Both, Error, " ERROR: Could not find displays in both halves")
logMessage(Both, Error, " Left half (SpO2): rightX=%d, Right half (HR): rightX=%d", spo2RightX, hrRightX)
return nil, 0, nil
}
// STEP 14: Create boxes with fixed CUT_WIDTH
spo2LeftX := spo2RightX - CUT_WIDTH
spo2Box := image.Rect(spo2LeftX, spo2MinY, spo2RightX, spo2MaxY)
hrLeftX := hrRightX - CUT_WIDTH
hrBox := image.Rect(hrLeftX, hrMinY, hrRightX, hrMaxY)
logMessage(LogFile, Debug, " Final SpO2 box: X=%d-%d, Y=%d-%d", spo2Box.Min.X, spo2Box.Max.X, spo2Box.Min.Y, spo2Box.Max.Y)
logMessage(LogFile, Debug, " Final HR box: X=%d-%d, Y=%d-%d", hrBox.Min.X, hrBox.Max.X, hrBox.Min.Y, hrBox.Max.Y)
// DEBUG: Save step 15 - final boxes visualization
finalVis := gocv.NewMat()
gocv.CvtColor(scaled, &finalVis, gocv.ColorGrayToBGR)
gocv.Rectangle(&finalVis, spo2Box, color.RGBA{255, 0, 0, 255}, 4)
gocv.Rectangle(&finalVis, hrBox, color.RGBA{0, 255, 255, 255}, 4)
gocv.PutText(&finalVis, "SpO2", image.Pt(spo2Box.Min.X, spo2Box.Min.Y-10),
gocv.FontHersheyDuplex, 1.2, color.RGBA{255, 0, 0, 255}, 2)
gocv.PutText(&finalVis, "HR", image.Pt(hrBox.Min.X, hrBox.Min.Y-10),
gocv.FontHersheyDuplex, 1.2, color.RGBA{0, 255, 255, 255}, 2)
gocv.IMWrite("test_output/layout_step15_final_boxes.png", finalVis)
finalVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step15_final_boxes.png")
logMessage(LogFile, Debug, " [TIMING] Total layout detection: %dms", time.Since(startTime).Milliseconds())
// Return layout and scale factor
return &ScreenLayout{
SpO2Area: spo2Box,
HRArea: hrBox,
}, scale, nil
}
// Old functions kept for backward compatibility but deprecated
func groupAdjacentContours(rects []image.Rectangle) []Display {
var displays []Display
used := make([]bool, len(rects))
logMessage(LogFile, Debug, " Grouping %d candidate contours:", len(rects))
for i := 0; i < len(rects); i++ {
if used[i] {
continue
}
// Look for an adjacent rect with similar Y coordinate
var adjacent *int
for j := i + 1; j < len(rects); j++ {
if used[j] {
continue
}
// Check if Y coordinates are similar (within 20 pixels)
yDiff := rects[i].Min.Y - rects[j].Min.Y
if yDiff < 0 {
yDiff = -yDiff
}
if yDiff > 20 {
logMessage(LogFile, Debug, " Skip pairing rect[%d] and rect[%d]: Y diff=%d > 20", i, j, yDiff)
continue
}
// Check if they're side-by-side (allow small overlap or gap)
xGap := rects[j].Min.X - rects[i].Max.X
// Allow up to 50 pixels of overlap (negative gap) or up to 200 pixels of space
if xGap >= -50 && xGap < 200 {
logMessage(LogFile, Debug, " Pairing rect[%d] and rect[%d]: Y diff=%d, X gap=%d", i, j, yDiff, xGap)
adjacent = &j
break
} else {
logMessage(LogFile, Debug, " Skip pairing rect[%d] and rect[%d]: X gap=%d not in range [-50, 200)", i, j, xGap)
}
}
if adjacent != nil {
// Two separate contours - use them as left and right
used[i] = true
used[*adjacent] = true
left := rects[i]
right := rects[*adjacent]
// Ensure left is actually on the left
if left.Min.X > right.Min.X {
left, right = right, left
}
logMessage(LogFile, Debug, " DUAL: [%dx%d @(%d,%d)] + [%dx%d @(%d,%d)]",
left.Dx(), left.Dy(), left.Min.X, left.Min.Y,
right.Dx(), right.Dy(), right.Min.X, right.Min.Y)
displays = append(displays, Display{
IsSplit: false,
LeftRect: left,
RightRect: right,
})
} else {
// Single contour - will be split in half
used[i] = true
logMessage(LogFile, Debug, " SPLIT: [%dx%d @(%d,%d)]",
rects[i].Dx(), rects[i].Dy(), rects[i].Min.X, rects[i].Min.Y)
displays = append(displays, Display{
IsSplit: true,
FullRect: rects[i],
})
}
}
return displays
}
func detectScreenLayout(rotated gocv.Mat) ([]Display, error) {
gray := gocv.NewMat()
gocv.CvtColor(rotated, &gray, gocv.ColorBGRToGray)
thresh := gocv.NewMat()
gocv.Threshold(gray, &thresh, 170, 255, gocv.ThresholdBinary)
gray.Close()
contours := gocv.FindContours(thresh, gocv.RetrievalExternal, gocv.ChainApproxSimple)
thresh.Close()
var rects []image.Rectangle
for i := 0; i < contours.Size(); i++ {
rect := gocv.BoundingRect(contours.At(i))
if rect.Dy() > MIN_BOX_HEIGHT && rect.Dy() < 200 {
rects = append(rects, rect)
}
}
contours.Close()
sort.Slice(rects, func(i, j int) bool {
return rects[i].Dy() > rects[j].Dy()
})
if len(rects) < 3 {
return nil, fmt.Errorf("found only %d contours (need 3+)", len(rects))
}
// Take up to 6 candidates to ensure we capture split digits
maxCands := 6
if len(rects) < maxCands+1 {
maxCands = len(rects) - 1
}
cands := rects[1 : 1+maxCands]
// Sort by Y position for processing (Y desc, then X asc)
sort.Slice(cands, func(i, j int) bool {
if cands[i].Min.Y != cands[j].Min.Y {
return cands[i].Min.Y > cands[j].Min.Y
}
return cands[i].Min.X < cands[j].Min.X
})
logMessage(LogFile, Debug, " Selected %d candidates (excluding largest):", len(cands))
for idx, rect := range cands {
logMessage(LogFile, Debug, " [%d]: %dx%d @(%d,%d)", idx, rect.Dx(), rect.Dy(), rect.Min.X, rect.Min.Y)
}
// Group adjacent contours
displays := groupAdjacentContours(cands)
logMessage(LogFile, Debug, " Found %d displays after grouping", len(displays))
// Group displays by Y position to find the digit row
// Displays within 30 pixels vertically are considered same row
type YGroup struct {
avgY int
displays []Display
}
var yGroups []YGroup
for _, disp := range displays {
dispY := disp.FullRect.Min.Y
if !disp.IsSplit {
dispY = disp.LeftRect.Min.Y
}
// Find existing group with similar Y
found := false
for i := range yGroups {
if math.Abs(float64(dispY-yGroups[i].avgY)) < 30 {
yGroups[i].displays = append(yGroups[i].displays, disp)
found = true
break
}
}
if !found {
yGroups = append(yGroups, YGroup{avgY: dispY, displays: []Display{disp}})
}
}
logMessage(LogFile, Debug, " Grouped into %d Y-bands:", len(yGroups))
for idx, group := range yGroups {
logMessage(LogFile, Debug, " Band %d (Y~%d): %d displays", idx, group.avgY, len(group.displays))
}
// Find the band with exactly 2 displays (SpO2 and HR)
var digitDisplays []Display
for _, group := range yGroups {
if len(group.displays) == 2 {
digitDisplays = group.displays
break
}
}
if len(digitDisplays) != 2 {
return nil, fmt.Errorf("could not find Y-band with exactly 2 displays (found %d bands)", len(yGroups))
}
// Sort by X position: left = SpO2, right = HR
sort.Slice(digitDisplays, func(i, j int) bool {
xI := digitDisplays[i].FullRect.Min.X
if !digitDisplays[i].IsSplit {
xI = digitDisplays[i].LeftRect.Min.X
}
xJ := digitDisplays[j].FullRect.Min.X
if !digitDisplays[j].IsSplit {
xJ = digitDisplays[j].LeftRect.Min.X
}
return xI < xJ
})
return digitDisplays, nil
}
func saveLayoutVisualization(rotated gocv.Mat, layout *ScreenLayout, filename string) {
visualization := rotated.Clone()
// Draw SpO2 box in red
red := color.RGBA{255, 0, 0, 255}
gocv.Rectangle(&visualization, layout.SpO2Area, red, 3)
gocv.PutText(&visualization, "SpO2", image.Pt(layout.SpO2Area.Min.X, layout.SpO2Area.Min.Y-10),
gocv.FontHersheyDuplex, 1.2, red, 2)
// Draw HR box in cyan
cyan := color.RGBA{0, 255, 255, 255}
gocv.Rectangle(&visualization, layout.HRArea, cyan, 3)
gocv.PutText(&visualization, "HR", image.Pt(layout.HRArea.Min.X, layout.HRArea.Min.Y-10),
gocv.FontHersheyDuplex, 1.2, cyan, 2)
gocv.IMWrite(filename, visualization)
visualization.Close()
}

View File

@ -0,0 +1,202 @@
package main
import (
"fmt"
"log"
"os"
"gocv.io/x/gocv"
)
// Global DEBUG flag
var DEBUG = false
func main() {
// Parse command line args
if len(os.Args) > 1 && os.Args[1] == "/debug" {
DEBUG = true
fmt.Println("DEBUG mode enabled\n")
}
// Hardcode RTSP URL
streamURL := "rtsp://tapohass:!!Helder06@192.168.2.183:554/stream1"
fmt.Println("Rotation Detection System")
fmt.Println("========================")
fmt.Println("Processing frames...\n")
// Open RTSP stream
stream, err := gocv.OpenVideoCapture(streamURL)
if err != nil {
log.Fatalf("Failed to open stream: %v", err)
}
defer stream.Close()
// Initialize detection state
rotation := 9999.0 // Sentinel value for "not detected"
width := 0 // 0 means "not detected"
// Frame processing counters
framesSkipped := 0
framesProcessed := 0
detectionAttempts := 0
totalFramesRead := 0
SKIP_COUNT := 7 // Process every 8th frame
MAX_DETECTION_ATTEMPTS := 100 // Give up after 100 attempts
RE_DETECT_AFTER := 0 // Re-detect after N frames (0 = disabled)
// Main processing loop
frame := gocv.NewMat()
defer frame.Close()
for {
// Acquire a frame
if ok := stream.Read(&frame); !ok || frame.Empty() {
log.Fatal("Failed to read frame from stream")
}
totalFramesRead++
// Preprocess frame to binary
binary := PreprocessFrame(frame)
// If rotation is 9999 or width is 0, detect bands/width/rotation
if rotation == 9999 || width == 0 {
detectionAttempts++
// Give up if too many attempts
if detectionAttempts > MAX_DETECTION_ATTEMPTS {
fmt.Printf("❌ Detection failed after %d attempts. Exiting.\n", MAX_DETECTION_ATTEMPTS)
break
}
result := DetectRotationAndWidth(binary)
if result.Success {
rotation = result.Rotation
width = result.Width
fmt.Printf("✓ DETECTION SUCCESSFUL (frame %d, attempt #%d): Width=%dpx, Rotation=%.3f°\n\n",
totalFramesRead, detectionAttempts, width, rotation)
detectionAttempts = 0 // Reset counter
} else {
// Show detection attempts with frame info
if detectionAttempts <= 5 {
if DEBUG {
fmt.Printf("Frame %d: Detection attempt #%d failed\n", totalFramesRead, detectionAttempts)
}
} else {
fmt.Printf("⚠ Frame %d: Detection attempt #%d failed - retrying...\n", totalFramesRead, detectionAttempts)
}
// Clean up and continue to next frame
binary.Close()
// Skip a few frames to ensure display has changed
for skip := 0; skip < 3; skip++ {
if ok := stream.Read(&frame); !ok {
break
}
}
continue
}
}
// If rotation != 9999 and width != 0 and we've skipped enough frames
if rotation != 9999 && width != 0 && framesSkipped >= SKIP_COUNT {
framesProcessed++
// Rotate the image to correct orientation
rotated := RotateImage(binary, rotation)
defer rotated.Close()
// Calculate scale factor based on detected width
scaleFactor := 860.0 / float64(width)
// Scale to 860px width using the scale factor
scaled := ScaleByFactor(rotated, scaleFactor)
defer scaled.Close()
fmt.Printf("Frame #%d processed: Rotated %.3f°, Scaled from %dpx to 860px (factor: %.3f)\n",
framesProcessed, rotation, width, scaleFactor)
// Re-detect bands on the scaled image
// Scale the 80px minimum height by the same factor
scaledMinHeight := int(80.0 * scaleFactor)
bands := DetectBands(scaled, scaledMinHeight)
if DEBUG {
fmt.Printf(" Using scaled minimum height: %dpx (80px * %.3f)\n", scaledMinHeight, scaleFactor)
fmt.Printf(" Found %d bands on scaled image:\n", len(bands))
for i, band := range bands {
fmt.Printf(" Band #%d: Y range [%d-%d], Height: %dpx\n",
i+1, band.minY, band.maxY, band.maxY-band.minY)
}
}
// We should have exactly 2 bands: graph and digits
if len(bands) == 2 {
// First band should be graph (taller), second should be digits
graphBand := bands[0]
digitBand := bands[1]
// Verify graph band is taller
graphHeight := graphBand.maxY - graphBand.minY
digitHeight := digitBand.maxY - digitBand.minY
if graphHeight < digitHeight {
// Swap if needed
graphBand, digitBand = digitBand, graphBand
}
fmt.Printf(" ✓ Graph band: Y [%d-%d], Height: %dpx\n",
graphBand.minY, graphBand.maxY, graphBand.maxY-graphBand.minY)
fmt.Printf(" ✓ Digit band: Y [%d-%d], Height: %dpx\n",
digitBand.minY, digitBand.maxY, digitBand.maxY-digitBand.minY)
// Extract just the digit band region for OCR
digitRegion := ExtractBandRegion(scaled, digitBand)
defer digitRegion.Close()
// Save visualization if in DEBUG mode
if DEBUG && framesProcessed <= 5 {
// Simple visualization with just 2 bands
VisualizeSimpleBands(scaled, graphBand, digitBand,
fmt.Sprintf("debug_frame_%03d_bands.png", framesProcessed))
// Save just the digit region
digitFilename := fmt.Sprintf("debug_frame_%03d_digits.png", framesProcessed)
gocv.IMWrite(digitFilename, digitRegion)
fmt.Printf("DEBUG: Saved digit region to %s\n", digitFilename)
}
// TODO: Run OCR on digitRegion here
} else {
fmt.Printf(" ⚠ Expected 2 bands, found %d bands (>%dpx height)\n", len(bands), scaledMinHeight)
}
// Save debug images if in DEBUG mode
if DEBUG && framesProcessed <= 5 {
filename := fmt.Sprintf("debug_frame_%03d_scaled.png", framesProcessed)
gocv.IMWrite(filename, scaled)
fmt.Printf("DEBUG: Saved %s\n", filename)
}
// TODO: Add OCR processing on 'scaled' image here in later phase
framesSkipped = 0
// Optional: Force re-detection after N frames to adapt to changes
if RE_DETECT_AFTER > 0 && framesProcessed >= RE_DETECT_AFTER {
fmt.Printf("\n🔄 Re-detecting after %d frames...\n", RE_DETECT_AFTER)
rotation = 9999
width = 0
framesProcessed = 0
}
} else {
framesSkipped++
}
// Clean up
binary.Close()
}
fmt.Println("\nProgram terminated.")
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,328 @@
# Systematic Review of MRI Brain Report for Sophia
**Patient:** Sophia Helena Jongsma
**Date of Birth:** January 1, 2017
**Date of MRI:** May 6, 2022
**Date of Review:** November 10, 2025
**Original Radiologist:** Javier Quintana, MD
---
## Author's Note
This document was written by Johan Jongsma (Sophia's father) with the assistance of Claude Opus AI. While I am not a medical professional, I have strong analytical skills that I've applied to understanding my daughter's condition through systematic observation and reasoning. The MRI analysis referenced in this document was performed by Claude Opus AI based on the original radiology reports from May 6, 2022.
---
## Original Radiologist's Report
> IMPRESSION: In light of the history, imaging findings favored to reflect a degree of global hypoxic injury as discussed in detail.
>
> Indication: >72 hours post-cardiac arrest. Please evaluate Comparison Study Date: 5/6/2022 Technique: Multiplanar multisequence imaging of the brain was performed with and without contrast.
>
> Findings: There is moderate opacification of the paranasal sinuses.<sup>1</sup> There is bilateral mastoid fluid.<sup>2</sup> The brain is abnormal. Extensive areas of T2 prolongation are identified within the posterior upper cervical cord and brainstem<sup>3</sup>, midbrain, periaqueductal gray, thalami, basal ganglia<sup>4-7</sup>. Abnormal increased T2 signal with swelling is identified throughout much of the supratentorial cortex more noticeable in the parietal and occipital regions. This does affect the temporal and frontal lobes as well.<sup>8</sup> Diffusion-weighted images are abnormal demonstrating restricted water motion along much of the cortical abnormalities near the gray-white matter junction. Restricted water motion within the basal ganglia and thalami is also noted to a degree. Involvement of some of the white matter is not entirely excluded as well.<sup>9-11</sup> The lateral ventricles are symmetric and not particularly dilated and similar to this morning CT.<sup>12</sup> The cavum septum pellucidum seen on the 5/2/2020 examination is however no longer visualized suggesting a degree of mass effect.<sup>13</sup> However, suprasellar cisterns remain patent with probably similar appearance of the medial temporal lobes and cerebellar tonsils when accounting for the differences technique. The major intracranial flow-voids are grossly preserved. The cerebellar tonsils are rounded appearing and above the level of foramen magnum and again similar.<sup>14-16</sup> The adenohypophysis is somewhat thin appearing which may be within anatomic range. Neurohypophysis is orthotopic. Corpus callosum is present. Subcutaneous edema is questioned within the face. As seen on the recent MRI, there does appear to be a degree of increased enhancement within the sulci bilaterally. However, given the underlying changes within the brain, this is favored to be related to congestion rather than leptomeningeal disease. Some degree of enhancement is questioned within the midbrain and periaqueductal gray as well as the inferior colliculus.<sup>17-23</sup>
**Note:** Superscript numbers have been added to the original report text for reference in the detailed analysis that follows.
---
## CRITICAL DISCOVERY #1: Temporal Evolution of Cisterna Magna Enlargement
### Comparative CT Analysis: May 2, 2022 vs May 6, 2022
A systematic comparison of CT scans reveals the most important finding was missed entirely by the radiologist: **the massive cisterna magna enlargement developed progressively over the 4 days following the incident.**
#### May 2, 2022 CT (8:31 PM - Day of Incident)
**Observations:**
- **Normal-sized posterior fossa CSF spaces**
- Brainstem in normal anatomic position
- No evidence of enlarged cisterna magna
- Cerebellar hemispheres normal in size and position
- No anterior compression or flattening of brainstem
#### May 6, 2022 CT (8:49 AM - Four Days Post-Incident)
**Observations:**
- **Massive enlargement of posterior fossa CSF space (cisterna magna)**
- Brainstem compressed and displaced anteriorly
- Cerebellar hemispheres pushed laterally by CSF accumulation
- Clear evidence of progressive mass effect
#### May 6, 2022 MRI (Same Day as Second CT)
**Observations:**
- Confirms massive cisterna magna enlargement
- Brainstem mechanically compressed and flattened
- FLAIR sequences show BLACK signal (CSF), NOT tissue edema
### Clinical Significance
This temporal sequence establishes that the enlarged cisterna magna is:
1. **ACQUIRED** - Not present immediately after the incident
2. **PROGRESSIVE** - Developed over a 4-day period
3. **STRUCTURAL** - Represents CSF accumulation, not hypoxic tissue injury
4. **MECHANICAL** - Causing brainstem compression through mass effect
---
## CRITICAL DISCOVERY #2: Acquired Cerebellar Tonsil Herniation
### Comparative Analysis: May 2, 2022 vs May 6, 2022
In addition to the cisterna magna enlargement, systematic comparison reveals **progressive descent of the cerebellar tonsils** between May 2 and May 6, 2022.
#### May 2, 2022 CT
**Observations:**
- Cerebellar tonsils in **normal position** relative to foramen magnum
- Normal tapered configuration of tonsils
- No tonsillar herniation
#### May 6, 2022 CT
**Observations:**
- Cerebellar tonsils showing **rounded/blunted contour**
- Beginning descent toward foramen magnum
#### May 6, 2022 MRI (SAG T1, slice 15/29)
**Observations:**
- Cerebellar tonsils **descended/herniated** into or through the foramen magnum opening
- Acquired Chiari-like malformation
- Tonsils being compressed downward by the massive cisterna magna above
### Clinical Significance of Tonsillar Herniation
The progressive tonsillar descent is critical because:
1. **Acquired, not congenital** - This is NOT a pre-existing Chiari malformation, but an acquired herniation caused by the enlarging cisterna magna pushing the cerebellum downward
2. **CSF flow obstruction** - Herniated tonsils can block CSF circulation at the foramen magnum, potentially contributing to or perpetuating the cisterna magna enlargement
3. **Direct neural compression** - Tonsils compress the brainstem and upper spinal cord, explaining:
- Autonomic dysfunction (breathing, heart rate abnormalities)
- Positional symptoms (inability to tolerate right-side positioning)
- Progressive neurological deterioration
4. **Surgically addressable** - Posterior fossa decompression surgery can:
- Relieve tonsillar compression
- Restore CSF flow
- Address the cisterna magna
- Potentially reverse or improve symptoms
### Clinical Context
According to the patient's father, the medical team urgently performed imaging on May 6 because they were concerned about worsening pressure on the brainstem/spinal cord. The clinical team observed progressive neurological deterioration over those 4 days - which correlates directly with the progressive structural changes documented here: both the cisterna magna enlargement AND the tonsillar herniation were actively worsening.
**These findings completely change the interpretation of Sophia's condition.**
---
## Detailed Finding-by-Finding Analysis of May 6 MRI Report
### Finding #1-2: Paranasal sinus opacification and mastoid fluid
**Sequences reviewed:** AX T1 (slice 1/25)
**Observation:** Confirmed presence of fluid in sinuses and mastoid air cells
**Assessment:** Accurate but clinically irrelevant. Common in intubated ICU patients.
### Finding #3: "T2 prolongation in posterior upper cervical cord and brainstem"
**Sequences reviewed:** SAG T1 (15/29), AX T2 (7/25), AX FLAIR (7/25)
**Observation:**
- Massive CSF space posterior to brainstem (enlarged cisterna magna)
- Brainstem mechanically compressed/flattened anteriorly
- FLAIR shows BLACK signal confirming CSF, NOT tissue edema
- **May 2 CT shows this was NOT present initially**
**Assessment:** **FUNDAMENTAL MISINTERPRETATION** - This is an acquired structural CSF enlargement that developed over 4 days, NOT hypoxic tissue injury as claimed.
### Finding #4-7: Midbrain, periaqueductal gray, thalami, basal ganglia changes
**Sequences reviewed:** AX T2 (10,12/25), AX FLAIR (8,9/25)
**Observation:**
- Some T2 hyperintensity confirmed in deep gray structures
- FLAIR confirms tissue changes (not just CSF effect)
- Pattern consistent with some injury but not extensive
**Assessment:** Partially accurate, changes present but must be interpreted in context of progressive mechanical compression rather than purely hypoxic etiology.
### Finding #8: "Cortical swelling throughout supratentorial regions"
**Sequences reviewed:** AX FLAIR (10,12,14,20,22,24/25)
**Observation:**
- Sulci remain OPEN and visible
- Gray-white differentiation preserved
- Minimal signal changes, NO significant swelling
**Assessment:** **GROSSLY OVERSTATED** - Minimal changes present, no significant swelling evident.
### Finding #9-11: "Restricted diffusion in cortex and deep gray matter"
**Sequences reviewed:** T2 weighted trace B1000 (13,23,44/56), Average DCTENSOR B1000 (13,23/56), Fractional Anisotropy B1000 (13,23/56)
**Observation:**
- DWI images essentially NEGATIVE - no significant restricted diffusion
- FA maps show preserved white matter tract integrity
**Assessment:** **DEMONSTRABLY FALSE** - No evidence of extensive restricted diffusion. This directly contradicts the claim of severe acute hypoxic-ischemic injury.
### Finding #12: Ventricles symmetric, not dilated
**Assessment:** Accurate, normal finding.
### Finding #13: Cavum septum pellucidum comparison to "5/2/2020"
**Critical Issue:** References exam from "5/2/2020" - TWO YEARS before accident occurred (5/2/2022). Presumably meant to reference May 2, 2022 CT.
**Assessment:** **MAJOR ERROR** - Wrong date by exactly 2 years, indicating severe inattention to detail and destroying report credibility.
### Finding #14-16: "Cerebellar tonsils rounded appearing and above the level of foramen magnum"
**Sequences reviewed:** SAG T1 (especially slice 15/29)
**Observation:**
- Radiologist noted "rounded" appearance and claimed tonsils "above foramen magnum"
- Direct comparison with May 2 CT shows cerebellar tonsils were in normal position on May 2
- May 6 MRI shows cerebellar tonsils have **descended/herniated** into or through the foramen magnum
- **This is acquired tonsillar herniation, NOT a stable finding**
**Assessment:** **CRITICAL MISINTERPRETATION** - The radiologist noted abnormal tonsil appearance but:
1. Failed to recognize this as an acquired change (comparing to May 2 imaging)
2. Mischaracterized the tonsil position as "above foramen magnum" when MRI shows herniation
3. Missed the clinical significance of progressive tonsillar descent
4. This is acquired Chiari-like malformation from progressive compression, not a pre-existing condition
### Finding #17-23: Various minor/questioned findings
**Assessment:** Multiple vague "questioned" findings showing uncertainty throughout report.
---
## Summary of Errors
| Finding | Radiologist's Claim | Actual Finding | Clinical Impact |
|---------|-------------------|----------------|-----------------|
| **Timeline recognition** | Not addressed | **Cisterna magna AND tonsils changed May 2-6** | **Critical missed findings** |
| **Posterior brainstem "T2 prolongation"** | Hypoxic injury | **Acquired enlarged cisterna magna** | **Fundamental misdiagnosis** |
| **Cerebellar tonsils** | "Rounded, above foramen magnum" | **Acquired herniation/descent** | **Missed progressive compression** |
| Deep gray matter changes | Present | Partially accurate | Some changes present |
| **Cortical "swelling"** | Extensive | **Minimal - Exaggerated** | Overstated severity |
| **Restricted diffusion** | Extensive | **FALSE - DWI negative** | **Major error contradicting hypoxic diagnosis** |
| **Date reference** | 5/2/2020 exam | **Wrong by 2 years** | **Credibility destroyed** |
| Structural findings | Various | Misinterpreted | Mechanical vs hypoxic confusion |
---
## What the Report SHOULD Have Said
> **IMPRESSION:** Serial imaging demonstrates progressive structural abnormalities in the posterior fossa developing between May 2 and May 6, 2022, including marked enlargement of the cisterna magna and acquired cerebellar tonsillar herniation, resulting in brainstem and spinal cord compression. The absence of significant restricted diffusion on DWI sequences argues strongly against extensive acute hypoxic-ischemic injury as the primary pathology. Mild T2 signal changes in deep gray structures may represent limited injury, but the dominant findings are progressive structural/mechanical pathology rather than hypoxic injury.
>
> **COMPARISON:** CT brain May 2, 2022 shows normal posterior fossa anatomy with normal cerebellar tonsil position. Current study (May 6, 2022) demonstrates dramatic interval development of:
> 1. Markedly enlarged cisterna magna
> 2. Acquired cerebellar tonsillar herniation
>
> **KEY FINDINGS:**
>
> 1. **Progressive cisterna magna enlargement** - Developed between May 2 and May 6, 2022 (documented on serial CT)
> 2. **Acquired cerebellar tonsillar herniation** - Tonsils descended from normal position (May 2) to herniation through foramen magnum (May 6), creating Chiari-like malformation
> 3. **Acquired brainstem compression** - Anterior displacement and flattening of brainstem due to posterior CSF accumulation
> 4. **CSF flow obstruction** - Herniated tonsils blocking CSF circulation at foramen magnum level
> 5. Mild T2 hyperintensity in bilateral thalami and basal ganglia without corresponding restricted diffusion
> 6. **No significant cortical edema or swelling** - sulci remain visible, gray-white differentiation preserved
> 7. **No evidence of extensive acute hypoxic-ischemic injury** on diffusion-weighted imaging
> 8. Preserved white matter tract integrity on fractional anisotropy maps
>
> **CLINICAL CORRELATION:**
> The progressive nature of both the cisterna magna enlargement and tonsillar herniation over 4 days, accompanied by clinical deterioration prompting urgent imaging, suggests an active structural process rather than immediate traumatic or hypoxic injury. The mechanism may represent:
> - Post-traumatic arachnoid disruption with progressive CSF accumulation
> - Impaired CSF circulation/resorption exacerbated by tonsillar herniation
> - Progressive posterior fossa pressure changes causing secondary tonsillar descent
>
> **RECOMMENDATION:** URGENT neurosurgical consultation for evaluation of:
> 1. Acquired cisterna magna enlargement
> 2. Acquired cerebellar tonsillar herniation (Chiari-like malformation)
> 3. Brainstem and spinal cord compression
>
> The progressive nature of these findings over 4 days with accompanying clinical deterioration suggests an evolving process requiring prompt neurosurgical assessment. Posterior fossa decompression may be indicated to:
> - Relieve tonsillar herniation
> - Restore CSF flow
> - Decompress brainstem
> - Address cisterna magna
> - Potentially reverse or improve neurological status
---
## Conclusion
This systematic review reveals fundamental errors in the original radiological interpretation that completely missed TWO critical progressive findings:
### Primary Findings: Progressive Acquired Structural Abnormalities
**1. The enlarged cisterna magna developed between May 2 and May 6, 2022.**
**2. Cerebellar tonsil herniation developed between May 2 and May 6, 2022.**
This is documented by direct comparison of serial imaging showing:
- Normal posterior fossa anatomy on May 2, 2022 (day of incident)
- Normal cerebellar tonsil position on May 2, 2022
- Massive cisterna magna enlargement by May 6, 2022 (4 days later)
- Cerebellar tonsillar herniation by May 6, 2022 (4 days later)
This temporal evolution establishes that we are dealing with **acquired, progressive structural processes** - not pre-existing anatomical variants, and not immediate traumatic injury.
### Critical Errors in Original Report
1. **Failure to recognize temporal evolution:** The radiologist had access to the May 2 CT for comparison but failed to identify or comment on the dramatic interval development of BOTH the cisterna magna enlargement AND the cerebellar tonsillar herniation.
2. **Misidentification of pathology:** The most striking findings - a progressively enlarging cisterna magna causing brainstem compression and progressive tonsillar herniation - were misinterpreted as "T2 prolongation" from hypoxic injury and static "rounded" tonsils. The FLAIR sequences clearly show BLACK signal (CSF), not the bright signal expected from tissue edema.
3. **False diffusion findings:** The report claimed "extensive restricted diffusion" throughout the brain. Direct review of DWI sequences shows they are essentially negative. This directly contradicts the diagnosis of severe acute hypoxic-ischemic injury.
4. **Basic factual errors:** The report references a comparison study from "5/2/2020" - exactly two years before the actual incident. This appears to be a typo for 5/2/2022, but such an error in a critical report indicates severe inattention to detail.
5. **Exaggeration of findings:** Claimed "extensive swelling" where minimal changes exist.
6. **Mischaracterization of tonsillar position:** Described tonsils as "above the level of foramen magnum" when imaging shows herniation into or through the foramen magnum.
### Clinical Implications
**Sophia has acquired, progressive posterior fossa pathology consisting of:**
1. **Cisterna magna enlargement** causing brainstem compression
2. **Cerebellar tonsillar herniation** (acquired Chiari-like malformation) causing:
- CSF flow obstruction at foramen magnum
- Direct brainstem and upper spinal cord compression
- Autonomic dysfunction
- Positional symptoms
**These conditions:**
- Developed progressively over 4 days following the incident
- Represent structural/mechanical problems rather than purely hypoxic injury
- May be amenable to neurosurgical intervention (posterior fossa decompression)
- Have completely different prognostic implications than diffuse hypoxic-ischemic injury
**The absence of significant restricted diffusion on DWI sequences argues strongly against extensive acute hypoxic-ischemic injury as the primary diagnosis.** The radiologist's conclusion of "global hypoxic injury" is not supported by the imaging findings and completely misses the progressive structural pathology that is the dominant finding.
### Mechanism of Injury: Implications
The progressive development of both cisterna magna enlargement and tonsillar herniation between May 2 and May 6 suggests several possible mechanisms:
1. **Post-traumatic CSF accumulation** - Disruption of arachnoid membranes or posterior fossa structures allowing CSF to accumulate, with increasing pressure causing downward cerebellar displacement and tonsillar herniation
2. **Impaired CSF circulation** - Damage to CSF resorption pathways leading to focal accumulation, with subsequent mass effect causing tonsillar descent and further CSF flow obstruction, creating a progressive cycle
3. **Combined mechanism** - Initial CSF accumulation leading to tonsillar herniation, which then further impairs CSF flow and perpetuates the enlargement
The key point is that these are **progressive, acquired processes that may be reversible or amenable to treatment** - fundamentally different from the fixed, irreversible hypoxic injury diagnosis that was given.
**This analysis fundamentally changes the understanding of Sophia's condition and has profound implications for both prognosis and treatment options, including the critical potential role for neurosurgical intervention to address:**
- Mechanical brainstem compression from cisterna magna
- Cerebellar tonsillar herniation and CSF flow obstruction
- Progressive posterior fossa pathology that was actively worsening and prompted urgent clinical concern
The medical team's urgent imaging on May 6 due to concerns about "worsening pressure" was clinically appropriate - they were observing the effects of these progressive structural changes. The tragedy is that the radiologist completely missed documenting this progressive pathology, instead attributing everything to irreversible hypoxic injury.
---
**Document prepared by:** Johan Jongsma
**Date:** November 10, 2025
**Assistance:** Claude Opus AI
**Purpose:** Medical consultation with neurology specialist

View File

@ -0,0 +1,44 @@
#!/bin/bash
# Build script for pulseox-monitor v3.10
echo "=== Building PulseOx Monitor v3.10 (Corruption Frame Number) ==="
echo ""
# List of all source files needed
FILES="pulseox-monitor.go types.go helpers.go frame_source.go processor.go validators.go ocr.go layout_detection.go config.go homeassistant.go html_report.go timestamp_ocr.go"
# Check if all files exist
MISSING=0
for file in $FILES; do
if [ ! -f "$file" ]; then
echo "❌ Missing file: $file"
MISSING=1
fi
done
if [ $MISSING -eq 1 ]; then
echo ""
echo "Error: Missing source files. Please ensure all files are present."
exit 1
fi
echo "✓ All source files present"
echo ""
echo "Compiling..."
# Build
go build -o pulseox-monitor $FILES
if [ $? -eq 0 ]; then
echo ""
echo "✓ Build successful: pulseox-monitor"
echo ""
echo "Test with:"
echo " ./pulseox-monitor raw_frames/raw_20251028-00123.png # Single frame"
echo " ./pulseox-monitor # Live stream"
echo ""
else
echo ""
echo "❌ Build failed"
exit 1
fi

View File

@ -0,0 +1,41 @@
package main
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Camera struct {
RTSPURL string `yaml:"rtsp_url"`
} `yaml:"camera"`
HomeAssistant struct {
URL string `yaml:"url"`
Token string `yaml:"token"`
} `yaml:"home_assistant"`
Processing struct {
SampleInterval int `yaml:"sample_interval"`
ChangeThresholdPercent int `yaml:"change_threshold_percent"`
MaxDriftSeconds int `yaml:"max_drift_seconds"`
} `yaml:"processing"`
Logging struct {
Level string `yaml:"level"`
File string `yaml:"file"`
} `yaml:"logging"`
}
func LoadConfig(filename string) (*Config, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
return &config, nil
}

View File

@ -0,0 +1,25 @@
# Pulse Oximeter Monitor Configuration
camera:
# Tapo C110 RTSP URL (pulse ox monitoring camera)
rtsp_url: "rtsp://tapohass:!!Helder06@192.168.2.183:554/stream1"
home_assistant:
# TS140 server IP where HASS is hosted
url: "http://192.168.1.252:8123"
# Long-lived access token for pulse-monitor
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzOTMxMTA4MjczYzI0NDU1YjIzOGJlZWE0Y2NkM2I1OCIsImlhdCI6MTc2MTExNTQxNywiZXhwIjoyMDc2NDc1NDE3fQ.URFS4M0rX78rW27gQuBX-PyrPYMLlGujF16jIBHXYOw"
processing:
# How often to capture and process frames (seconds)
sample_interval: 1
# Reject readings that change more than this percent
change_threshold_percent: 5
# Maximum allowed drift INCREASE in seconds (detects camera lag/freeze)
# Camera can be 10s behind server consistently - that's fine
# But if drift increases by more than this, frame is stale
max_drift_seconds: 3
logging:
level: "info" # debug, info, warn, error
file: "./logs/pulse-monitor.log"

View File

@ -0,0 +1,20 @@
#!/bin/bash
# Backup script for pulse-monitor before refactoring
# Created: 2025-10-30
BACKUP_DIR="backups/backup_20251030_043342"
mkdir -p "$BACKUP_DIR"
# Copy all source files
cp pulse-monitor.go "$BACKUP_DIR/"
cp ocr.go "$BACKUP_DIR/"
cp layout_detection.go "$BACKUP_DIR/"
cp normalize.go "$BACKUP_DIR/"
cp config.go "$BACKUP_DIR/"
cp homeassistant.go "$BACKUP_DIR/"
cp html_report.go "$BACKUP_DIR/"
cp PROJECT_STATE.md "$BACKUP_DIR/"
cp config.yaml "$BACKUP_DIR/"
echo "✓ Backup completed to $BACKUP_DIR"
ls -lh "$BACKUP_DIR"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,237 @@
package main
import (
"fmt"
"image"
"sync"
"time"
"gocv.io/x/gocv"
)
// FrameSource provides frames to process
type FrameSource interface {
// Next returns the next frame and whether to continue processing
// Returns (frame, shouldContinue, error)
Next() (gocv.Mat, bool, error)
// NextImmediate returns the very next frame (ignoring skip count)
// Used for immediate retry after low confidence/corruption
NextImmediate() (gocv.Mat, bool, error)
// Close cleans up resources
Close() error
// Name returns a description of the source
Name() string
// IsActive returns false if source has been closed
IsActive() bool
}
// RTSPSource reads from an RTSP stream
type RTSPSource struct {
url string
stream *gocv.VideoCapture
frameNum int
minFrameInterval time.Duration // Minimum time between processed frames
lastProcessedTime time.Time // Last time we processed a frame
lastAcquiredTime time.Time // Last time we acquired a frame (for logging)
closed bool
closeOnce sync.Once
}
// NewRTSPSource creates a new RTSP frame source
// Processes frames at 4 fps (250ms minimum between frames)
func NewRTSPSource(url string) (*RTSPSource, error) {
stream, err := gocv.VideoCaptureFile(url)
if err != nil {
return nil, fmt.Errorf("failed to connect to RTSP stream: %w", err)
}
logMessage(Console, Info, "📊 Processing frames at 4 fps (250ms minimum interval)")
return &RTSPSource{
url: url,
stream: stream,
minFrameInterval: 250 * time.Millisecond,
lastProcessedTime: time.Time{}, // Zero time = process first frame immediately
}, nil
}
// readFrame reads and validates the next frame from the stream
func (s *RTSPSource) readFrame() (gocv.Mat, bool, error) {
// Check if source was closed
if s.closed {
return gocv.NewMat(), false, nil
}
// Check if stream is valid
if s.stream == nil {
return gocv.NewMat(), false, fmt.Errorf("stream is null")
}
frame := gocv.NewMat()
if ok := s.stream.Read(&frame); !ok {
frame.Close()
// Check again before reconnecting
if s.closed {
return gocv.NewMat(), false, nil
}
// Try to reconnect
s.stream.Close()
time.Sleep(5 * time.Second)
// Check again after sleep
if s.closed {
return gocv.NewMat(), false, nil
}
newStream, err := gocv.VideoCaptureFile(s.url)
if err != nil {
s.stream = nil // Set to nil to prevent further read attempts
s.closed = true // Mark as closed
return gocv.NewMat(), false, fmt.Errorf("reconnect failed: %w", err)
}
s.stream = newStream
return gocv.NewMat(), true, nil // Signal to retry
}
s.frameNum++
// Check frame validity
if frame.Empty() || frame.Cols() < 640 || frame.Rows() < 480 {
frame.Close()
return gocv.NewMat(), true, nil // Signal to retry
}
return frame, true, nil
}
func (s *RTSPSource) Next() (gocv.Mat, bool, error) {
for {
frame, shouldContinue, err := s.readFrame()
if err != nil || !shouldContinue {
return frame, shouldContinue, err
}
if frame.Empty() {
// readFrame returned empty (reconnecting or invalid), retry
continue
}
// Check if enough time has passed since last processed frame was ACQUIRED
now := time.Now()
if !s.lastProcessedTime.IsZero() {
elapsed := now.Sub(s.lastProcessedTime)
if elapsed < s.minFrameInterval {
// Not enough time passed, skip this frame
frame.Close()
continue
}
}
// Enough time passed, mark acquisition time NOW (before processing)
s.lastProcessedTime = now
// Log frame acquisition interval
if !s.lastAcquiredTime.IsZero() {
interval := now.Sub(s.lastAcquiredTime).Milliseconds()
logMessage(LogFile, Debug, " [TIMING] Frame acquired: +%dms since last (target: 250ms)", interval)
}
s.lastAcquiredTime = now
return frame, true, nil
}
}
func (s *RTSPSource) NextImmediate() (gocv.Mat, bool, error) {
// Get the very next frame (no skip count)
for {
frame, shouldContinue, err := s.readFrame()
if err != nil || !shouldContinue {
return frame, shouldContinue, err
}
if frame.Empty() {
// readFrame returned empty (reconnecting or invalid), retry
continue
}
return frame, true, nil
}
}
func (s *RTSPSource) Close() error {
s.closeOnce.Do(func() {
s.closed = true
// DON'T close the stream here - it causes segfault if read is in progress
// The stream will be cleaned up when main() exits
})
return nil
}
func (s *RTSPSource) IsActive() bool {
return !s.closed
}
func (s *RTSPSource) Name() string {
return fmt.Sprintf("RTSP stream: %s", s.url)
}
// FileSource reads a single frame from a file
type FileSource struct {
path string
done bool
}
// NewFileSource creates a new file frame source
func NewFileSource(path string) *FileSource {
return &FileSource{
path: path,
done: false,
}
}
func (s *FileSource) Next() (gocv.Mat, bool, error) {
if s.done {
return gocv.NewMat(), false, nil
}
frame := gocv.IMRead(s.path, gocv.IMReadColor)
if frame.Empty() {
return gocv.NewMat(), false, fmt.Errorf("failed to load frame: %s", s.path)
}
s.done = true
return frame, false, nil // Return frame, but signal to stop after this
}
func (s *FileSource) NextImmediate() (gocv.Mat, bool, error) {
// For file source, NextImmediate is same as Next
return s.Next()
}
func (s *FileSource) Close() error {
return nil
}
func (s *FileSource) IsActive() bool {
return !s.done
}
func (s *FileSource) Name() string {
return fmt.Sprintf("File: %s", s.path)
}
// preprocessFrame crops timestamp and rotates
func preprocessFrame(frame gocv.Mat) gocv.Mat {
// Crop timestamp (top 68 pixels)
noTs := frame.Region(image.Rect(0, 68, frame.Cols(), frame.Rows()))
rotated := gocv.NewMat()
gocv.Rotate(noTs, &rotated, gocv.Rotate90Clockwise)
noTs.Close()
return rotated
}

View File

@ -0,0 +1,9 @@
module pulse-monitor
go 1.24.4
require (
github.com/otiai10/gosseract/v2 v2.4.1 // indirect
gocv.io/x/gocv v0.42.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -0,0 +1,7 @@
github.com/otiai10/gosseract/v2 v2.4.1 h1:G8AyBpXEeSlcq8TI85LH/pM5SXk8Djy2GEXisgyblRw=
github.com/otiai10/gosseract/v2 v2.4.1/go.mod h1:1gNWP4Hgr2o7yqWfs6r5bZxAatjOIdqWxJLWsTsembk=
gocv.io/x/gocv v0.42.0 h1:AAsrFJH2aIsQHukkCovWqj0MCGZleQpVyf5gNVRXjQI=
gocv.io/x/gocv v0.42.0/go.mod h1:zYdWMj29WAEznM3Y8NsU3A0TRq/wR/cy75jeUypThqU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,117 @@
package main
import (
"fmt"
"io"
"time"
)
// LogTarget specifies where to write the log message
type LogTarget int
const (
Console LogTarget = iota
LogFile
Both
)
// LogLevel specifies the severity/type of log message
type LogLevel int
const (
Debug LogLevel = iota
Info
Warning
Error
)
// Global logger - set once during initialization
var globalLogger io.Writer = nil
// logMessage writes a formatted log message to the specified target(s)
// Debug messages are automatically filtered out when DEBUG_MODE is false
//
// Usage examples:
// logMessage(Console, Info, "SpO2=%d%%, HR=%d bpm", spo2, hr)
// logMessage(Both, Debug, "Before preprocess: %dx%d", cols, rows)
// logMessage(Both, Error, "Layout detection failed: %v", err)
// logMessage(Both, Warning, "Low confidence (#%d)", count)
func logMessage(target LogTarget, level LogLevel, format string, args ...interface{}) {
// Filter debug messages when not in debug mode
if level == Debug && !DEBUG_MODE {
return
}
// Format the message
message := fmt.Sprintf(format, args...)
// Single-letter prefix for fixed-width alignment
var levelChar string
switch level {
case Debug:
levelChar = "D"
case Info:
levelChar = "I"
case Warning:
levelChar = "W"
case Error:
levelChar = "E"
}
// Format with timestamp
timestamp := time.Now().Format("15:04:05.000")
formattedMessage := fmt.Sprintf("[%s] %s %s\n", timestamp, levelChar, message)
// Write to target(s)
switch target {
case Console:
fmt.Print(formattedMessage)
case LogFile:
if globalLogger != nil {
fmt.Fprint(globalLogger, formattedMessage)
}
case Both:
fmt.Print(formattedMessage)
if globalLogger != nil {
fmt.Fprint(globalLogger, formattedMessage)
}
}
}
// logf is DEPRECATED - use logMessage() instead
// Kept temporarily for reference, but no longer used in the codebase
/*
func logf(logger io.Writer, format string, args ...interface{}) {
if logger != nil {
fmt.Fprintf(logger, format, args...)
}
}
*/
// printTimingTable prints timing data in horizontal table format
// Prints header every 20 frames for readability
// Note: Acquire is separate (camera waiting time)
// Note: Total is wall-clock processing time (may not equal sum due to logging/overhead)
// Note: HASS is added in main loop after function returns
func printTimingTable(timing TimingData, showHeader bool) {
if !TIMING_MODE {
return
}
if showHeader {
fmt.Println("")
fmt.Println("Frame | Acquire | Thresh | Prep | Scale | OCR_SpO2 | OCR_HR | Valid | FileIO | HASS | Total")
fmt.Println("------|---------|--------|------|-------|----------|--------|-------|--------|------|-------")
}
fmt.Printf("#%-4d | %5dms | %5dms | %3dms | %4dms | %7dms | %5dms | %4dms | %5dms | %3dms | %4dms\n",
timing.FrameNum,
timing.Acquire,
timing.Threshold,
timing.Preprocess,
timing.Scale,
timing.OCR_SpO2,
timing.OCR_HR,
timing.Validation,
timing.FileIO,
timing.HASS,
timing.Total)
}

View File

@ -0,0 +1,43 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
func postToHomeAssistant(config *Config, entityID string, value int, unit string, friendlyName string) error {
url := fmt.Sprintf("%s/api/states/%s", config.HomeAssistant.URL, entityID)
payload := map[string]interface{}{
"state": fmt.Sprintf("%d", value),
"attributes": map[string]interface{}{
"unit_of_measurement": unit,
"friendly_name": friendlyName,
"device_class": "measurement",
},
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal error: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("request error: %w", err)
}
req.Header.Set("Authorization", "Bearer "+config.HomeAssistant.Token)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("HTTP error: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 && resp.StatusCode != 201 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return nil
}

View File

@ -0,0 +1,281 @@
package main
import (
"fmt"
"os"
)
type ReviewEntry struct {
FrameNum int
Timestamp string
SpO2Value int
SpO2LeftDigit int
SpO2LeftConf float64
SpO2RightDigit int
SpO2RightConf float64
HRValue int
HRLeftDigit int
HRLeftConf float64
HRRightDigit int
HRRightConf float64
Failed bool
FailureReason string
Unstable bool
UnstableReason string
}
// initReviewHTML creates the HTML file with header and opens the table
func initReviewHTML() error {
html := `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Pulse-Ox Recognition Review</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; background-color: #000; color: #fff; }
h1 { color: #fff; }
table { border-collapse: collapse; width: 100%; margin-top: 20px; }
th, td { border: 1px solid #444; padding: 12px; text-align: center; }
th { background-color: #2a5c2e; color: white; }
tr:nth-child(even) { background-color: #1a1a1a; }
tr:nth-child(odd) { background-color: #0a0a0a; }
.conf-high { color: #4CAF50; font-weight: bold; }
.conf-med { color: #ff9800; font-weight: bold; }
.conf-low { color: #f44336; font-weight: bold; }
.digit-box { display: inline-flex; align-items: center; margin: 5px; gap: 8px; }
.digit-img { max-height: 40px; cursor: pointer; transition: transform 0.2s; }
.digit-img:hover { opacity: 0.8; }
.ocr-value { font-size: 54px; font-weight: bold; }
p { color: #ccc; }
strong { color: #fff; }
code { background-color: #222; padding: 2px 6px; border-radius: 3px; color: #4CAF50; }
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.95); }
.modal-content { margin: auto; display: block; max-width: 90%; max-height: 90%; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }
.close { position: absolute; top: 20px; right: 35px; color: #f1f1f1; font-size: 40px; font-weight: bold; cursor: pointer; }
.close:hover { color: #bbb; }
</style>
<script>
function enlargeImage(imgSrc) {
var modal = document.getElementById('imageModal');
var modalImg = document.getElementById('enlargedImg');
modal.style.display = 'block';
modalImg.src = imgSrc;
}
function closeModal() {
document.getElementById('imageModal').style.display = 'none';
}
window.onclick = function(event) {
var modal = document.getElementById('imageModal');
if (event.target == modal) {
modal.style.display = 'none';
}
}
</script>
</head>
<body>
<div id="imageModal" class="modal" onclick="closeModal()">
<span class="close" onclick="closeModal()">&times;</span>
<img class="modal-content" id="enlargedImg">
</div>
<h1>Pulse-Ox Recognition Review (Live)</h1>
<p><strong>Tip:</strong> Press Ctrl+F (or Cmd+F on Mac) and search for "CHECK" to find frames with low confidence (&lt;85%)</p>
<p><em>Refresh page to see latest frames...</em></p>
<table>
<tr>
<th>Frame</th>
<th>Time</th>
<th>Detected Boxes</th>
<th>SpO2 Recognition</th>
<th>HR Recognition</th>
</tr>
`
return os.WriteFile("review/review.html", []byte(html), 0644)
}
// appendReviewEntry appends a single entry to the HTML file
func appendReviewEntry(e ReviewEntry) error {
confClass := func(conf float64) string {
if conf >= 85 {
return "conf-high"
} else if conf >= 70 {
return "conf-med"
}
return "conf-low"
}
needsReview := e.SpO2LeftConf < 85 || e.SpO2RightConf < 85 || e.HRLeftConf < 85 || e.HRRightConf < 85
reviewMarker := ""
if needsReview {
reviewMarker = " ⚠CHECK"
}
var rowHTML string
if e.Failed {
// Failed recognition entry
rowHTML = fmt.Sprintf(` <tr style="background-color: #330000;">
<td>%d%s</td>
<td>%s</td>
<td>
<img class="digit-img" src="f%d_boxes.jpg" onclick="enlargeImage('f%d_boxes.jpg')">
</td>
<td style="color: #ff6666;">
<strong style="color: #ff6666;"> FAILED: %s</strong><br>
<strong>SpO2: %d</strong><br>
<div style="margin: 10px 0;">
<img class="digit-img" src="f%d_spo2_checks.png" onclick="enlargeImage('f%d_spo2_checks.png')" style="max-height: 60px; border: 1px solid #444;">
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_spo2_digit1.png" onclick="enlargeImage('f%d_spo2_digit1.png')">
<span class="ocr-value" style="font-size: 24px;">D1</span>
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_spo2_digit2.png" onclick="enlargeImage('f%d_spo2_digit2.png')">
<span class="ocr-value">%d</span>
<span class="%s">(%.0f%%)</span>
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_spo2_digit3.png" onclick="enlargeImage('f%d_spo2_digit3.png')">
<span class="ocr-value">%d</span>
<span class="%s">(%.0f%%)</span>
</div>
</td>
<td style="color: #ff6666;">
<strong>HR: %d</strong><br>
<div style="margin: 10px 0;">
<img class="digit-img" src="f%d_hr_checks.png" onclick="enlargeImage('f%d_hr_checks.png')" style="max-height: 60px; border: 1px solid #444;">
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_hr_digit1.png" onclick="enlargeImage('f%d_hr_digit1.png')">
<span class="ocr-value" style="font-size: 24px;">D1</span>
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_hr_digit2.png" onclick="enlargeImage('f%d_hr_digit2.png')">
<span class="ocr-value">%d</span>
<span class="%s">(%.0f%%)</span>
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_hr_digit3.png" onclick="enlargeImage('f%d_hr_digit3.png')">
<span class="ocr-value">%d</span>
<span class="%s">(%.0f%%)</span>
</div>
</td>
</tr>
`, e.FrameNum, reviewMarker, e.Timestamp, e.FrameNum, e.FrameNum, e.FailureReason,
e.SpO2Value,
e.FrameNum, e.FrameNum,
e.FrameNum, e.FrameNum,
e.FrameNum, e.FrameNum, e.SpO2LeftDigit, confClass(e.SpO2LeftConf), e.SpO2LeftConf,
e.FrameNum, e.FrameNum, e.SpO2RightDigit, confClass(e.SpO2RightConf), e.SpO2RightConf,
e.HRValue,
e.FrameNum, e.FrameNum,
e.FrameNum, e.FrameNum,
e.FrameNum, e.FrameNum, e.HRLeftDigit, confClass(e.HRLeftConf), e.HRLeftConf,
e.FrameNum, e.FrameNum, e.HRRightDigit, confClass(e.HRRightConf), e.HRRightConf)
} else {
// Successful recognition entry
unstableMarker := ""
if e.Unstable {
unstableMarker = fmt.Sprintf(" <br><span style='color: #ff9800; font-size: 12px;'>⚠️ %s</span>", e.UnstableReason)
}
rowHTML = fmt.Sprintf(` <tr>
<td>%d%s%s</td>
<td>%s</td>
<td>
<img class="digit-img" src="f%d_boxes.jpg" onclick="enlargeImage('f%d_boxes.jpg')">
</td>
<td>
<strong>%d</strong><br>
<div style="margin: 10px 0;">
<img class="digit-img" src="f%d_spo2_checks.png" onclick="enlargeImage('f%d_spo2_checks.png')" style="max-height: 60px; border: 1px solid #444;">
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_spo2_digit1.png" onclick="enlargeImage('f%d_spo2_digit1.png')">
<span class="ocr-value" style="font-size: 24px;">D1</span>
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_spo2_digit2.png" onclick="enlargeImage('f%d_spo2_digit2.png')">
<span class="ocr-value">%d</span>
<span class="%s">(%.0f%%)</span>
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_spo2_digit3.png" onclick="enlargeImage('f%d_spo2_digit3.png')">
<span class="ocr-value">%d</span>
<span class="%s">(%.0f%%)</span>
</div>
</td>
<td>
<strong>%d</strong><br>
<div style="margin: 10px 0;">
<img class="digit-img" src="f%d_hr_checks.png" onclick="enlargeImage('f%d_hr_checks.png')" style="max-height: 60px; border: 1px solid #444;">
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_hr_digit1.png" onclick="enlargeImage('f%d_hr_digit1.png')">
<span class="ocr-value" style="font-size: 24px;">D1</span>
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_hr_digit2.png" onclick="enlargeImage('f%d_hr_digit2.png')">
<span class="ocr-value">%d</span>
<span class="%s">(%.0f%%)</span>
</div>
<div class="digit-box">
<img class="digit-img" src="f%d_hr_digit3.png" onclick="enlargeImage('f%d_hr_digit3.png')">
<span class="ocr-value">%d</span>
<span class="%s">(%.0f%%)</span>
</div>
</td>
</tr>
`, e.FrameNum, reviewMarker, unstableMarker, e.Timestamp,
e.FrameNum, e.FrameNum,
e.SpO2Value,
e.FrameNum, e.FrameNum,
e.FrameNum, e.FrameNum,
e.FrameNum, e.FrameNum, e.SpO2LeftDigit, confClass(e.SpO2LeftConf), e.SpO2LeftConf,
e.FrameNum, e.FrameNum, e.SpO2RightDigit, confClass(e.SpO2RightConf), e.SpO2RightConf,
e.HRValue,
e.FrameNum, e.FrameNum,
e.FrameNum, e.FrameNum,
e.FrameNum, e.FrameNum, e.HRLeftDigit, confClass(e.HRLeftConf), e.HRLeftConf,
e.FrameNum, e.FrameNum, e.HRRightDigit, confClass(e.HRRightConf), e.HRRightConf)
}
// Open file in append mode
f, err := os.OpenFile("review/review.html", os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(rowHTML)
return err
}
// closeReviewHTML writes the closing tags
func closeReviewHTML() error {
footer := ` </table>
<p style="margin-top: 20px;">
<strong>Color coding:</strong>
<span class="conf-high">Green 85%</span>,
<span class="conf-med">Orange 70-84%</span>,
<span class="conf-low">Red &lt;70%</span>
</p>
<p>To add digits to training set, copy files like: <code>cp review/f5_spo2_digit2.png training_digits/9_2.png</code></p>
</body>
</html>`
f, err := os.OpenFile("review/review.html", os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(footer)
return err
}
// writeReviewHTML - kept for backward compatibility (final shutdown)
func writeReviewHTML(entries []ReviewEntry) error {
// Just close the HTML properly - entries already appended
return closeReviewHTML()
}

View File

@ -0,0 +1,957 @@
package main
import (
"fmt"
"image"
"image/color"
"math"
"sort"
"time"
"gocv.io/x/gocv"
)
type Display struct {
IsSplit bool // true = one box split in half, false = two separate boxes
FullRect image.Rectangle // used when IsSplit=true
LeftRect image.Rectangle // used when IsSplit=false
RightRect image.Rectangle // used when IsSplit=false
}
type ScreenLayout struct {
SpO2Area image.Rectangle
HRArea image.Rectangle
}
// rotateImage rotates an image by the specified angle (in degrees)
// Positive angle = counter-clockwise, negative = clockwise
func rotateImage(src gocv.Mat, angleDegrees float64) gocv.Mat {
logMessage(Both, Info, " [rotateImage] Input: %dx%d, Angle: %.3f°", src.Cols(), src.Rows(), angleDegrees)
// Get image center
center := image.Pt(src.Cols()/2, src.Rows()/2)
logMessage(Both, Info, " [rotateImage] Center: (%d,%d)", center.X, center.Y)
// Get rotation matrix (angle in degrees, scale=1.0)
rotMat := gocv.GetRotationMatrix2D(center, angleDegrees, 1.0)
defer rotMat.Close()
logMessage(Both, Info, " [rotateImage] Rotation matrix created")
// Apply rotation
rotated := gocv.NewMat()
gocv.WarpAffineWithParams(src, &rotated, rotMat, image.Pt(src.Cols(), src.Rows()),
gocv.InterpolationLinear, gocv.BorderConstant, color.RGBA{0, 0, 0, 0})
logMessage(Both, Info, " [rotateImage] Output: %dx%d, Empty: %v", rotated.Cols(), rotated.Rows(), rotated.Empty())
return rotated
}
// calculateRotationAngle determines the rotation angle needed to straighten the frame
// by analyzing the bottom horizontal boxes (status indicators below 80% Y)
func calculateRotationAngle(preprocessed gocv.Mat) float64 {
const ROTATION_THRESHOLD = 0.5 // Only rotate if angle > 0.5 degrees
// Threshold to find contours
gray := gocv.NewMat()
gocv.CvtColor(preprocessed, &gray, gocv.ColorBGRToGray)
defer gray.Close()
thresh := gocv.NewMat()
gocv.Threshold(gray, &thresh, 240, 255, gocv.ThresholdBinary)
defer thresh.Close()
// Find all contours
contours := gocv.FindContours(thresh, gocv.RetrievalList, gocv.ChainApproxSimple)
defer contours.Close()
if contours.Size() == 0 {
logMessage(Both, Warning, " [ROTATION] No contours found - no rotation")
return 0.0
}
logMessage(Both, Info, " [ROTATION] Found %d contours", contours.Size())
// FIRST: Calculate bounding box from ALL contours (no filtering)
// This gives us the true extent of the display
if contours.Size() == 0 {
logMessage(Both, Warning, " [ROTATION] No contours - cannot calculate bounding box")
return 0.0
}
firstRect := gocv.BoundingRect(contours.At(0))
minX := firstRect.Min.X
minY := firstRect.Min.Y
maxX := firstRect.Max.X
maxY := firstRect.Max.Y
for i := 1; i < contours.Size(); i++ {
rect := gocv.BoundingRect(contours.At(i))
if rect.Min.X < minX { minX = rect.Min.X }
if rect.Min.Y < minY { minY = rect.Min.Y }
if rect.Max.X > maxX { maxX = rect.Max.X }
if rect.Max.Y > maxY { maxY = rect.Max.Y }
}
boundingBoxHeight := maxY - minY
logMessage(Both, Info, " [ROTATION] Bounding box from ALL contours: Y=%d-%d (H=%d)", minY, maxY, boundingBoxHeight)
// THEN: Get bounding boxes for filtered contours (for rotation calculation)
var boxes []image.Rectangle
for i := 0; i < contours.Size(); i++ {
rect := gocv.BoundingRect(contours.At(i))
// Filter out tiny boxes
if rect.Dx() > 20 && rect.Dy() > 10 {
boxes = append(boxes, rect)
}
}
logMessage(Both, Info, " [ROTATION] %d boxes after size filter (W>20, H>10)", len(boxes))
if len(boxes) < 3 {
logMessage(Both, Warning, " [ROTATION] Only %d boxes found - need at least 3", len(boxes))
return 0.0
}
// Calculate 80% Y threshold relative to bounding box (bottom 20% of bounding box)
threshold80 := minY + int(float64(boundingBoxHeight)*0.8)
logMessage(Both, Info, " [ROTATION] 80%%%% threshold: Y>%d", threshold80)
// Filter to boxes in bottom 20% (Y > 80% of height)
var bottomBoxes []image.Rectangle
for _, box := range boxes {
// Use box center Y for filtering
centerY := box.Min.Y + box.Dy()/2
if centerY > threshold80 {
bottomBoxes = append(bottomBoxes, box)
}
}
logMessage(Both, Info, " [ROTATION] %d boxes in bottom 20%%%% (Y>%d)", len(bottomBoxes), threshold80)
if len(bottomBoxes) < 3 {
logMessage(Both, Warning, " [ROTATION] Only %d boxes in bottom 20%%%% - need at least 3", len(bottomBoxes))
return 0.0
}
// Sort by Y coordinate (descending = bottom to top) to get lowest boxes
sort.Slice(bottomBoxes, func(i, j int) bool {
return bottomBoxes[i].Min.Y > bottomBoxes[j].Min.Y
})
// Take bottom 3 boxes (highest Y coordinates)
topBoxes := bottomBoxes[:3]
logMessage(Both, Info, " [ROTATION] Using 3 lowest boxes:")
for i, box := range topBoxes {
centerY := box.Min.Y + box.Dy()/2
logMessage(Both, Info, " Box %d: X=%d-%d, Y=%d-%d (centerY=%d)",
i, box.Min.X, box.Max.X, box.Min.Y, box.Max.Y, centerY)
}
// DEBUG: Save visualization of the three boxes used for rotation
if DEBUG_MODE {
rotDebugVis := preprocessed.Clone() // Clone the colored frame
// Create bounding box rectangle from calculated values
boundingBox := image.Rect(minX, minY, maxX, maxY)
// Draw bounding box in blue
gocv.Rectangle(&rotDebugVis, boundingBox, color.RGBA{0, 100, 255, 255}, 3)
// Draw 80% threshold line in red (this is what we use to filter boxes)
gocv.Line(&rotDebugVis,
image.Pt(0, threshold80),
image.Pt(preprocessed.Cols(), threshold80),
color.RGBA{255, 0, 0, 255}, 3) // Red line at threshold
// Draw the three selected boxes in green
for _, box := range topBoxes {
gocv.Rectangle(&rotDebugVis, box, color.RGBA{0, 255, 0, 255}, 3)
}
// Add labels
gocv.PutText(&rotDebugVis,
fmt.Sprintf("80%%%% threshold: Y=%d", threshold80),
image.Pt(10, threshold80-10),
gocv.FontHersheyPlain, 1.5, color.RGBA{255, 0, 0, 255}, 2)
gocv.PutText(&rotDebugVis,
fmt.Sprintf("BBox: %dx%d (Y=%d-%d)", boundingBox.Dx(), boundingBox.Dy(), minY, maxY),
image.Pt(minX+10, minY+30),
gocv.FontHersheyPlain, 1.5, color.RGBA{0, 100, 255, 255}, 2)
gocv.IMWrite("test_output/rotation_boxes_BEFORE.png", rotDebugVis)
rotDebugVis.Close()
logMessage(Both, Info, " [ROTATION] Saved rotation_boxes_BEFORE.png")
}
// Find leftmost and rightmost boxes
var leftBox, rightBox image.Rectangle
leftBox = topBoxes[0]
rightBox = topBoxes[0]
for _, box := range topBoxes[1:] {
if box.Min.X < leftBox.Min.X {
leftBox = box
}
if box.Min.X > rightBox.Min.X {
rightBox = box
}
}
// Get center Y of each box
leftY := leftBox.Min.Y + leftBox.Dy()/2
rightY := rightBox.Min.Y + rightBox.Dy()/2
leftX := leftBox.Min.X + leftBox.Dx()/2
rightX := rightBox.Min.X + rightBox.Dx()/2
logMessage(Both, Info, " [ROTATION] Left box center: (%d,%d), Right box center: (%d,%d)", leftX, leftY, rightX, rightY)
// Calculate rotation angle
if rightX == leftX {
logMessage(Both, Info, " [ROTATION] Boxes vertically aligned - no rotation")
return 0.0
}
slope := float64(rightY-leftY) / float64(rightX-leftX)
angleDeg := math.Atan(slope) * 180.0 / math.Pi
logMessage(Both, Info, " [ROTATION] Slope=%.4f, Angle=%.3f°", slope, angleDeg)
// Apply threshold
if angleDeg > -ROTATION_THRESHOLD && angleDeg < ROTATION_THRESHOLD {
logMessage(Both, Info, " [ROTATION] Angle %.3f° below threshold %.1f° - no rotation", angleDeg, ROTATION_THRESHOLD)
return 0.0
}
logMessage(Both, Info, " [ROTATION] Will rotate by %.3f°", angleDeg)
return angleDeg
}
func detectScreenLayoutAreas(rotated gocv.Mat) (*ScreenLayout, float64, error) {
// Correct order: Find center region FIRST (using all contours), THEN find digit displays
startTime := time.Now()
// Input is already thresholded binary image - work directly on it
// No grayscale, no threshold - just find contours
// DEBUG: Save input to see what we're working with
if DEBUG_MODE {
gocv.IMWrite("test_output/layout_step1_input.png", rotated)
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step1_input.png")
}
// STEP 3: Apply erosion to separate nearby elements (5x5 kernel) - COMMENTED OUT
// step3Start := time.Now()
// kernel := gocv.GetStructuringElement(gocv.MorphRect, image.Pt(5, 5))
// eroded := gocv.NewMat()
// gocv.Erode(thresh, &eroded, kernel)
// kernel.Close()
// defer eroded.Close()
// fmt.Printf(" [TIMING] Step 3 (Erosion): %dms\n", time.Since(step3Start).Milliseconds())
// STEP 2: Find contours - use RetrievalList to get ALL contours including nested
step2Start := time.Now()
contours := gocv.FindContours(rotated, gocv.RetrievalList, gocv.ChainApproxSimple)
defer contours.Close()
logMessage(Both, Info, " Found %d total contours", contours.Size())
if contours.Size() == 0 {
logMessage(Both, Error, " ERROR: No contours found in thresholded frame")
logMessage(Both, Error, " Frame dimensions: %dx%d", rotated.Cols(), rotated.Rows())
// Save debug image
gocv.IMWrite("test_output/error_input.png", rotated)
logMessage(Both, Error, " Saved debug image to test_output/error_input.png")
return nil, 0, nil
}
logMessage(LogFile, Debug, " [TIMING] Step 2 (Find contours): %dms", time.Since(step2Start).Milliseconds())
logMessage(LogFile, Debug, " [TIMING] Step 2 (Total): %dms", time.Since(startTime).Milliseconds())
// STEP 1: Collect ALL boxes (no filter) and print details
var allBoxes []image.Rectangle
for i := 0; i < contours.Size(); i++ {
rect := gocv.BoundingRect(contours.At(i))
allBoxes = append(allBoxes, rect)
// fmt.Printf(" Box %d: X=%d-%d (W=%d), Y=%d-%d (H=%d)\n",
// i, rect.Min.X, rect.Max.X, rect.Dx(),
// rect.Min.Y, rect.Max.Y, rect.Dy())
}
if len(allBoxes) == 0 {
logMessage(Both, Error, " ERROR: No bounding boxes found from contours")
return nil, 0, nil
}
logMessage(LogFile, Debug, " Found %d boxes (all, no filter)", len(allBoxes))
logMessage(LogFile, Debug, "")
// DEBUG: Skip step 6a and 6b visualizations
// rawContoursVis := rotated.Clone()
// ...
// allBoxesVis := rotated.Clone()
// ...
// STEP 2: Filter to significant boxes (width or height > 30px) for processing
var significantBoxes []image.Rectangle
for _, box := range allBoxes {
if box.Dx() > 30 || box.Dy() > 30 {
significantBoxes = append(significantBoxes, box)
}
}
if len(significantBoxes) == 0 {
logMessage(Both, Error, " ERROR: No significant boxes found (>30px)")
logMessage(Both, Error, " All %d boxes were too small", len(allBoxes))
return nil, 0, nil
}
logMessage(LogFile, Debug, " Found %d significant boxes (>30px) for processing", len(significantBoxes))
// STEP 3: Calculate bounding box from ALL boxes (not just significant ones)
// This ensures bottom small numbers are included in the height calculation
minX := allBoxes[0].Min.X
minY := allBoxes[0].Min.Y
maxX := allBoxes[0].Max.X
maxY := allBoxes[0].Max.Y
for _, box := range allBoxes {
if box.Min.X < minX {
minX = box.Min.X
}
if box.Min.Y < minY {
minY = box.Min.Y
}
if box.Max.X > maxX {
maxX = box.Max.X
}
if box.Max.Y > maxY {
maxY = box.Max.Y
}
}
boundingBox := image.Rect(minX, minY, maxX, maxY)
boundingBoxWidth := boundingBox.Dx()
logMessage(LogFile, Debug, " Bounding box from all boxes: X=%d-%d, Y=%d-%d, Width=%d", minX, maxX, minY, maxY, boundingBoxWidth)
// Calculate required scale to normalize to 860px width
const TARGET_WIDTH = 860
scale := float64(TARGET_WIDTH) / float64(boundingBoxWidth)
logMessage(Both, Info, " 📊 Bounding box width: %dpx, Scale factor: %.3f (target: %dpx)", boundingBoxWidth, scale, TARGET_WIDTH)
// STEP 4: Scale the frame to 860px width
step4Start := time.Now()
newWidth := int(float64(rotated.Cols()) * scale)
newHeight := int(float64(rotated.Rows()) * scale)
scaled := gocv.NewMat()
gocv.Resize(rotated, &scaled, image.Pt(newWidth, newHeight), 0, 0, gocv.InterpolationLinear)
defer scaled.Close()
logMessage(LogFile, Debug, " [TIMING] Step 4 (Scale frame): %dms", time.Since(step4Start).Milliseconds())
logMessage(LogFile, Debug, " Scaled frame: %dx%d -> %dx%d", rotated.Cols(), rotated.Rows(), newWidth, newHeight)
// CRITICAL: Scale the bounding box coordinates to match the scaled frame
scaledMinX := int(float64(minX) * scale)
scaledMaxX := int(float64(maxX) * scale)
scaledMinY := int(float64(minY) * scale)
scaledMaxY := int(float64(maxY) * scale)
scaledBoundingBox := image.Rect(scaledMinX, scaledMinY, scaledMaxX, scaledMaxY)
logMessage(LogFile, Debug, " Scaled bounding box: X=%d-%d, Y=%d-%d (from original: X=%d-%d, Y=%d-%d)",
scaledMinX, scaledMaxX, scaledMinY, scaledMaxY, minX, maxX, minY, maxY)
// Scale all significant boxes coordinates too
var scaledSignificantBoxes []image.Rectangle
for _, box := range significantBoxes {
scaledBox := image.Rect(
int(float64(box.Min.X)*scale),
int(float64(box.Min.Y)*scale),
int(float64(box.Max.X)*scale),
int(float64(box.Max.Y)*scale),
)
scaledSignificantBoxes = append(scaledSignificantBoxes, scaledBox)
}
// Scale all boxes too (for visualization)
var scaledAllBoxes []image.Rectangle
for _, box := range allBoxes {
scaledBox := image.Rect(
int(float64(box.Min.X)*scale),
int(float64(box.Min.Y)*scale),
int(float64(box.Max.X)*scale),
int(float64(box.Max.Y)*scale),
)
scaledAllBoxes = append(scaledAllBoxes, scaledBox)
}
// All subsequent processing now works on the SCALED frame with SCALED coordinates
// This ensures boxes are calculated in scaled coordinates
// STEP 5: Find 50% line
height := scaledBoundingBox.Dy()
line50 := scaledBoundingBox.Min.Y + height/2
logMessage(LogFile, Debug, " 50%% line at Y=%d", line50)
// DEBUG: Save step 5 visualization - bounding box and 50% line
bboxVis := gocv.NewMat()
gocv.CvtColor(scaled, &bboxVis, gocv.ColorGrayToBGR)
// Draw bounding box in blue
gocv.Rectangle(&bboxVis, scaledBoundingBox, color.RGBA{0, 100, 255, 255}, 3)
// Draw 50% line in red
gocv.Line(&bboxVis, image.Pt(scaledMinX, line50), image.Pt(scaledMaxX, line50), color.RGBA{255, 0, 0, 255}, 3)
// Add labels
gocv.PutText(&bboxVis, fmt.Sprintf("BBox: %d-%d, %d-%d", scaledMinX, scaledMaxX, scaledMinY, scaledMaxY),
image.Pt(scaledMinX+10, scaledMinY+30),
gocv.FontHersheyPlain, 1.2, color.RGBA{0, 100, 255, 255}, 2)
gocv.PutText(&bboxVis, fmt.Sprintf("50%% line: Y=%d", line50),
image.Pt(scaledMinX+10, line50-10),
gocv.FontHersheyPlain, 1.2, color.RGBA{255, 0, 0, 255}, 2)
gocv.IMWrite("test_output/layout_step5_bbox_and_line.png", bboxVis)
bboxVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step5_bbox_and_line.png")
// DEBUG: Save step 6 - all boxes with numbers
allBoxesVis := gocv.NewMat()
gocv.CvtColor(scaled, &allBoxesVis, gocv.ColorGrayToBGR)
for i, box := range scaledAllBoxes {
gocv.Rectangle(&allBoxesVis, box, color.RGBA{0, 255, 0, 255}, 2)
// Add box number
gocv.PutText(&allBoxesVis, fmt.Sprintf("%d", i),
image.Pt(box.Min.X+5, box.Min.Y+15),
gocv.FontHersheyPlain, 1.2, color.RGBA{0, 255, 0, 255}, 2)
}
gocv.IMWrite("test_output/layout_step6_all_boxes.png", allBoxesVis)
allBoxesVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step6_all_boxes.png")
// STEP 6: Filter to boxes crossing 50% line (center region)
var centerBoxes []image.Rectangle
for _, box := range scaledSignificantBoxes {
if box.Min.Y < line50 && box.Max.Y > line50 {
centerBoxes = append(centerBoxes, box)
}
}
if len(centerBoxes) == 0 {
logMessage(Both, Error, " ERROR: No boxes crossing 50%%%% line")
logMessage(Both, Error, " 50%%%% line at Y=%d, checked %d significant boxes", line50, len(scaledSignificantBoxes))
return nil, 0, nil
}
logMessage(LogFile, Debug, " Found %d boxes crossing 50%% line (center region)", len(centerBoxes))
// DEBUG: Save step 7 - center boxes visualization
centerBoxesVis := gocv.NewMat()
gocv.CvtColor(scaled, &centerBoxesVis, gocv.ColorGrayToBGR)
// Draw 50% line
gocv.Line(&centerBoxesVis, image.Pt(scaledMinX, line50), image.Pt(scaledMaxX, line50), color.RGBA{255, 0, 0, 255}, 3)
// Draw center boxes
for _, box := range centerBoxes {
gocv.Rectangle(&centerBoxesVis, box, color.RGBA{0, 255, 255, 255}, 2)
}
gocv.IMWrite("test_output/layout_step7_center_boxes.png", centerBoxesVis)
centerBoxesVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step7_center_boxes.png")
// STEP 7: From center boxes, identify digit displays (height >= 110px)
var digitDisplays []image.Rectangle
for _, box := range centerBoxes {
logMessage(LogFile, Debug, " Center box: X=%d-%d (W=%d), Y=%d-%d (H=%d)",
box.Min.X, box.Max.X, box.Dx(),
box.Min.Y, box.Max.Y, box.Dy())
if box.Dy() >= 110 {
digitDisplays = append(digitDisplays, box)
logMessage(LogFile, Debug, " ✓ Added as digit display (H=%d >= 110)", box.Dy())
} else {
logMessage(LogFile, Debug, " ✗ Skipped (H=%d < 110)", box.Dy())
}
}
if len(digitDisplays) == 0 {
logMessage(Both, Error, " ERROR: No digit displays found (height >= 110px) in center region")
logMessage(Both, Error, " Found %d center boxes, none met height requirement", len(centerBoxes))
return nil, 0, nil
}
logMessage(LogFile, Debug, " Found %d digit displays (height > 120px) BEFORE trimming", len(digitDisplays))
// DEBUG: Save step 8 - digit displays visualization
if DEBUG_MODE {
digitDisplaysVis := gocv.NewMat()
gocv.CvtColor(scaled, &digitDisplaysVis, gocv.ColorGrayToBGR)
for _, box := range digitDisplays {
gocv.Rectangle(&digitDisplaysVis, box, color.RGBA{255, 0, 255, 255}, 3)
}
gocv.IMWrite("test_output/layout_step8_digit_displays.png", digitDisplaysVis)
digitDisplaysVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step8_digit_displays.png")
}
// STEP 8: Get Y range from digit displays
minDigitY := digitDisplays[0].Min.Y
maxDigitY := digitDisplays[0].Max.Y
for _, box := range digitDisplays {
if box.Min.Y < minDigitY {
minDigitY = box.Min.Y
}
if box.Max.Y > maxDigitY {
maxDigitY = box.Max.Y
}
}
logMessage(LogFile, Debug, " Digit display Y range: %d-%d", minDigitY, maxDigitY)
// STEP 9: Find ALL boxes (from scaledAllBoxes) within this Y range
var boxesInRange []image.Rectangle
for _, box := range scaledAllBoxes {
// Box overlaps with Y range if its bottom is below minDigitY and top is above maxDigitY
if box.Max.Y > minDigitY && box.Min.Y < maxDigitY {
boxesInRange = append(boxesInRange, box)
}
}
logMessage(LogFile, Debug, " Found %d boxes in Y range %d-%d", len(boxesInRange), minDigitY, maxDigitY)
// DEBUG: Save step 9 - boxes in range visualization
boxesInRangeVis := gocv.NewMat()
gocv.CvtColor(scaled, &boxesInRangeVis, gocv.ColorGrayToBGR)
// Draw Y range lines
gocv.Line(&boxesInRangeVis, image.Pt(0, minDigitY), image.Pt(boxesInRangeVis.Cols(), minDigitY), color.RGBA{255, 0, 0, 255}, 2)
gocv.Line(&boxesInRangeVis, image.Pt(0, maxDigitY), image.Pt(boxesInRangeVis.Cols(), maxDigitY), color.RGBA{255, 0, 0, 255}, 2)
// Draw digit displays in magenta
for _, box := range digitDisplays {
gocv.Rectangle(&boxesInRangeVis, box, color.RGBA{255, 0, 255, 255}, 3)
}
// Draw all boxes in range in cyan
for _, box := range boxesInRange {
gocv.Rectangle(&boxesInRangeVis, box, color.RGBA{0, 255, 255, 255}, 1)
}
gocv.IMWrite("test_output/layout_step9_boxes_in_range.png", boxesInRangeVis)
boxesInRangeVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step9_boxes_in_range.png")
// STEP 10: Trim digit displays based on small boxes crossing their right edge
for i := range digitDisplays {
originalMaxX := digitDisplays[i].Max.X
newMaxX := originalMaxX
// Check each box in range
for _, smallBox := range boxesInRange {
// Skip if this is the digit display itself
if smallBox == digitDisplays[i] {
continue
}
// Check if small box crosses the right edge of this digit display
// Crosses if: smallBox.Min.X < digitDisplay.Max.X AND smallBox.Max.X > digitDisplay.Max.X
if smallBox.Min.X < digitDisplays[i].Max.X && smallBox.Max.X > digitDisplays[i].Max.X {
// Use the left edge of the small box as the new right edge
if smallBox.Min.X < newMaxX {
newMaxX = smallBox.Min.X
logMessage(LogFile, Debug, " Trimming digit display %d: right edge %d -> %d (small box at X=%d-%d)",
i, originalMaxX, newMaxX, smallBox.Min.X, smallBox.Max.X)
}
}
}
digitDisplays[i].Max.X = newMaxX
}
logMessage(LogFile, Debug, " Digit displays AFTER trimming: %d", len(digitDisplays))
// DEBUG: Save step 11 - trimmed digit displays visualization
trimmedVis := gocv.NewMat()
gocv.CvtColor(scaled, &trimmedVis, gocv.ColorGrayToBGR)
for _, box := range digitDisplays {
gocv.Rectangle(&trimmedVis, box, color.RGBA{0, 255, 0, 255}, 4)
}
gocv.IMWrite("test_output/layout_step11_trimmed_displays.png", trimmedVis)
trimmedVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step11_trimmed_displays.png")
if len(digitDisplays) < 2 {
// Save debug visualization showing what was found
debugVis := gocv.NewMat()
gocv.CvtColor(scaled, &debugVis, gocv.ColorGrayToBGR)
// Draw 50% line
gocv.Line(&debugVis, image.Pt(scaledMinX, line50), image.Pt(scaledMaxX, line50), color.RGBA{255, 0, 0, 255}, 2)
gocv.PutText(&debugVis, fmt.Sprintf("50%% line: Y=%d", line50),
image.Pt(10, line50-10), gocv.FontHersheyPlain, 1.5, color.RGBA{255, 0, 0, 255}, 2)
// Draw all center boxes with labels
for i, box := range centerBoxes {
boxColor := color.RGBA{0, 255, 255, 255} // Cyan for rejected
label := fmt.Sprintf("#%d: H=%d", i, box.Dy())
// Check if this box qualified as digit display
qualified := false
for _, dd := range digitDisplays {
if box == dd {
qualified = true
break
}
}
if qualified {
boxColor = color.RGBA{0, 255, 0, 255} // Green for qualified
label += " OK"
} else if box.Dy() < 110 {
label += " TOO SHORT"
}
gocv.Rectangle(&debugVis, box, boxColor, 3)
gocv.PutText(&debugVis, label,
image.Pt(box.Min.X+5, box.Min.Y+25),
gocv.FontHersheyPlain, 1.5, boxColor, 2)
}
// Add summary at top
summary := fmt.Sprintf("Found: %d center boxes, %d digit displays (need 2)",
len(centerBoxes), len(digitDisplays))
gocv.PutText(&debugVis, summary,
image.Pt(10, 30), gocv.FontHersheyDuplex, 1.0, color.RGBA{255, 255, 255, 255}, 2)
gocv.PutText(&debugVis, "Requirement: Height >= 110px",
image.Pt(10, 60), gocv.FontHersheyDuplex, 1.0, color.RGBA{255, 255, 255, 255}, 2)
timestamp := time.Now().Format("20060102_150405")
errorFilename := fmt.Sprintf("test_output/layout_error_%s.png", timestamp)
gocv.IMWrite(errorFilename, debugVis)
debugVis.Close()
// Enhanced error message
logMessage(Both, Error, " ERROR: Only %d digit display(s) found (need 2)", len(digitDisplays))
logMessage(Both, Error, " ")
logMessage(Both, Error, " LIKELY CAUSE: Pulse oximeter is not centered under camera")
logMessage(Both, Error, " - Device may be at an angle or offset to one side")
logMessage(Both, Error, " - Only one display (SpO2 or HR) is in the center detection region")
logMessage(Both, Error, " ")
logMessage(Both, Error, " ACTION REQUIRED: Physically reposition camera or device")
logMessage(Both, Error, " - Move device to be centered and level under camera")
logMessage(Both, Error, " - Or adjust camera angle to capture both displays")
logMessage(Both, Error, " ")
logMessage(Both, Error, " Technical details:")
logMessage(Both, Error, " Center boxes found: %d (crossing 50%% line at Y=%d)", len(centerBoxes), line50)
for i, box := range centerBoxes {
qualified := ""
for _, dd := range digitDisplays {
if box == dd {
qualified = " ✓ QUALIFIED"
break
}
}
if qualified == "" {
if box.Dy() < 110 {
qualified = " ✗ TOO SHORT (< 110px)"
}
}
logMessage(Both, Error, " Box #%d: W=%dpx, H=%dpx at X=%d-%d, Y=%d-%d%s",
i, box.Dx(), box.Dy(), box.Min.X, box.Max.X, box.Min.Y, box.Max.Y, qualified)
}
logMessage(Both, Error, " 💾 Debug image saved: %s", errorFilename)
logMessage(Both, Error, " Green boxes = qualified, Cyan = rejected")
return nil, 0, nil
}
// STEP 11: Get Y range from digit displays
minCenterY := digitDisplays[0].Min.Y
maxCenterY := digitDisplays[0].Max.Y
for _, box := range digitDisplays {
if box.Min.Y < minCenterY {
minCenterY = box.Min.Y
}
if box.Max.Y > maxCenterY {
maxCenterY = box.Max.Y
}
}
// Create center region
centerRegion := image.Rect(scaledBoundingBox.Min.X, minCenterY, scaledBoundingBox.Max.X, maxCenterY)
// STEP 12: Find X-center to split left/right
centerX := centerRegion.Min.X + centerRegion.Dx()/2
logMessage(LogFile, Debug, " Center X: %d", centerX)
// STEP 13: Find rightmost X and Y range in each half
spo2RightX := -1
spo2MinY := 10000
spo2MaxY := 0
hrRightX := -1
hrMinY := 10000
hrMaxY := 0
for _, box := range digitDisplays {
// Determine which half this box belongs to based on its center
boxCenterX := box.Min.X + box.Dx()/2
if boxCenterX < centerX {
// Left half (SpO2)
if box.Max.X > spo2RightX {
spo2RightX = box.Max.X
}
if box.Min.Y < spo2MinY {
spo2MinY = box.Min.Y
}
if box.Max.Y > spo2MaxY {
spo2MaxY = box.Max.Y
}
} else {
// Right half (HR)
if box.Max.X > hrRightX {
hrRightX = box.Max.X
}
if box.Min.Y < hrMinY {
hrMinY = box.Min.Y
}
if box.Max.Y > hrMaxY {
hrMaxY = box.Max.Y
}
}
}
if spo2RightX == -1 || hrRightX == -1 {
logMessage(Both, Error, " ERROR: Could not find displays in both halves")
logMessage(Both, Error, " Left half (SpO2): rightX=%d, Right half (HR): rightX=%d", spo2RightX, hrRightX)
return nil, 0, nil
}
// STEP 14: Create boxes with fixed CUT_WIDTH
spo2LeftX := spo2RightX - CUT_WIDTH
spo2Box := image.Rect(spo2LeftX, spo2MinY, spo2RightX, spo2MaxY)
hrLeftX := hrRightX - CUT_WIDTH
hrBox := image.Rect(hrLeftX, hrMinY, hrRightX, hrMaxY)
logMessage(LogFile, Debug, " Final SpO2 box: X=%d-%d, Y=%d-%d", spo2Box.Min.X, spo2Box.Max.X, spo2Box.Min.Y, spo2Box.Max.Y)
logMessage(LogFile, Debug, " Final HR box: X=%d-%d, Y=%d-%d", hrBox.Min.X, hrBox.Max.X, hrBox.Min.Y, hrBox.Max.Y)
// DEBUG: Save step 15 - final boxes visualization
finalVis := gocv.NewMat()
gocv.CvtColor(scaled, &finalVis, gocv.ColorGrayToBGR)
gocv.Rectangle(&finalVis, spo2Box, color.RGBA{255, 0, 0, 255}, 4)
gocv.Rectangle(&finalVis, hrBox, color.RGBA{0, 255, 255, 255}, 4)
gocv.PutText(&finalVis, "SpO2", image.Pt(spo2Box.Min.X, spo2Box.Min.Y-10),
gocv.FontHersheyDuplex, 1.2, color.RGBA{255, 0, 0, 255}, 2)
gocv.PutText(&finalVis, "HR", image.Pt(hrBox.Min.X, hrBox.Min.Y-10),
gocv.FontHersheyDuplex, 1.2, color.RGBA{0, 255, 255, 255}, 2)
gocv.IMWrite("test_output/layout_step15_final_boxes.png", finalVis)
finalVis.Close()
logMessage(LogFile, Debug, " [DEBUG] Saved layout_step15_final_boxes.png")
logMessage(LogFile, Debug, " [TIMING] Total layout detection: %dms", time.Since(startTime).Milliseconds())
// Return layout and scale factor
return &ScreenLayout{
SpO2Area: spo2Box,
HRArea: hrBox,
}, scale, nil
}
// Old functions kept for backward compatibility but deprecated
func groupAdjacentContours(rects []image.Rectangle) []Display {
var displays []Display
used := make([]bool, len(rects))
logMessage(LogFile, Debug, " Grouping %d candidate contours:", len(rects))
for i := 0; i < len(rects); i++ {
if used[i] {
continue
}
// Look for an adjacent rect with similar Y coordinate
var adjacent *int
for j := i + 1; j < len(rects); j++ {
if used[j] {
continue
}
// Check if Y coordinates are similar (within 20 pixels)
yDiff := rects[i].Min.Y - rects[j].Min.Y
if yDiff < 0 {
yDiff = -yDiff
}
if yDiff > 20 {
logMessage(LogFile, Debug, " Skip pairing rect[%d] and rect[%d]: Y diff=%d > 20", i, j, yDiff)
continue
}
// Check if they're side-by-side (allow small overlap or gap)
xGap := rects[j].Min.X - rects[i].Max.X
// Allow up to 50 pixels of overlap (negative gap) or up to 200 pixels of space
if xGap >= -50 && xGap < 200 {
logMessage(LogFile, Debug, " Pairing rect[%d] and rect[%d]: Y diff=%d, X gap=%d", i, j, yDiff, xGap)
adjacent = &j
break
} else {
logMessage(LogFile, Debug, " Skip pairing rect[%d] and rect[%d]: X gap=%d not in range [-50, 200)", i, j, xGap)
}
}
if adjacent != nil {
// Two separate contours - use them as left and right
used[i] = true
used[*adjacent] = true
left := rects[i]
right := rects[*adjacent]
// Ensure left is actually on the left
if left.Min.X > right.Min.X {
left, right = right, left
}
logMessage(LogFile, Debug, " DUAL: [%dx%d @(%d,%d)] + [%dx%d @(%d,%d)]",
left.Dx(), left.Dy(), left.Min.X, left.Min.Y,
right.Dx(), right.Dy(), right.Min.X, right.Min.Y)
displays = append(displays, Display{
IsSplit: false,
LeftRect: left,
RightRect: right,
})
} else {
// Single contour - will be split in half
used[i] = true
logMessage(LogFile, Debug, " SPLIT: [%dx%d @(%d,%d)]",
rects[i].Dx(), rects[i].Dy(), rects[i].Min.X, rects[i].Min.Y)
displays = append(displays, Display{
IsSplit: true,
FullRect: rects[i],
})
}
}
return displays
}
func detectScreenLayout(rotated gocv.Mat) ([]Display, error) {
gray := gocv.NewMat()
gocv.CvtColor(rotated, &gray, gocv.ColorBGRToGray)
thresh := gocv.NewMat()
gocv.Threshold(gray, &thresh, 170, 255, gocv.ThresholdBinary)
gray.Close()
contours := gocv.FindContours(thresh, gocv.RetrievalExternal, gocv.ChainApproxSimple)
thresh.Close()
var rects []image.Rectangle
for i := 0; i < contours.Size(); i++ {
rect := gocv.BoundingRect(contours.At(i))
if rect.Dy() > MIN_BOX_HEIGHT && rect.Dy() < 200 {
rects = append(rects, rect)
}
}
contours.Close()
sort.Slice(rects, func(i, j int) bool {
return rects[i].Dy() > rects[j].Dy()
})
if len(rects) < 3 {
return nil, fmt.Errorf("found only %d contours (need 3+)", len(rects))
}
// Take up to 6 candidates to ensure we capture split digits
maxCands := 6
if len(rects) < maxCands+1 {
maxCands = len(rects) - 1
}
cands := rects[1 : 1+maxCands]
// Sort by Y position for processing (Y desc, then X asc)
sort.Slice(cands, func(i, j int) bool {
if cands[i].Min.Y != cands[j].Min.Y {
return cands[i].Min.Y > cands[j].Min.Y
}
return cands[i].Min.X < cands[j].Min.X
})
logMessage(LogFile, Debug, " Selected %d candidates (excluding largest):", len(cands))
for idx, rect := range cands {
logMessage(LogFile, Debug, " [%d]: %dx%d @(%d,%d)", idx, rect.Dx(), rect.Dy(), rect.Min.X, rect.Min.Y)
}
// Group adjacent contours
displays := groupAdjacentContours(cands)
logMessage(LogFile, Debug, " Found %d displays after grouping", len(displays))
// Group displays by Y position to find the digit row
// Displays within 30 pixels vertically are considered same row
type YGroup struct {
avgY int
displays []Display
}
var yGroups []YGroup
for _, disp := range displays {
dispY := disp.FullRect.Min.Y
if !disp.IsSplit {
dispY = disp.LeftRect.Min.Y
}
// Find existing group with similar Y
found := false
for i := range yGroups {
if math.Abs(float64(dispY-yGroups[i].avgY)) < 30 {
yGroups[i].displays = append(yGroups[i].displays, disp)
found = true
break
}
}
if !found {
yGroups = append(yGroups, YGroup{avgY: dispY, displays: []Display{disp}})
}
}
logMessage(LogFile, Debug, " Grouped into %d Y-bands:", len(yGroups))
for idx, group := range yGroups {
logMessage(LogFile, Debug, " Band %d (Y~%d): %d displays", idx, group.avgY, len(group.displays))
}
// Find the band with exactly 2 displays (SpO2 and HR)
var digitDisplays []Display
for _, group := range yGroups {
if len(group.displays) == 2 {
digitDisplays = group.displays
break
}
}
if len(digitDisplays) != 2 {
return nil, fmt.Errorf("could not find Y-band with exactly 2 displays (found %d bands)", len(yGroups))
}
// Sort by X position: left = SpO2, right = HR
sort.Slice(digitDisplays, func(i, j int) bool {
xI := digitDisplays[i].FullRect.Min.X
if !digitDisplays[i].IsSplit {
xI = digitDisplays[i].LeftRect.Min.X
}
xJ := digitDisplays[j].FullRect.Min.X
if !digitDisplays[j].IsSplit {
xJ = digitDisplays[j].LeftRect.Min.X
}
return xI < xJ
})
return digitDisplays, nil
}
func saveLayoutVisualization(rotated gocv.Mat, layout *ScreenLayout, filename string) {
visualization := rotated.Clone()
// Draw SpO2 box in red
red := color.RGBA{255, 0, 0, 255}
gocv.Rectangle(&visualization, layout.SpO2Area, red, 3)
gocv.PutText(&visualization, "SpO2", image.Pt(layout.SpO2Area.Min.X, layout.SpO2Area.Min.Y-10),
gocv.FontHersheyDuplex, 1.2, red, 2)
// Draw HR box in cyan
cyan := color.RGBA{0, 255, 255, 255}
gocv.Rectangle(&visualization, layout.HRArea, cyan, 3)
gocv.PutText(&visualization, "HR", image.Pt(layout.HRArea.Min.X, layout.HRArea.Min.Y-10),
gocv.FontHersheyDuplex, 1.2, cyan, 2)
gocv.IMWrite(filename, visualization)
visualization.Close()
}

View File

@ -0,0 +1,569 @@
package main
import (
"fmt"
"image"
"image/color"
"io"
"os"
"strings"
"time"
"gocv.io/x/gocv"
)
func loadTemplates() (map[int][]gocv.Mat, error) {
templates := make(map[int][]gocv.Mat)
files, err := os.ReadDir("training_digits")
if err != nil {
logMessage(Console, Warning, "⚠️ WARNING: training_digits directory not found or cannot be read: %v", err)
logMessage(Console, Warning, "⚠️ Starting with empty template set. Recognition will not work until templates are added.")
logMessage(Console, Info, "")
return templates, nil
}
templateCount := 0
for _, file := range files {
if !strings.HasSuffix(file.Name(), ".png") {
continue
}
name := strings.TrimSuffix(file.Name(), ".png")
parts := strings.Split(name, "_")
var digit int
_, err := fmt.Sscanf(parts[0], "%d", &digit)
if err != nil || digit < 0 || digit > 9 {
continue
}
filename := fmt.Sprintf("training_digits/%s", file.Name())
template := gocv.IMRead(filename, gocv.IMReadGrayScale)
if template.Empty() {
logMessage(Console, Warning, "Warning: Failed to load %s", filename)
continue
}
templates[digit] = append(templates[digit], template)
templateCount++
}
// Load invalid digit markers (use index -1 for invalid patterns)
invalidFiles, err := os.ReadDir("training_digits/invalid")
if err == nil {
invalidCount := 0
for _, file := range invalidFiles {
if !strings.HasSuffix(file.Name(), ".png") {
continue
}
filename := fmt.Sprintf("training_digits/invalid/%s", file.Name())
template := gocv.IMRead(filename, gocv.IMReadGrayScale)
if template.Empty() {
logMessage(Console, Warning, "Warning: Failed to load invalid template %s", filename)
continue
}
templates[-1] = append(templates[-1], template)
invalidCount++
}
if invalidCount > 0 {
logMessage(Console, Info, "✓ Loaded %d invalid digit markers", invalidCount)
}
}
for digit := 0; digit <= 9; digit++ {
if len(templates[digit]) == 0 {
logMessage(Console, Warning, "⚠️ WARNING: No templates found for digit %d", digit)
}
}
logMessage(Console, Info, "✓ Loaded %d digit templates (0-9)", templateCount)
return templates, nil
}
func matchDigit(digitImg gocv.Mat, templates map[int][]gocv.Mat) (int, float64) {
bestDigit := -1
bestScore := 0.0
bestInvalidScore := 0.0
// Check invalid patterns first (stored at index -1)
invalidTemplates := templates[-1]
for _, template := range invalidTemplates {
compareImg := digitImg
if digitImg.Rows() != template.Rows() || digitImg.Cols() != template.Cols() {
resized := gocv.NewMat()
gocv.Resize(digitImg, &resized, image.Pt(template.Cols(), template.Rows()), 0, 0, gocv.InterpolationLinear)
defer resized.Close()
compareImg = resized
}
diff := gocv.NewMat()
gocv.AbsDiff(compareImg, template, &diff)
totalPixels := compareImg.Rows() * compareImg.Cols()
diffPixels := gocv.CountNonZero(diff)
samePixels := totalPixels - diffPixels
score := (float64(samePixels) / float64(totalPixels)) * 100.0
diff.Close()
if score > bestInvalidScore {
bestInvalidScore = score
}
}
// Check regular digits (0-9)
for digit := 0; digit <= 9; digit++ {
digitTemplates := templates[digit]
if len(digitTemplates) == 0 {
// No templates for this digit, skip
continue
}
for _, template := range digitTemplates {
compareImg := digitImg
if digitImg.Rows() != template.Rows() || digitImg.Cols() != template.Cols() {
resized := gocv.NewMat()
gocv.Resize(digitImg, &resized, image.Pt(template.Cols(), template.Rows()), 0, 0, gocv.InterpolationLinear)
defer resized.Close()
compareImg = resized
}
diff := gocv.NewMat()
gocv.AbsDiff(compareImg, template, &diff)
totalPixels := compareImg.Rows() * compareImg.Cols()
diffPixels := gocv.CountNonZero(diff)
samePixels := totalPixels - diffPixels
score := (float64(samePixels) / float64(totalPixels)) * 100.0
diff.Close()
if score > bestScore {
bestScore = score
bestDigit = digit
}
}
}
// If invalid pattern matches better than any digit (and score > 70%), return -1
if bestInvalidScore > bestScore && bestInvalidScore > 70 {
return -1, bestInvalidScore
}
return bestDigit, bestScore
}
// Helper function: check if there's a '1' digit by cutting at the expected width and matching templates
// ONLY checks against '1' templates to prevent false matches with '4', '7', etc.
func hasOneAt(thresh gocv.Mat, x int, templates map[int][]gocv.Mat, logger io.Writer) bool {
h := thresh.Rows()
w := thresh.Cols()
// Calculate the region where a '1' would be if it exists
// '1' is 72px wide, so extract from (x - 72) to x
leftEdge := x - DIGIT_ONE_WIDTH
if leftEdge < 0 {
leftEdge = 0
}
if x > w {
x = w
}
logMessage(Both, Debug, " hasOneAt(x=%d): extracting region [%d..%d] (width=%d) from display width=%d",
x, leftEdge, x, x-leftEdge, w)
// Extract the potential '1' region
region := thresh.Region(image.Rect(leftEdge, 0, x, h))
digitImg := region.Clone()
region.Close()
// Match ONLY against '1' templates (don't check other digits)
oneTemplates := templates[1]
if len(oneTemplates) == 0 {
logMessage(LogFile, Warning, " hasOneAt(x=%d): No '1' templates loaded -> NO", x)
digitImg.Close()
return false
}
bestScore := 0.0
for _, template := range oneTemplates {
compareImg := digitImg
if digitImg.Rows() != template.Rows() || digitImg.Cols() != template.Cols() {
resized := gocv.NewMat()
gocv.Resize(digitImg, &resized, image.Pt(template.Cols(), template.Rows()), 0, 0, gocv.InterpolationLinear)
defer resized.Close()
compareImg = resized
}
diff := gocv.NewMat()
gocv.AbsDiff(compareImg, template, &diff)
totalPixels := compareImg.Rows() * compareImg.Cols()
diffPixels := gocv.CountNonZero(diff)
samePixels := totalPixels - diffPixels
score := (float64(samePixels) / float64(totalPixels)) * 100.0
diff.Close()
if score > bestScore {
bestScore = score
}
}
digitImg.Close()
// If it matches '1' with >85% confidence, we have a '1'
if bestScore > 85.0 {
logMessage(Both, Debug, " hasOneAt(x=%d): matched '1' with %.1f%% confidence -> YES", x, bestScore)
return true
}
logMessage(Both, Debug, " hasOneAt(x=%d): best '1' match %.1f%% confidence -> NO", x, bestScore)
return false
}
// validateEight checks if a digit image has the two characteristic holes of an '8'
// by checking for black pixels along horizontal lines where holes should be
func validateEight(digitImg gocv.Mat, logger io.Writer) bool {
h := digitImg.Rows()
w := digitImg.Cols()
// Upper hole: 30% of height
// Lower hole: 70% of height
// Horizontal range: 40%-50% of width
topHoleY := (h * 30) / 100
bottomHoleY := (h * 70) / 100
xStart := (w * 40) / 100
xEnd := (w * 50) / 100
// Check for ANY black pixel in the horizontal range
// In thresholded image: black (0) = hole, white (255) = digit
topHoleFound := false
for x := xStart; x <= xEnd; x++ {
pixel := digitImg.GetUCharAt(topHoleY, x)
if pixel < 128 {
topHoleFound = true
break
}
}
bottomHoleFound := false
for x := xStart; x <= xEnd; x++ {
pixel := digitImg.GetUCharAt(bottomHoleY, x)
if pixel < 128 {
bottomHoleFound = true
break
}
}
logMessage(LogFile, Debug, " validateEight: top hole @ y=%d (30%%) x=%d-%d (%v), bottom hole @ y=%d (70%%) x=%d-%d (%v)",
topHoleY, xStart, xEnd, topHoleFound,
bottomHoleY, xStart, xEnd, bottomHoleFound)
return topHoleFound && bottomHoleFound
}
// validateZero checks if a digit image has the characteristic hole of a '0'
// by checking for black pixels along a horizontal line at the center
func validateZero(digitImg gocv.Mat, logger io.Writer) bool {
h := digitImg.Rows()
w := digitImg.Cols()
// Center hole: 50% of height
// Horizontal range: 30%-70% of width (wider range than '8' since '0' hole is larger)
centerY := h / 2
xStart := (w * 30) / 100
xEnd := (w * 70) / 100
// Check for ANY black pixel in the horizontal range
// In thresholded image: black (0) = hole, white (255) = digit
holeFound := false
for x := xStart; x <= xEnd; x++ {
pixel := digitImg.GetUCharAt(centerY, x)
if pixel < 128 {
holeFound = true
break
}
}
logMessage(LogFile, Debug, " validateZero: center hole @ y=%d (50%%) x=%d-%d (%v)",
centerY, xStart, xEnd, holeFound)
return holeFound
}
// New simplified version that works with fixed-width rectangles
func recognizeDisplayArea(rotated gocv.Mat, area image.Rectangle, templates map[int][]gocv.Mat, displayName string, frameCount int, logger io.Writer) (int, int, float64, int, float64) {
startTime := time.Now()
cleanName := strings.TrimSpace(strings.ToLower(displayName))
// Extract the display area (ALREADY THRESHOLDED BINARY IMAGE)
region := rotated.Region(area)
thresh := region.Clone()
region.Close()
// DEBUG: Save extracted region - already thresholded
if DEBUG_MODE {
step1Filename := fmt.Sprintf("review/f%d_%s_step1_extracted.png", frameCount, cleanName)
gocv.IMWrite(step1Filename, thresh)
}
w := thresh.Cols()
h := thresh.Rows()
// Log display dimensions
logMessage(LogFile, Debug, " %s display: w=%d, h=%d", displayName, w, h)
// Sequential check for '1' patterns to determine digit widths
// hasOneAt checks if column X is white and column X+10 is black (detects RIGHT EDGE where '1' ends)
// Digit detection (right to left: digit3, digit2, digit1)
// D stores cut positions, digitIsOne stores detection results
D := make([]int, 4) // indices 1,2,3 (0 unused)
digitIsOne := make([]bool, 4) // indices 1,2,3 (0 unused)
totalWidth := 0
logMessage(Both, Debug, " [%s] Display width=%d, starting digit detection...", displayName, w)
detectionStart := time.Now()
for i := 3; i >= 1; i-- {
var checkPos int
if i == 3 {
checkPos = w // Check at right edge, not w-8
} else {
checkPos = w - totalWidth - 5
}
logMessage(Both, Debug, " [%s] Digit %d: checkPos=%d (w=%d, totalWidth=%d)", displayName, i, checkPos, w, totalWidth)
digitIsOne[i] = hasOneAt(thresh, checkPos, templates, logger)
// Add width to accumulator
var widthUsed int
if digitIsOne[i] {
widthUsed = DIGIT_ONE_WIDTH
totalWidth += DIGIT_ONE_WIDTH
logMessage(Both, Debug, " [%s] Digit %d: IS ONE -> using width=%d (totalWidth now %d)", displayName, i, widthUsed, totalWidth)
} else {
widthUsed = DIGIT_NON_ONE_WIDTH
totalWidth += DIGIT_NON_ONE_WIDTH
logMessage(Both, Debug, " [%s] Digit %d: NOT ONE -> using width=%d (totalWidth now %d)", displayName, i, widthUsed, totalWidth)
}
D[i] = w - totalWidth
logMessage(Both, Debug, " [%s] Digit %d: CUT position D[%d]=%d", displayName, i, i, D[i])
}
logMessage(LogFile, Debug, " [TIMING][%s] Digit detection (hasOneAt x3): %dms", displayName, time.Since(detectionStart).Milliseconds())
logMessage(Both, Debug, " [%s] Final cut positions: D[1]=%d, D[2]=%d, D[3]=%d", displayName, D[1], D[2], D[3])
// Create visualization image showing check positions AND cut positions
visImg := gocv.NewMat()
gocv.CvtColor(thresh, &visImg, gocv.ColorGrayToBGR)
// Draw vertical lines at check positions with labels
greenColor := color.RGBA{0, 255, 0, 255} // Green for '1' detected
redColor := color.RGBA{0, 0, 255, 255} // Red for no '1'
yellowColor := color.RGBA{0, 255, 255, 255} // Yellow for CUT lines
// Digit 3 check line
digit3Color := redColor
if digitIsOne[3] {
digit3Color = greenColor
}
gocv.Line(&visImg, image.Pt(w-8, 0), image.Pt(w-8, h), digit3Color, 2)
gocv.PutText(&visImg, "D3", image.Pt(w-8+5, 20), gocv.FontHersheyDuplex, 0.5, digit3Color, 1)
// Digit 2 check line (calculate position from accumulated width)
var digit2Width int
if digitIsOne[3] {
digit2Width = DIGIT_ONE_WIDTH
} else {
digit2Width = DIGIT_NON_ONE_WIDTH
}
digit2CheckPos := w - digit2Width - 5
digit2Color := redColor
if digitIsOne[2] {
digit2Color = greenColor
}
gocv.Line(&visImg, image.Pt(digit2CheckPos, 0), image.Pt(digit2CheckPos, h), digit2Color, 2)
gocv.PutText(&visImg, "D2", image.Pt(digit2CheckPos+5, 40), gocv.FontHersheyDuplex, 0.5, digit2Color, 1)
// Digit 1 check line (calculate position from accumulated width)
var digit3Width int
if digitIsOne[3] {
digit3Width = DIGIT_ONE_WIDTH
} else {
digit3Width = DIGIT_NON_ONE_WIDTH
}
var digit2WidthForD1 int
if digitIsOne[2] {
digit2WidthForD1 = DIGIT_ONE_WIDTH
} else {
digit2WidthForD1 = DIGIT_NON_ONE_WIDTH
}
digit1CheckPos := w - digit3Width - digit2WidthForD1 - 5
digit1Color := redColor
if digitIsOne[1] {
digit1Color = greenColor
}
gocv.Line(&visImg, image.Pt(digit1CheckPos, 0), image.Pt(digit1CheckPos, h), digit1Color, 2)
gocv.PutText(&visImg, "D1", image.Pt(digit1CheckPos+5, 60), gocv.FontHersheyDuplex, 0.5, digit1Color, 1)
// Draw CUT lines in yellow showing where we actually split the digits
gocv.Line(&visImg, image.Pt(D[2], 0), image.Pt(D[2], h), yellowColor, 3)
gocv.PutText(&visImg, "CUT1", image.Pt(D[2]+5, h-10), gocv.FontHersheyDuplex, 0.6, yellowColor, 2)
gocv.Line(&visImg, image.Pt(D[3], 0), image.Pt(D[3], h), yellowColor, 3)
gocv.PutText(&visImg, "CUT2", image.Pt(D[3]+5, h-30), gocv.FontHersheyDuplex, 0.6, yellowColor, 2)
// Save visualization (always - needed for debugging failures)
visFilename := fmt.Sprintf("review/f%d_%s_checks.png", frameCount, cleanName)
gocv.IMWrite(visFilename, visImg)
visImg.Close()
// Clamp cut positions to valid range
if D[3] < 0 {
D[3] = 0
}
if D[2] < 0 {
D[2] = 0
}
// Extract three digit regions
logMessage(Both, Debug, " [%s] Extracting digit1: region [0..%d] (width=%d)", displayName, D[2], D[2])
digit1Region := thresh.Region(image.Rect(0, 0, D[2], h))
digit1 := digit1Region.Clone()
digit1Region.Close()
logMessage(Both, Debug, " [%s] Extracting digit2: region [%d..%d] (width=%d)", displayName, D[2], D[3], D[3]-D[2])
digit2Region := thresh.Region(image.Rect(D[2], 0, D[3], h))
digit2 := digit2Region.Clone()
digit2Region.Close()
logMessage(Both, Debug, " [%s] Extracting digit3: region [%d..%d] (width=%d)", displayName, D[3], w, w-D[3])
digit3Region := thresh.Region(image.Rect(D[3], 0, w, h))
digit3 := digit3Region.Clone()
digit3Region.Close()
// Match all three digits
matchStart := time.Now()
num1, score1 := matchDigit(digit1, templates)
num2, score2 := matchDigit(digit2, templates)
num3, score3 := matchDigit(digit3, templates)
logMessage(LogFile, Debug, " [TIMING][%s] matchDigit x3: %dms", displayName, time.Since(matchStart).Milliseconds())
// Validate '8' digits - check for two holes
if num1 == 8 && !validateEight(digit1, logger) {
logMessage(LogFile, Debug, " ⚠️ Digit1 recognized as 8 but failed hole validation - marking invalid")
num1 = -1
score1 = 0.0
}
if num2 == 8 && !validateEight(digit2, logger) {
logMessage(LogFile, Debug, " ⚠️ Digit2 recognized as 8 but failed hole validation - marking invalid")
num2 = -1
score2 = 0.0
}
if num3 == 8 && !validateEight(digit3, logger) {
logMessage(LogFile, Debug, " ⚠️ Digit3 recognized as 8 but failed hole validation - marking invalid")
num3 = -1
score3 = 0.0
}
// Validate '0' digits - check for center hole
if num1 == 0 && !validateZero(digit1, logger) {
logMessage(LogFile, Debug, " ⚠️ Digit1 recognized as 0 but failed hole validation - marking invalid")
num1 = -1
score1 = 0.0
}
if num2 == 0 && !validateZero(digit2, logger) {
logMessage(LogFile, Debug, " ⚠️ Digit2 recognized as 0 but failed hole validation - marking invalid")
num2 = -1
score2 = 0.0
}
if num3 == 0 && !validateZero(digit3, logger) {
logMessage(LogFile, Debug, " ⚠️ Digit3 recognized as 0 but failed hole validation - marking invalid")
num3 = -1
score3 = 0.0
}
// Calculate final number
var number int
if digitIsOne[1] {
// 3-digit number: 1XX
number = 100 + num2*10 + num3
} else {
// 2-digit number: XX (digit1 region is mostly empty/padding)
number = num2*10 + num3
}
// Save individual digits (only when SAVE_CROPS flag is set - for approving templates)
if SAVE_CROPS {
saveStart := time.Now()
digit1Filename := fmt.Sprintf("review/f%d_%s_digit1.png", frameCount, cleanName)
digit2Filename := fmt.Sprintf("review/f%d_%s_digit2.png", frameCount, cleanName)
digit3Filename := fmt.Sprintf("review/f%d_%s_digit3.png", frameCount, cleanName)
gocv.IMWrite(digit1Filename, digit1)
gocv.IMWrite(digit2Filename, digit2)
gocv.IMWrite(digit3Filename, digit3)
logMessage(LogFile, Debug, " [TIMING][%s] Save digit images: %dms", displayName, time.Since(saveStart).Milliseconds())
}
// Create labeled full image (combine all three digits)
combinedWidth := digit1.Cols() + digit2.Cols() + digit3.Cols()
combinedHeight := digit1.Rows()
if digit2.Rows() > combinedHeight {
combinedHeight = digit2.Rows()
}
if digit3.Rows() > combinedHeight {
combinedHeight = digit3.Rows()
}
fullForLabel := gocv.NewMatWithSize(combinedHeight, combinedWidth, digit1.Type())
// Copy digit1
digit1ROI := fullForLabel.Region(image.Rect(0, 0, digit1.Cols(), digit1.Rows()))
digit1.CopyTo(&digit1ROI)
digit1ROI.Close()
// Copy digit2
digit2ROI := fullForLabel.Region(image.Rect(digit1.Cols(), 0, digit1.Cols()+digit2.Cols(), digit2.Rows()))
digit2.CopyTo(&digit2ROI)
digit2ROI.Close()
// Copy digit3
digit3ROI := fullForLabel.Region(image.Rect(digit1.Cols()+digit2.Cols(), 0, combinedWidth, digit3.Rows()))
digit3.CopyTo(&digit3ROI)
digit3ROI.Close()
// Add padding at top for label
labeledFull := gocv.NewMat()
gocv.CopyMakeBorder(fullForLabel, &labeledFull, 60, 0, 0, 0, gocv.BorderConstant, color.RGBA{0, 0, 0, 255})
fullForLabel.Close()
// Draw label
label := fmt.Sprintf("%d", number)
textColor := color.RGBA{255, 255, 255, 255}
gocv.PutText(&labeledFull, label, image.Pt(10, 45), gocv.FontHersheyDuplex, 1.8, textColor, 3)
// Save labeled full image (always - needed for debugging failures)
fullFilename := fmt.Sprintf("review/f%d_%s_full.png", frameCount, cleanName)
gocv.IMWrite(fullFilename, labeledFull)
labeledFull.Close()
digit1.Close()
digit2.Close()
digit3.Close()
thresh.Close()
// Calculate widths for logging
digit3Width = DIGIT_ONE_WIDTH
if !digitIsOne[3] {
digit3Width = DIGIT_NON_ONE_WIDTH
}
digit2Width = DIGIT_ONE_WIDTH
if !digitIsOne[2] {
digit2Width = DIGIT_NON_ONE_WIDTH
}
// Print debug info to log only
if digitIsOne[1] {
logMessage(LogFile, Info, " %s: 1%d%d [3-DIGIT: d1=1@%dpx (x=0-%d), d2=%s@%dpx (x=%d-%d), d3=%s@%dpx (x=%d-%d)] (scores: %d=%.0f%%, %d=%.0f%%, %d=%.0f%%)",
displayName, num2, num3, D[2], D[2],
map[bool]string{true: "1", false: "X"}[digitIsOne[2]], digit2Width, D[2], D[3],
map[bool]string{true: "1", false: "X"}[digitIsOne[3]], digit3Width, D[3], w,
num1, score1, num2, score2, num3, score3)
} else {
logMessage(LogFile, Info, " %s: %d%d [2-DIGIT: d2=%s@%dpx (x=%d-%d), d3=%s@%dpx (x=%d-%d)] (scores: %d=%.0f%%, %d=%.0f%%)",
displayName, num2, num3,
map[bool]string{true: "1", false: "X"}[digitIsOne[2]], digit2Width, D[2], D[3],
map[bool]string{true: "1", false: "X"}[digitIsOne[3]], digit3Width, D[3], w,
num2, score2, num3, score3)
}
logMessage(LogFile, Debug, " [TIMING][%s] Total recognizeDisplayArea: %dms", displayName, time.Since(startTime).Milliseconds())
// Return using middle two digits for compatibility with existing code
return number, num2, score2, num3, score3
}

View File

@ -0,0 +1,299 @@
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)
}

View File

@ -0,0 +1,551 @@
package main
import (
"fmt"
"image"
"image/color"
"os"
"os/signal"
"syscall"
"time"
"gocv.io/x/gocv"
)
const VERSION = "v3.57"
// Global debug flag
var DEBUG_MODE = false
// Global timing flag
var TIMING_MODE = false
// Global save crops flag
var SAVE_CROPS = false
// Display and digit measurement constants
const (
CUT_WIDTH = 280
DIGIT_ONE_WIDTH = 72
DIGIT_NON_ONE_WIDTH = 100
MIN_BOX_HEIGHT = 110
)
func main() {
// Check for /help flag first
for _, arg := range os.Args[1:] {
if arg == "/help" || arg == "--help" || arg == "-h" {
showHelp()
return
}
}
logMessage(Console, Info, "=== Pulse-Ox Monitor %s (Refactored Architecture) ===", VERSION)
logMessage(Console, Info, "")
// Limit OpenCV thread pool to reduce CPU overhead
// Small images (280x200px) don't benefit from multi-threading
gocv.SetNumThreads(1)
logMessage(Console, Info, "🔧 OpenCV threads limited to 1 (single-threaded for minimal overhead)")
logMessage(Console, Info, "")
// Check for flags
for _, arg := range os.Args[1:] {
if arg == "/debug" {
DEBUG_MODE = true
logMessage(Console, Info, "🐛 DEBUG MODE ENABLED")
} else if arg == "/timing" {
TIMING_MODE = true
logMessage(Console, Info, "⏱️ TIMING MODE ENABLED")
} else if arg == "/crops" {
SAVE_CROPS = true
logMessage(Console, Info, "✂️ SAVE CROPS ENABLED (individual digit images)")
}
}
// Check if running in single-frame test mode
if len(os.Args) >= 2 {
// Filter out flags
framePath := ""
for _, arg := range os.Args[1:] {
if arg != "/debug" && arg != "/timing" && arg != "/crops" {
framePath = arg
break
}
}
if framePath != "" {
runSingleFrameMode(framePath)
return
}
}
// Normal streaming mode
runStreamingMode()
}
func showHelp() {
logMessage(Console, Info, "=== Pulse-Ox Monitor %s ===", VERSION)
logMessage(Console, Info, "")
logMessage(Console, Info, "USAGE:")
logMessage(Console, Info, " pulseox-monitor [options] [framefile]")
logMessage(Console, Info, "")
logMessage(Console, Info, "OPTIONS:")
logMessage(Console, Info, " /help Show this help message")
logMessage(Console, Info, " /debug Enable debug mode (extra diagnostic output)")
logMessage(Console, Info, " /timing Show timing table for performance analysis")
logMessage(Console, Info, " /crops Save individual digit crop images (for approving templates)")
logMessage(Console, Info, "")
logMessage(Console, Info, "MODES:")
logMessage(Console, Info, " No arguments - Run in streaming mode (RTSP camera)")
logMessage(Console, Info, " [framefile] - Test mode: process single PNG file")
logMessage(Console, Info, "")
logMessage(Console, Info, "EXAMPLES:")
logMessage(Console, Info, " pulseox-monitor # Normal streaming")
logMessage(Console, Info, " pulseox-monitor /crops # Streaming with digit crops")
logMessage(Console, Info, " pulseox-monitor /timing # Show performance timing")
logMessage(Console, Info, " pulseox-monitor raw_frames/thresh_*.png # Test single frame")
logMessage(Console, Info, " pulseox-monitor /debug /crops # Multiple flags")
logMessage(Console, Info, "")
logMessage(Console, Info, "OUTPUT:")
logMessage(Console, Info, " review/ - Processed frame images and review.html")
logMessage(Console, Info, " raw_frames/ - Failed recognition frames (for debugging)")
logMessage(Console, Info, " test_output/ - Layout detection debug images")
logMessage(Console, Info, " pulse-monitor_*.log - Detailed execution log")
logMessage(Console, Info, "")
}
func runSingleFrameMode(framePath string) {
logMessage(Console, Info, "=== Single Frame Test Mode ===")
logMessage(Console, Info, "Loading frame: %s", framePath)
logMessage(Console, Info, "")
// Automatically enable DEBUG_MODE and SAVE_CROPS for file processing
DEBUG_MODE = true
SAVE_CROPS = true
logMessage(Console, Info, "🐛 DEBUG MODE AUTO-ENABLED (file processing)")
logMessage(Console, Info, "✂️ SAVE CROPS AUTO-ENABLED (file processing)")
logMessage(Console, Info, "")
// In test mode, all output goes to console only (no separate file log)
// Leave globalLogger as nil to avoid duplication when using Both target
globalLogger = nil
// Setup
os.MkdirAll("test_output", 0755)
os.MkdirAll("review", 0755)
// Load templates
logMessage(Console, Info, "Loading templates...")
templates, err := loadTemplates()
if err != nil {
logMessage(Console, Error, "❌ Error loading templates: %v", err)
return
}
defer closeTemplates(templates)
logMessage(Console, Info, "✓ Templates loaded")
logMessage(Console, Info, "")
// Create frame source
source := NewFileSource(framePath)
defer source.Close()
// Create processor
processor := NewProcessor(templates, nil, nil)
state := NewProcessingState()
// Process single frame with unified loop
processFrames(source, processor, state)
logMessage(Console, Info, "")
logMessage(Console, Info, "✓ Single frame test complete")
logMessage(Console, Info, "")
logMessage(Console, Info, "=== OUTPUT FILES ===")
logMessage(Console, Info, "Test outputs:")
logMessage(Console, Info, " test_output/layout_boxes.jpg - Layout visualization")
logMessage(Console, Info, " review/f*_spo2_full.png - SpO2 recognition")
logMessage(Console, Info, " review/f*_hr_full.png - HR recognition")
}
func runStreamingMode() {
// Create log file
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 {
logMessage(Console, Warning, "Warning: Could not create log file: %v", err)
logMessage(Console, Info, "Continuing without file logging...")
logMessage(Console, Info, "")
globalLogger = nil
} else {
defer logFile.Close()
globalLogger = logFile
logMessage(Both, Info, "📝 Logging to: %s", logFilename)
}
// Setup directories - clean output directories in streaming mode
logMessage(Console, Info, "🗑️ Cleaning output directories...")
logMessage(Console, Info, " - review/... ✓")
os.RemoveAll("review")
logMessage(Console, Info, " - raw_frames/... ✓")
os.RemoveAll("raw_frames")
logMessage(Console, Info, " - test_output/... ✓")
os.RemoveAll("test_output")
os.MkdirAll("review", 0755)
os.MkdirAll("raw_frames", 0755)
os.MkdirAll("test_output", 0755)
logMessage(Console, Info, "")
// Initialize live review HTML
if err := initReviewHTML(); err != nil {
logMessage(Console, Warning, "Warning: Could not initialize review HTML: %v", err)
} else {
logMessage(Console, Info, "✅ Live review HTML initialized: review/review.html (refresh browser to see updates)")
}
// Load config
config, err := LoadConfig("config.yaml")
if err != nil {
logMessage(Console, Error, "Error loading config: %v", err)
return
}
// Load templates
templates, err := loadTemplates()
if err != nil {
logMessage(Console, Error, "Error loading templates: %v", err)
return
}
defer closeTemplates(templates)
// Initialize timestamp OCR client (reusable)
InitTimestampOCR()
defer CloseTimestampOCR()
logMessage(Console, Info, "📊 All processed frames saved to review/")
logMessage(Console, Info, " Press Ctrl+C to stop and generate review.html")
logMessage(Console, Info, "")
// Create RTSP source with reconnection handling
logMessage(Console, Info, "Connecting to RTSP stream...")
var source *RTSPSource
for {
source, err = NewRTSPSource(config.Camera.RTSPURL)
if err == nil {
break
}
logMessage(Console, Warning, "Failed to connect: %v", err)
logMessage(Console, Info, "Retrying in 5 seconds...")
time.Sleep(5 * time.Second)
}
defer source.Close()
logMessage(Console, Info, "✓ Connected! Press Ctrl+C to stop")
logMessage(Console, Info, "Posting to HASS: %s", config.HomeAssistant.URL)
logMessage(Console, Info, "")
// Setup signal handler
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigChan
logMessage(Console, Info, "")
logMessage(Console, Info, "")
logMessage(Console, Info, "🛑 Received stop signal, finishing up...")
source.Close() // Safe to call multiple times now (sync.Once)
}()
// Create processor
processor := NewProcessor(templates, config, logFile)
state := NewProcessingState()
// Run unified processing loop
processFrames(source, processor, state)
// Close review HTML
logMessage(Console, Info, "")
logMessage(Console, Info, "📝 Closing review.html...")
if err := closeReviewHTML(); err != nil {
logMessage(Console, Error, "Error closing review HTML: %v", err)
} else {
logMessage(Console, Info, "✓ Review page completed: review/review.html (%d frames)", len(state.ReviewEntries))
logMessage(Console, Info, " Open it in browser to review recognition results")
}
}
// interruptibleSleep sleeps for the specified duration, but checks every second if source is closed
func interruptibleSleep(source FrameSource, duration time.Duration) {
remaining := duration
for remaining > 0 {
sleepTime := time.Second
if remaining < sleepTime {
sleepTime = remaining
}
time.Sleep(sleepTime)
remaining -= sleepTime
// Check if source was closed (Ctrl+C pressed)
if !source.IsActive() {
return
}
}
}
// processFrames is the UNIFIED processing loop - works for both RTSP and single-frame
func processFrames(source FrameSource, processor *Processor, state *ProcessingState) {
logMessage(Console, Info, "Processing frames from: %s", source.Name())
logMessage(Console, Info, "")
for {
// Initialize timing data
var timing TimingData
acquireStart := time.Now()
// Read raw frame from source
var frame gocv.Mat
var shouldContinue bool
var err error
if state.ConsecutiveFailures == 1 {
// First failure: grab IMMEDIATE next frame (bypass skip)
logMessage(Console, Debug, " Using NextImmediate() due to consecutive failure")
frame, shouldContinue, err = source.NextImmediate()
} else {
// Normal operation or 2nd+ failure: use normal skip
frame, shouldContinue, err = source.Next()
}
timing.Acquire = time.Since(acquireStart).Milliseconds()
if err != nil {
logMessage(Both, Error, "Error reading frame: %v", err)
if !shouldContinue {
break
}
continue
}
if frame.Empty() {
if !shouldContinue {
break
}
continue
}
// Check timestamp every 10 processed frames (BEFORE preprocessing, on colored frame)
state.ProcessedCount++
state.TimestampCheckCounter++
if state.TimestampCheckCounter >= 10 {
state.TimestampCheckCounter = 0
diff, _, err := extractTimestamp(frame)
if err != nil {
logMessage(Both, Warning, " Timestamp OCR failed: %v", err)
} else if diff > 3 {
logMessage(Both, Warning, " Camera timestamp lag: %ds behind server", diff)
} else if diff < -3 {
logMessage(Both, Warning, " Camera timestamp ahead: %ds ahead of server", -diff)
}
// Silent if drift is within ±3 seconds
}
// Save truly raw frame (before any preprocessing) - threshold to reduce file size
// Clone the colored frame, convert to grayscale, threshold at 240
// This becomes the "raw" frame that will be saved on errors
rawFrameClone := frame.Clone()
rawGray := gocv.NewMat()
gocv.CvtColor(rawFrameClone, &rawGray, gocv.ColorBGRToGray)
rawThresholded := gocv.NewMat()
gocv.Threshold(rawGray, &rawThresholded, 240, 255, gocv.ThresholdBinary)
rawGray.Close()
rawFrameClone.Close()
// rawThresholded is now the true raw frame (portrait, with timestamp, thresholded)
// Will be passed to processFrame() for potential error saving
// Preprocess COLORED frame (rotate, crop timestamp)
preprocessStart := time.Now()
logMessage(Console, Debug, " Before preprocess: %dx%d", frame.Cols(), frame.Rows())
preprocessed := preprocessFrame(frame)
logMessage(Console, Debug, " After preprocess: %dx%d", preprocessed.Cols(), preprocessed.Rows())
frame.Close()
timing.Preprocess = time.Since(preprocessStart).Milliseconds()
// Calculate rotation angle on first frame (if not set)
if state.LockedRotation == 0.0 && !state.LayoutValid {
angle := calculateRotationAngle(preprocessed)
state.LockedRotation = angle
if angle != 0.0 {
logMessage(Both, Info, "🔄 Rotation angle: %.2f° (will rotate all frames)", angle)
} else {
logMessage(Both, Info, "📐 No rotation needed (angle < 0.5°)")
}
}
// Apply rotation if needed (on colored preprocessed frame, BEFORE thresholding)
var rotated gocv.Mat
if state.LockedRotation != 0.0 {
rotateStart := time.Now()
rotated = rotateImage(preprocessed, state.LockedRotation)
preprocessed.Close()
logMessage(Both, Info, " ✓ Applied rotation: %.3f° to frame", state.LockedRotation)
logMessage(LogFile, Debug, " [TIMING] Rotation: %dms", time.Since(rotateStart).Milliseconds())
// DEBUG: Save rotated frame with same visualization as BEFORE
if DEBUG_MODE && state.ProcessedCount <= 2 {
// Save plain rotated frame
gocv.IMWrite(fmt.Sprintf("test_output/rotated_frame_%d.png", state.ProcessedCount), rotated)
// Create visualization matching BEFORE image
afterVis := rotated.Clone()
// Note: We can't draw the exact same boxes since they were calculated on pre-rotated frame
// But we can add a reference line to show the frame is rotated
gocv.PutText(&afterVis,
fmt.Sprintf("AFTER rotation: %.2f degrees", state.LockedRotation),
image.Pt(50, 50),
gocv.FontHersheyDuplex, 1.5, color.RGBA{0, 255, 0, 255}, 3)
gocv.IMWrite("test_output/rotation_boxes_AFTER.png", afterVis)
afterVis.Close()
logMessage(Both, Info, " 💾 Saved rotated frame and AFTER visualization")
}
} else {
rotated = preprocessed
logMessage(Both, Info, " 📐 No rotation applied (angle=0)")
}
// THRESHOLD AFTER PREPROCESSING AND ROTATION - SINGLE POINT OF TRUTH
// Convert rotated colored frame to grayscale then threshold at 240
thresholdStart := time.Now()
gray := gocv.NewMat()
gocv.CvtColor(rotated, &gray, gocv.ColorBGRToGray)
thresholded := gocv.NewMat()
gocv.Threshold(gray, &thresholded, 240, 255, gocv.ThresholdBinary)
gray.Close()
rotated.Close()
timing.Threshold = time.Since(thresholdStart).Milliseconds()
// Try to acquire layout if we don't have it
if !state.LayoutValid {
// Call layout detection directly
layout, scale, _ := detectScreenLayoutAreas(thresholded)
if layout != nil {
// Layout acquired successfully
state.Layout = layout
state.LockedScale = scale
state.LayoutValid = true
state.ConsecutiveFailures = 0
logMessage(LogFile, Info, " ✓ Layout detected, scale factor: %.3f", scale)
logMessage(LogFile, Info, " SpO2 area: X=%d, Y=%d, Width=%d, Height=%d",
layout.SpO2Area.Min.X, layout.SpO2Area.Min.Y,
layout.SpO2Area.Dx(), layout.SpO2Area.Dy())
logMessage(LogFile, Info, " HR area: X=%d, Y=%d, Width=%d, Height=%d",
layout.HRArea.Min.X, layout.HRArea.Min.Y,
layout.HRArea.Dx(), layout.HRArea.Dy())
logMessage(Console, Info, "✓ Layout detected, scale: %.3f", scale)
} else {
// Layout acquisition failed - increment counter
state.ConsecutiveFailures++
thresholded.Close()
rawThresholded.Close()
// Escalation strategy
if state.ConsecutiveFailures == 1 {
// 1st failure: Skip this frame, try next immediately
logMessage(Both, Warning, " Layout detection failed (1st try) - trying next frame...")
} else if state.ConsecutiveFailures == 2 {
// 2nd failure: Try next frame immediately (different conditions?)
logMessage(Both, Warning, " Layout detection failed (2nd try) - trying next frame...")
} else if state.ConsecutiveFailures == 3 {
// 3rd failure: Wait 10s
logMessage(Both, Warning, " Layout detection failed (3rd try) - waiting 10s...")
interruptibleSleep(source, 10*time.Second)
} else if state.ConsecutiveFailures == 4 {
// 4th failure: Wait 30s
logMessage(Both, Warning, " Layout detection failed (4th try) - waiting 30s...")
interruptibleSleep(source, 30*time.Second)
} else {
// 5th+ failure: Wait 60s (device likely off/removed)
logMessage(Both, Info, " ⏳ Pulse oximeter not detected (day mode) - waiting 60s...")
interruptibleSleep(source, 60*time.Second)
}
if !shouldContinue {
break
}
continue
}
}
// Process frame with current layout
timing.FrameNum = state.ProcessedCount
result := processor.processFrame(thresholded, rawThresholded, state.ProcessedCount, state, &timing)
thresholded.Close()
rawThresholded.Close()
// Print timing table (header every 20 frames)
state.TimingFrameCount++
showHeader := (state.TimingFrameCount % 20) == 1
printTimingTable(timing, showHeader)
// DEBUG: Show result status
if state.ConsecutiveFailures > 0 {
logMessage(Console, Debug, " Frame result: %v", result.Status)
}
// Handle result
switch result.Status {
case StatusSuccess:
// Reset failure counter on successful processing
state.ConsecutiveFailures = 0
if result.ShouldPost {
hassStart := time.Now()
processor.postReading(&result.Reading, state)
timing.HASS = time.Since(hassStart).Milliseconds()
timing.Total += timing.HASS // Add HASS time to total
}
case StatusCorrupted:
// Corruption is a recognition issue, not layout issue
// Count as a failure for escalation
state.ConsecutiveFailures++
if state.ConsecutiveFailures == 2 {
// 2nd consecutive problem: invalidate layout for re-detection
logMessage(Both, Warning, " Re-detecting layout...")
state.LayoutValid = false
}
case StatusLowConfidence:
// Low confidence is also a problem that needs escalation
state.ConsecutiveFailures++
if state.ConsecutiveFailures == 1 {
logMessage(Both, Warning, " Low confidence - grabbing next frame immediately...")
// Next iteration will automatically use NextImmediate() due to counter = 1
} else if state.ConsecutiveFailures == 2 {
// 2nd consecutive problem: invalidate layout for re-detection
logMessage(Both, Warning, " 2nd consecutive low confidence - re-detecting layout...")
state.LayoutValid = false
}
case StatusNoChange:
// Values didn't change - this is normal, not a failure
// Don't increment failure counter
case StatusUnstable:
// Reading held for validation - this is normal, not a failure
// Don't increment failure counter
}
// Check if we should continue
if !shouldContinue {
break
}
}
}
func closeTemplates(templates map[int][]gocv.Mat) {
for _, templateList := range templates {
for _, t := range templateList {
t.Close()
}
}
}

View File

@ -0,0 +1,223 @@
package main
import (
"fmt"
"log"
"os"
"gocv.io/x/gocv"
)
// Global DEBUG flag
var DEBUG = false
func main() {
// Parse command line args
if len(os.Args) > 1 && os.Args[1] == "/debug" {
DEBUG = true
fmt.Println("DEBUG mode enabled\n")
}
// Hardcode RTSP URL
streamURL := "rtsp://tapohass:!!Helder06@192.168.2.183:554/stream1"
fmt.Println("Rotation Detection System")
fmt.Println("========================")
fmt.Println("Processing frames...\n")
// Open RTSP stream
stream, err := gocv.OpenVideoCapture(streamURL)
if err != nil {
log.Fatalf("Failed to open stream: %v", err)
}
defer stream.Close()
// Initialize detection state
rotation := 9999.0 // Sentinel value for "not detected"
width := 0 // 0 means "not detected"
scaleFactor := 0.0 // Scale factor from detection
// Frame processing counters
framesSkipped := 0
framesProcessed := 0
detectionAttempts := 0
totalFramesRead := 0
SKIP_COUNT := 7 // Process every 8th frame
MAX_DETECTION_ATTEMPTS := 100 // Give up after 100 attempts
RE_DETECT_AFTER := 0 // Re-detect after N frames (0 = disabled)
// Main processing loop
frame := gocv.NewMat()
defer frame.Close()
for {
// Acquire a frame
if ok := stream.Read(&frame); !ok || frame.Empty() {
log.Fatal("Failed to read frame from stream")
}
totalFramesRead++
// Preprocess frame to binary
binary := PreprocessFrame(frame)
// If rotation is 9999 or width is 0, detect bands/width/rotation
if rotation == 9999 || width == 0 || scaleFactor == 0.0 {
detectionAttempts++
// Give up if too many attempts
if detectionAttempts > MAX_DETECTION_ATTEMPTS {
fmt.Printf("❌ Detection failed after %d attempts. Exiting.\n", MAX_DETECTION_ATTEMPTS)
break
}
result := DetectRotationAndWidth(binary)
if result.Success {
rotation = result.Rotation
width = result.Width
scaleFactor = result.ScaleFactor
fmt.Printf("✓ DETECTION SUCCESSFUL (frame %d, attempt #%d):\n", totalFramesRead, detectionAttempts)
fmt.Printf(" Width=%dpx, Rotation=%.3f°, Scale=%.3f\n\n", width, rotation, scaleFactor)
detectionAttempts = 0 // Reset counter
} else {
// Show detection attempts with frame info
if detectionAttempts <= 5 {
if DEBUG {
fmt.Printf("Frame %d: Detection attempt #%d failed\n", totalFramesRead, detectionAttempts)
}
} else {
fmt.Printf("⚠ Frame %d: Detection attempt #%d failed - retrying...\n", totalFramesRead, detectionAttempts)
}
// Clean up and continue to next frame
binary.Close()
// Skip a few frames to ensure display has changed
for skip := 0; skip < 3; skip++ {
if ok := stream.Read(&frame); !ok {
break
}
}
continue
}
}
// If rotation != 9999 and width != 0 and scaleFactor != 0.0 and we've skipped enough frames
if rotation != 9999 && width != 0 && scaleFactor != 0.0 && framesSkipped >= SKIP_COUNT {
framesProcessed++
// Apply the same rotation correction to this frame
// (rotation was detected on a previous frame, now apply it to this one)
rotated := RotateImage(binary, rotation)
defer rotated.Close()
// Save rotated frame BEFORE scaling
if DEBUG && framesProcessed == 1 {
gocv.IMWrite("debug_frame_001_AFTER_ROTATION_BEFORE_SCALE.png", rotated)
fmt.Println("DEBUG: Saved rotated frame before scaling")
}
// Apply the same scaling using detected scale factor
// This scales the entire frame so the baseline becomes 860px
scaled := ScaleByFactor(rotated, scaleFactor)
defer scaled.Close()
fmt.Printf("Frame #%d processed: Rotated %.3f°, Scaled to %dx%d (factor: %.3f, baseline: %dpx -> 860px)\n",
framesProcessed, rotation, scaled.Cols(), scaled.Rows(), scaleFactor, width)
// Re-detect bands on scaled image for visualization
if DEBUG && framesProcessed <= 5 {
// Use the detected scale factor for minimum height
scaledMinHeight := int(80.0 * scaleFactor)
bands := DetectBands(scaled, scaledMinHeight)
if len(bands) >= 2 {
// Find tallest band (graph)
tallestIdx := 0
tallestHeight := 0
for i, band := range bands {
height := band.maxY - band.minY
if height > tallestHeight {
tallestHeight = height
tallestIdx = i
}
}
if tallestIdx < len(bands)-1 {
graphBand := bands[tallestIdx]
digitBand := bands[tallestIdx+1]
// Extract digit region
digitRegion := ExtractBandRegion(scaled, digitBand)
defer digitRegion.Close()
// Find digit boxes
digitBoxes := FindDigitBoxes(digitRegion, scaledMinHeight)
if DEBUG {
fmt.Printf(" DEBUG: FindDigitBoxes returned %d boxes:\n", len(digitBoxes))
for i, box := range digitBoxes {
fmt.Printf(" Box %d: X[%d-%d] Y[%d-%d] Size: %dx%d\n",
i+1, box.Min.X, box.Max.X, box.Min.Y, box.Max.Y,
box.Dx(), box.Dy())
}
}
// Calculate center from scaled baseline points
// Use the same proportion as detected
scaledCenterX := int(float64(scaled.Cols()) / 2.0)
// Merge into displays
displayAreas := mergeDigitBoxesWithCenter(digitRegion, digitBoxes, scaledCenterX)
fmt.Println(" DEBUG: Saving visualizations...")
// Visualize bands
VisualizeSimpleBands(scaled, graphBand, digitBand,
fmt.Sprintf("debug_frame_%03d_bands.png", framesProcessed))
// Save digit region
gocv.IMWrite(fmt.Sprintf("debug_frame_%03d_digits.png", framesProcessed), digitRegion)
// Visualize digit boxes
VisualizeDigitBoxes(digitRegion, digitBoxes, digitBand.minY,
fmt.Sprintf("debug_frame_%03d_digitboxes.png", framesProcessed))
// Visualize merged displays
VisualizeDetectedDisplays(digitRegion, displayAreas, digitBoxes, scaledCenterX,
fmt.Sprintf("debug_frame_%03d_merged.png", framesProcessed))
fmt.Printf(" DEBUG: Saved 4 visualization files for frame %d\n", framesProcessed)
}
}
}
// Save scaled frame if in DEBUG mode
if DEBUG && framesProcessed <= 5 {
filename := fmt.Sprintf("debug_frame_%03d_scaled.png", framesProcessed)
gocv.IMWrite(filename, scaled)
fmt.Printf("DEBUG: Saved %s\n", filename)
}
// TODO: Add OCR processing on 'scaled' image here in later phase
// Display areas (SpO2 and HR) are already detected and stored in result
// They will be available when we integrate this with the main monitoring system
framesSkipped = 0
// Optional: Force re-detection after N frames to adapt to changes
if RE_DETECT_AFTER > 0 && framesProcessed >= RE_DETECT_AFTER {
fmt.Printf("\n🔄 Re-detecting after %d frames...\n", RE_DETECT_AFTER)
rotation = 9999
width = 0
scaleFactor = 0.0
framesProcessed = 0
}
} else {
framesSkipped++
}
// Clean up
binary.Close()
}
fmt.Println("\nProgram terminated.")
}

View File

@ -0,0 +1,109 @@
package main
import (
"fmt"
"image"
"regexp"
"strconv"
"strings"
"time"
"github.com/otiai10/gosseract/v2"
"gocv.io/x/gocv"
)
// Global reusable OCR client
var timestampOCRClient *gosseract.Client
// InitTimestampOCR initializes the reusable OCR client
func InitTimestampOCR() {
timestampOCRClient = gosseract.NewClient()
timestampOCRClient.SetPageSegMode(gosseract.PSM_SINGLE_LINE)
timestampOCRClient.SetWhitelist("0123456789-: ")
}
// CloseTimestampOCR closes the OCR client
func CloseTimestampOCR() {
if timestampOCRClient != nil {
timestampOCRClient.Close()
}
}
// extractTimestamp extracts and OCRs the timestamp from the top of the frame
// Returns: (secondsDifference, ocrDurationMs, error)
func extractTimestamp(frame gocv.Mat) (int, int64, error) {
startTime := time.Now()
// Extract 1024x68 region
timestampRegion := frame.Region(image.Rect(0, 0, 1024, 68))
defer timestampRegion.Close()
// Downscale 50% (reduces OCR processing time)
downscaled := gocv.NewMat()
defer downscaled.Close()
gocv.Resize(timestampRegion, &downscaled, image.Point{512, 34}, 0, 0, gocv.InterpolationArea)
// Grayscale
gray := gocv.NewMat()
defer gray.Close()
gocv.CvtColor(downscaled, &gray, gocv.ColorBGRToGray)
// Binary threshold (Otsu automatically finds best threshold)
binary := gocv.NewMat()
defer binary.Close()
gocv.Threshold(gray, &binary, 0, 255, gocv.ThresholdBinary|gocv.ThresholdOtsu)
// Invert (white text on black → black text on white for better OCR)
inverted := gocv.NewMat()
defer inverted.Close()
gocv.BitwiseNot(binary, &inverted)
// Encode as PNG (lossless, better for text)
buf, err := gocv.IMEncode(".png", inverted)
if err != nil {
return 0, time.Since(startTime).Milliseconds(), fmt.Errorf("failed to encode: %w", err)
}
defer buf.Close()
// OCR with REUSED client (major speed boost)
err = timestampOCRClient.SetImageFromBytes(buf.GetBytes())
if err != nil {
return 0, time.Since(startTime).Milliseconds(), fmt.Errorf("failed to set image: %w", err)
}
text, err := timestampOCRClient.Text()
if err != nil {
return 0, time.Since(startTime).Milliseconds(), fmt.Errorf("OCR failed: %w", err)
}
// Parse timestamp
text = strings.TrimSpace(text)
text = strings.ReplaceAll(text, "\n", "")
// Pattern: YYYY-MM-DD HH:MM:SS
re := regexp.MustCompile(`(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})`)
matches := re.FindStringSubmatch(text)
if len(matches) != 7 {
return 0, time.Since(startTime).Milliseconds(), fmt.Errorf("could not parse timestamp from: '%s'", text)
}
// Parse components
year, _ := strconv.Atoi(matches[1])
month, _ := strconv.Atoi(matches[2])
day, _ := strconv.Atoi(matches[3])
hour, _ := strconv.Atoi(matches[4])
minute, _ := strconv.Atoi(matches[5])
second, _ := strconv.Atoi(matches[6])
// Create timestamp in local timezone
cameraTime := time.Date(year, time.Month(month), day, hour, minute, second, 0, time.Local)
serverTime := time.Now()
// Calculate difference
diff := int(serverTime.Sub(cameraTime).Seconds())
ocrDuration := time.Since(startTime).Milliseconds()
return diff, ocrDuration, nil
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 875 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -0,0 +1,167 @@
package main
import (
"gocv.io/x/gocv"
)
// ProcessingStatus represents the outcome of processing a frame
type ProcessingStatus int
const (
StatusSuccess ProcessingStatus = iota
StatusCorrupted
StatusLowConfidence
StatusLayoutInvalid
StatusNoChange
StatusUnstable
)
// Reading holds SpO2 and HR values with confidence scores
type Reading struct {
SpO2 int
SpO2LeftDigit int
SpO2LeftConf float64
SpO2RightDigit int
SpO2RightConf float64
HR int
HRLeftDigit int
HRLeftConf float64
HRRightDigit int
HRRightConf float64
Timestamp string
FrameNum int
}
// AvgConfidence returns average confidence for SpO2 and HR
func (r *Reading) AvgConfidence() (spo2Avg, hrAvg float64) {
spo2Avg = (r.SpO2LeftConf + r.SpO2RightConf) / 2.0
hrAvg = (r.HRLeftConf + r.HRRightConf) / 2.0
return
}
// IsCorrupted checks if any digit is marked as invalid (-1)
func (r *Reading) IsCorrupted() bool {
return r.SpO2LeftDigit == -1 || r.SpO2RightDigit == -1 ||
r.HRLeftDigit == -1 || r.HRRightDigit == -1
}
// GetCorruptionDetails returns a description of which digit(s) are corrupted
func (r *Reading) GetCorruptionDetails() string {
corrupted := []string{}
if r.SpO2LeftDigit == -1 {
corrupted = append(corrupted, "SpO2 digit 2")
}
if r.SpO2RightDigit == -1 {
corrupted = append(corrupted, "SpO2 digit 3")
}
if r.HRLeftDigit == -1 {
corrupted = append(corrupted, "HR digit 2")
}
if r.HRRightDigit == -1 {
corrupted = append(corrupted, "HR digit 3")
}
if len(corrupted) == 0 {
return "unknown"
}
result := ""
for i, c := range corrupted {
if i > 0 {
result += ", "
}
result += c
}
return result
}
// ProcessingResult contains the outcome of processing a single frame
type ProcessingResult struct {
Status ProcessingStatus
Reading Reading
ShouldPost bool
UnstableReason string
}
// ProcessingState tracks all state across frame processing
type ProcessingState struct {
// Layout detection
Layout *ScreenLayout
LayoutValid bool
LockedScale float64
LockedRotation float64 // Rotation angle in degrees (0 = no rotation)
// Change detection
LastPosted Reading
// Stability tracking
LastReading Reading
HasLastReading bool
PendingReading *Reading
BaselineReading Reading
// Retry state
InRetry bool
RetryCount int
MaxRetries int
ConsecutiveFailures int // Track consecutive layout/processing failures
// Statistics
FrameCount int
ProcessedCount int
SuccessCount int
FailureCount int
LowConfidenceCount int
TimestampCheckCounter int // Check timestamp every 10 processed frames
// Review entries
ReviewEntries []ReviewEntry
// Timing tracking
TimingFrameCount int // Count frames for periodic header
}
// NewProcessingState creates initial state
func NewProcessingState() *ProcessingState {
return &ProcessingState{
LayoutValid: false,
LockedScale: 1.0,
LockedRotation: 0.0,
MaxRetries: 1,
LastPosted: Reading{SpO2: -1, HR: -1},
LastReading: Reading{SpO2: -1, HR: -1},
BaselineReading: Reading{SpO2: -1, HR: -1},
}
}
// Action represents what to do next
type Action int
const (
ActionContinue Action = iota // Continue to next frame
ActionRetry // Retry with next frame + layout re-detection
ActionPost // Post reading to Home Assistant
ActionHold // Hold reading for validation
ActionDiscard // Discard reading
)
// FrameData represents a preprocessed frame ready for processing
type FrameData struct {
Original gocv.Mat
Normalized gocv.Mat
FrameNum int
Timestamp string
}
// TimingData holds timing measurements for a single frame
type TimingData struct {
FrameNum int
Acquire int64 // Frame acquisition (ms)
Threshold int64 // Grayscale + threshold (ms)
Preprocess int64 // Crop + rotate (ms)
Scale int64 // Frame scaling (ms)
OCR_SpO2 int64 // SpO2 recognition (ms)
OCR_HR int64 // HR recognition (ms)
Validation int64 // Validation checks (ms)
FileIO int64 // Saving review images (ms)
HASS int64 // Home Assistant POST (ms)
Total int64 // Total processing time (ms)
}

View File

@ -0,0 +1,144 @@
package main
import (
"fmt"
"io"
"time"
)
// Validator functions handle the three-tier exception system
// validateCorruption checks for corrupted digits (Handler #1)
func validateCorruption(reading *Reading, logger io.Writer) (Action, string) {
if reading.IsCorrupted() {
logMessage(LogFile, Info, " [CORRUPTION] Invalid digit detected: SpO2(%d,%d) HR(%d,%d) - skipping frame",
reading.SpO2LeftDigit, reading.SpO2RightDigit,
reading.HRLeftDigit, reading.HRRightDigit)
return ActionContinue, ""
}
return ActionContinue, ""
}
// validateConfidence checks OCR confidence (Handler #2)
func validateConfidence(reading *Reading, state *ProcessingState, logger io.Writer) (Action, string) {
spo2Avg, hrAvg := reading.AvgConfidence()
// High confidence - pass through
if spo2Avg >= 85 && hrAvg >= 85 {
state.InRetry = false // Clear retry flag
return ActionContinue, ""
}
// Low confidence
if !state.InRetry {
// First low confidence - trigger retry
state.InRetry = true
logMessage(LogFile, Info, " [LOW CONFIDENCE] SpO2: %.1f%%, HR: %.1f%% - retrying with layout re-detection",
spo2Avg, hrAvg)
return ActionRetry, ""
}
// Second consecutive low confidence - give up
state.InRetry = false
logMessage(LogFile, Info, " [LOW CONFIDENCE] Still low after retry (SpO2: %.1f%%, HR: %.1f%%) - pausing",
spo2Avg, hrAvg)
return ActionDiscard, ""
}
// validateStability checks for large deltas with hindsight validation (Handler #3)
func validateStability(reading *Reading, state *ProcessingState, logger io.Writer) (Action, string) {
// First reading - just accept it
if !state.HasLastReading {
state.LastReading = *reading
state.HasLastReading = true
state.BaselineReading = *reading
return ActionPost, ""
}
// Calculate deltas from last reading
spo2Delta := abs(reading.SpO2 - state.LastReading.SpO2)
hrDelta := abs(reading.HR - state.LastReading.HR)
// Stable (Δ ≤ 3) - check if we have pending
if spo2Delta <= 3 && hrDelta <= 3 {
if state.PendingReading != nil {
// Check if current reading is closer to baseline or to pending
spo2ToBaseline := abs(reading.SpO2 - state.BaselineReading.SpO2)
hrToBaseline := abs(reading.HR - state.BaselineReading.HR)
spo2ToPending := abs(reading.SpO2 - state.PendingReading.SpO2)
hrToPending := abs(reading.HR - state.PendingReading.HR)
if spo2ToBaseline <= spo2ToPending && hrToBaseline <= hrToPending {
// Closer to baseline - pending was a glitch
logMessage(LogFile, Info, " [STABILITY] Discarding glitch: baseline(%d,%d) -> pending(%d,%d) -> current(%d,%d)",
state.BaselineReading.SpO2, state.BaselineReading.HR,
state.PendingReading.SpO2, state.PendingReading.HR,
reading.SpO2, reading.HR)
state.PendingReading = nil
} else {
// Closer to pending - real trend, post pending first
logMessage(LogFile, Info, " [STABILITY] Confirming trend: baseline(%d,%d) -> pending(%d,%d) -> current(%d,%d)",
state.BaselineReading.SpO2, state.BaselineReading.HR,
state.PendingReading.SpO2, state.PendingReading.HR,
reading.SpO2, reading.HR)
// Will be handled by caller to post pending first
state.PendingReading = nil
}
}
// Accept current reading
state.LastReading = *reading
state.BaselineReading = *reading
return ActionPost, ""
}
// Large delta (Δ > 3) - check if we have pending
if state.PendingReading != nil {
// Check direction vs pending
spo2Direction := reading.SpO2 - state.PendingReading.SpO2
hrDirection := reading.HR - state.PendingReading.HR
pendingSpo2Diff := state.PendingReading.SpO2 - state.LastReading.SpO2
pendingHrDiff := state.PendingReading.HR - state.LastReading.HR
// Same direction if signs match
spo2SameDir := (spo2Direction > 0 && pendingSpo2Diff > 0) || (spo2Direction < 0 && pendingSpo2Diff < 0)
hrSameDir := (hrDirection > 0 && pendingHrDiff > 0) || (hrDirection < 0 && pendingHrDiff < 0)
if spo2SameDir && hrSameDir {
// Trend confirmed - post pending, then current becomes new pending
logMessage(LogFile, Info, " [STABILITY] Trend confirmed: %d->%d->%d (SpO2), %d->%d->%d (HR)",
state.LastReading.SpO2, state.PendingReading.SpO2, reading.SpO2,
state.LastReading.HR, state.PendingReading.HR, reading.HR)
// Post pending (caller handles), then hold current
oldPending := state.PendingReading
state.PendingReading = reading
state.LastReading = *reading
// Return special signal that pending should be posted
_ = oldPending // Will be handled by caller
return ActionHold, ""
} else {
// Opposite directions - discard pending as glitch
logMessage(LogFile, Info, " [STABILITY] Direction mismatch: %d->%d->%d (SpO2), %d->%d->%d (HR)",
state.LastReading.SpO2, state.PendingReading.SpO2, reading.SpO2,
state.LastReading.HR, state.PendingReading.HR, reading.HR)
state.PendingReading = nil
state.LastReading = *reading
return ActionPost, ""
}
}
// No pending yet - hold this unstable reading
logMessage(LogFile, Info, " [STABILITY] Holding unstable reading: SpO2 Δ%d, HR Δ%d", spo2Delta, hrDelta)
state.BaselineReading = state.LastReading
state.PendingReading = reading
state.LastReading = *reading
reason := fmt.Sprintf("[%s] Unstable (SpO2 Δ%d, HR Δ%d)", time.Now().Format("15:04:05.000"), spo2Delta, hrDelta)
return ActionHold, reason
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,328 @@
# Systematic Review of MRI Brain Report for Sophia
**Patient:** Sophia Helena Jongsma
**Date of Birth:** January 1, 2017
**Date of MRI:** May 6, 2022
**Date of Review:** November 10, 2025
**Original Radiologist:** Javier Quintana, MD
---
## Author's Note
This document was written by Johan Jongsma (Sophia's father) with the assistance of Claude Opus AI. While I am not a medical professional, I have strong analytical skills that I've applied to understanding my daughter's condition through systematic observation and reasoning. The MRI analysis referenced in this document was performed by Claude Opus AI based on the original radiology reports from May 6, 2022.
---
## Original Radiologist's Report
> IMPRESSION: In light of the history, imaging findings favored to reflect a degree of global hypoxic injury as discussed in detail.
>
> Indication: >72 hours post-cardiac arrest. Please evaluate Comparison Study Date: 5/6/2022 Technique: Multiplanar multisequence imaging of the brain was performed with and without contrast.
>
> Findings: There is moderate opacification of the paranasal sinuses.<sup>1</sup> There is bilateral mastoid fluid.<sup>2</sup> The brain is abnormal. Extensive areas of T2 prolongation are identified within the posterior upper cervical cord and brainstem<sup>3</sup>, midbrain, periaqueductal gray, thalami, basal ganglia<sup>4-7</sup>. Abnormal increased T2 signal with swelling is identified throughout much of the supratentorial cortex more noticeable in the parietal and occipital regions. This does affect the temporal and frontal lobes as well.<sup>8</sup> Diffusion-weighted images are abnormal demonstrating restricted water motion along much of the cortical abnormalities near the gray-white matter junction. Restricted water motion within the basal ganglia and thalami is also noted to a degree. Involvement of some of the white matter is not entirely excluded as well.<sup>9-11</sup> The lateral ventricles are symmetric and not particularly dilated and similar to this morning CT.<sup>12</sup> The cavum septum pellucidum seen on the 5/2/2020 examination is however no longer visualized suggesting a degree of mass effect.<sup>13</sup> However, suprasellar cisterns remain patent with probably similar appearance of the medial temporal lobes and cerebellar tonsils when accounting for the differences technique. The major intracranial flow-voids are grossly preserved. The cerebellar tonsils are rounded appearing and above the level of foramen magnum and again similar.<sup>14-16</sup> The adenohypophysis is somewhat thin appearing which may be within anatomic range. Neurohypophysis is orthotopic. Corpus callosum is present. Subcutaneous edema is questioned within the face. As seen on the recent MRI, there does appear to be a degree of increased enhancement within the sulci bilaterally. However, given the underlying changes within the brain, this is favored to be related to congestion rather than leptomeningeal disease. Some degree of enhancement is questioned within the midbrain and periaqueductal gray as well as the inferior colliculus.<sup>17-23</sup>
**Note:** Superscript numbers have been added to the original report text for reference in the detailed analysis that follows.
---
## CRITICAL DISCOVERY #1: Temporal Evolution of Cisterna Magna Enlargement
### Comparative CT Analysis: May 2, 2022 vs May 6, 2022
A systematic comparison of CT scans reveals the most important finding was missed entirely by the radiologist: **the massive cisterna magna enlargement developed progressively over the 4 days following the incident.**
#### May 2, 2022 CT (8:31 PM - Day of Incident)
**Observations:**
- **Normal-sized posterior fossa CSF spaces**
- Brainstem in normal anatomic position
- No evidence of enlarged cisterna magna
- Cerebellar hemispheres normal in size and position
- No anterior compression or flattening of brainstem
#### May 6, 2022 CT (8:49 AM - Four Days Post-Incident)
**Observations:**
- **Massive enlargement of posterior fossa CSF space (cisterna magna)**
- Brainstem compressed and displaced anteriorly
- Cerebellar hemispheres pushed laterally by CSF accumulation
- Clear evidence of progressive mass effect
#### May 6, 2022 MRI (Same Day as Second CT)
**Observations:**
- Confirms massive cisterna magna enlargement
- Brainstem mechanically compressed and flattened
- FLAIR sequences show BLACK signal (CSF), NOT tissue edema
### Clinical Significance
This temporal sequence establishes that the enlarged cisterna magna is:
1. **ACQUIRED** - Not present immediately after the incident
2. **PROGRESSIVE** - Developed over a 4-day period
3. **STRUCTURAL** - Represents CSF accumulation, not hypoxic tissue injury
4. **MECHANICAL** - Causing brainstem compression through mass effect
---
## CRITICAL DISCOVERY #2: Acquired Cerebellar Tonsil Herniation
### Comparative Analysis: May 2, 2022 vs May 6, 2022
In addition to the cisterna magna enlargement, systematic comparison reveals **progressive descent of the cerebellar tonsils** between May 2 and May 6, 2022.
#### May 2, 2022 CT
**Observations:**
- Cerebellar tonsils in **normal position** relative to foramen magnum
- Normal tapered configuration of tonsils
- No tonsillar herniation
#### May 6, 2022 CT
**Observations:**
- Cerebellar tonsils showing **rounded/blunted contour**
- Beginning descent toward foramen magnum
#### May 6, 2022 MRI (SAG T1, slice 15/29)
**Observations:**
- Cerebellar tonsils **descended/herniated** into or through the foramen magnum opening
- Acquired Chiari-like malformation
- Tonsils being compressed downward by the massive cisterna magna above
### Clinical Significance of Tonsillar Herniation
The progressive tonsillar descent is critical because:
1. **Acquired, not congenital** - This is NOT a pre-existing Chiari malformation, but an acquired herniation caused by the enlarging cisterna magna pushing the cerebellum downward
2. **CSF flow obstruction** - Herniated tonsils can block CSF circulation at the foramen magnum, potentially contributing to or perpetuating the cisterna magna enlargement
3. **Direct neural compression** - Tonsils compress the brainstem and upper spinal cord, explaining:
- Autonomic dysfunction (breathing, heart rate abnormalities)
- Positional symptoms (inability to tolerate right-side positioning)
- Progressive neurological deterioration
4. **Surgically addressable** - Posterior fossa decompression surgery can:
- Relieve tonsillar compression
- Restore CSF flow
- Address the cisterna magna
- Potentially reverse or improve symptoms
### Clinical Context
According to the patient's father, the medical team urgently performed imaging on May 6 because they were concerned about worsening pressure on the brainstem/spinal cord. The clinical team observed progressive neurological deterioration over those 4 days - which correlates directly with the progressive structural changes documented here: both the cisterna magna enlargement AND the tonsillar herniation were actively worsening.
**These findings completely change the interpretation of Sophia's condition.**
---
## Detailed Finding-by-Finding Analysis of May 6 MRI Report
### Finding #1-2: Paranasal sinus opacification and mastoid fluid
**Sequences reviewed:** AX T1 (slice 1/25)
**Observation:** Confirmed presence of fluid in sinuses and mastoid air cells
**Assessment:** Accurate but clinically irrelevant. Common in intubated ICU patients.
### Finding #3: "T2 prolongation in posterior upper cervical cord and brainstem"
**Sequences reviewed:** SAG T1 (15/29), AX T2 (7/25), AX FLAIR (7/25)
**Observation:**
- Massive CSF space posterior to brainstem (enlarged cisterna magna)
- Brainstem mechanically compressed/flattened anteriorly
- FLAIR shows BLACK signal confirming CSF, NOT tissue edema
- **May 2 CT shows this was NOT present initially**
**Assessment:** **FUNDAMENTAL MISINTERPRETATION** - This is an acquired structural CSF enlargement that developed over 4 days, NOT hypoxic tissue injury as claimed.
### Finding #4-7: Midbrain, periaqueductal gray, thalami, basal ganglia changes
**Sequences reviewed:** AX T2 (10,12/25), AX FLAIR (8,9/25)
**Observation:**
- Some T2 hyperintensity confirmed in deep gray structures
- FLAIR confirms tissue changes (not just CSF effect)
- Pattern consistent with some injury but not extensive
**Assessment:** Partially accurate, changes present but must be interpreted in context of progressive mechanical compression rather than purely hypoxic etiology.
### Finding #8: "Cortical swelling throughout supratentorial regions"
**Sequences reviewed:** AX FLAIR (10,12,14,20,22,24/25)
**Observation:**
- Sulci remain OPEN and visible
- Gray-white differentiation preserved
- Minimal signal changes, NO significant swelling
**Assessment:** **GROSSLY OVERSTATED** - Minimal changes present, no significant swelling evident.
### Finding #9-11: "Restricted diffusion in cortex and deep gray matter"
**Sequences reviewed:** T2 weighted trace B1000 (13,23,44/56), Average DCTENSOR B1000 (13,23/56), Fractional Anisotropy B1000 (13,23/56)
**Observation:**
- DWI images essentially NEGATIVE - no significant restricted diffusion
- FA maps show preserved white matter tract integrity
**Assessment:** **DEMONSTRABLY FALSE** - No evidence of extensive restricted diffusion. This directly contradicts the claim of severe acute hypoxic-ischemic injury.
### Finding #12: Ventricles symmetric, not dilated
**Assessment:** Accurate, normal finding.
### Finding #13: Cavum septum pellucidum comparison to "5/2/2020"
**Critical Issue:** References exam from "5/2/2020" - TWO YEARS before accident occurred (5/2/2022). Presumably meant to reference May 2, 2022 CT.
**Assessment:** **MAJOR ERROR** - Wrong date by exactly 2 years, indicating severe inattention to detail and destroying report credibility.
### Finding #14-16: "Cerebellar tonsils rounded appearing and above the level of foramen magnum"
**Sequences reviewed:** SAG T1 (especially slice 15/29)
**Observation:**
- Radiologist noted "rounded" appearance and claimed tonsils "above foramen magnum"
- Direct comparison with May 2 CT shows cerebellar tonsils were in normal position on May 2
- May 6 MRI shows cerebellar tonsils have **descended/herniated** into or through the foramen magnum
- **This is acquired tonsillar herniation, NOT a stable finding**
**Assessment:** **CRITICAL MISINTERPRETATION** - The radiologist noted abnormal tonsil appearance but:
1. Failed to recognize this as an acquired change (comparing to May 2 imaging)
2. Mischaracterized the tonsil position as "above foramen magnum" when MRI shows herniation
3. Missed the clinical significance of progressive tonsillar descent
4. This is acquired Chiari-like malformation from progressive compression, not a pre-existing condition
### Finding #17-23: Various minor/questioned findings
**Assessment:** Multiple vague "questioned" findings showing uncertainty throughout report.
---
## Summary of Errors
| Finding | Radiologist's Claim | Actual Finding | Clinical Impact |
|---------|-------------------|----------------|-----------------|
| **Timeline recognition** | Not addressed | **Cisterna magna AND tonsils changed May 2-6** | **Critical missed findings** |
| **Posterior brainstem "T2 prolongation"** | Hypoxic injury | **Acquired enlarged cisterna magna** | **Fundamental misdiagnosis** |
| **Cerebellar tonsils** | "Rounded, above foramen magnum" | **Acquired herniation/descent** | **Missed progressive compression** |
| Deep gray matter changes | Present | Partially accurate | Some changes present |
| **Cortical "swelling"** | Extensive | **Minimal - Exaggerated** | Overstated severity |
| **Restricted diffusion** | Extensive | **FALSE - DWI negative** | **Major error contradicting hypoxic diagnosis** |
| **Date reference** | 5/2/2020 exam | **Wrong by 2 years** | **Credibility destroyed** |
| Structural findings | Various | Misinterpreted | Mechanical vs hypoxic confusion |
---
## What the Report SHOULD Have Said
> **IMPRESSION:** Serial imaging demonstrates progressive structural abnormalities in the posterior fossa developing between May 2 and May 6, 2022, including marked enlargement of the cisterna magna and acquired cerebellar tonsillar herniation, resulting in brainstem and spinal cord compression. The absence of significant restricted diffusion on DWI sequences argues strongly against extensive acute hypoxic-ischemic injury as the primary pathology. Mild T2 signal changes in deep gray structures may represent limited injury, but the dominant findings are progressive structural/mechanical pathology rather than hypoxic injury.
>
> **COMPARISON:** CT brain May 2, 2022 shows normal posterior fossa anatomy with normal cerebellar tonsil position. Current study (May 6, 2022) demonstrates dramatic interval development of:
> 1. Markedly enlarged cisterna magna
> 2. Acquired cerebellar tonsillar herniation
>
> **KEY FINDINGS:**
>
> 1. **Progressive cisterna magna enlargement** - Developed between May 2 and May 6, 2022 (documented on serial CT)
> 2. **Acquired cerebellar tonsillar herniation** - Tonsils descended from normal position (May 2) to herniation through foramen magnum (May 6), creating Chiari-like malformation
> 3. **Acquired brainstem compression** - Anterior displacement and flattening of brainstem due to posterior CSF accumulation
> 4. **CSF flow obstruction** - Herniated tonsils blocking CSF circulation at foramen magnum level
> 5. Mild T2 hyperintensity in bilateral thalami and basal ganglia without corresponding restricted diffusion
> 6. **No significant cortical edema or swelling** - sulci remain visible, gray-white differentiation preserved
> 7. **No evidence of extensive acute hypoxic-ischemic injury** on diffusion-weighted imaging
> 8. Preserved white matter tract integrity on fractional anisotropy maps
>
> **CLINICAL CORRELATION:**
> The progressive nature of both the cisterna magna enlargement and tonsillar herniation over 4 days, accompanied by clinical deterioration prompting urgent imaging, suggests an active structural process rather than immediate traumatic or hypoxic injury. The mechanism may represent:
> - Post-traumatic arachnoid disruption with progressive CSF accumulation
> - Impaired CSF circulation/resorption exacerbated by tonsillar herniation
> - Progressive posterior fossa pressure changes causing secondary tonsillar descent
>
> **RECOMMENDATION:** URGENT neurosurgical consultation for evaluation of:
> 1. Acquired cisterna magna enlargement
> 2. Acquired cerebellar tonsillar herniation (Chiari-like malformation)
> 3. Brainstem and spinal cord compression
>
> The progressive nature of these findings over 4 days with accompanying clinical deterioration suggests an evolving process requiring prompt neurosurgical assessment. Posterior fossa decompression may be indicated to:
> - Relieve tonsillar herniation
> - Restore CSF flow
> - Decompress brainstem
> - Address cisterna magna
> - Potentially reverse or improve neurological status
---
## Conclusion
This systematic review reveals fundamental errors in the original radiological interpretation that completely missed TWO critical progressive findings:
### Primary Findings: Progressive Acquired Structural Abnormalities
**1. The enlarged cisterna magna developed between May 2 and May 6, 2022.**
**2. Cerebellar tonsil herniation developed between May 2 and May 6, 2022.**
This is documented by direct comparison of serial imaging showing:
- Normal posterior fossa anatomy on May 2, 2022 (day of incident)
- Normal cerebellar tonsil position on May 2, 2022
- Massive cisterna magna enlargement by May 6, 2022 (4 days later)
- Cerebellar tonsillar herniation by May 6, 2022 (4 days later)
This temporal evolution establishes that we are dealing with **acquired, progressive structural processes** - not pre-existing anatomical variants, and not immediate traumatic injury.
### Critical Errors in Original Report
1. **Failure to recognize temporal evolution:** The radiologist had access to the May 2 CT for comparison but failed to identify or comment on the dramatic interval development of BOTH the cisterna magna enlargement AND the cerebellar tonsillar herniation.
2. **Misidentification of pathology:** The most striking findings - a progressively enlarging cisterna magna causing brainstem compression and progressive tonsillar herniation - were misinterpreted as "T2 prolongation" from hypoxic injury and static "rounded" tonsils. The FLAIR sequences clearly show BLACK signal (CSF), not the bright signal expected from tissue edema.
3. **False diffusion findings:** The report claimed "extensive restricted diffusion" throughout the brain. Direct review of DWI sequences shows they are essentially negative. This directly contradicts the diagnosis of severe acute hypoxic-ischemic injury.
4. **Basic factual errors:** The report references a comparison study from "5/2/2020" - exactly two years before the actual incident. This appears to be a typo for 5/2/2022, but such an error in a critical report indicates severe inattention to detail.
5. **Exaggeration of findings:** Claimed "extensive swelling" where minimal changes exist.
6. **Mischaracterization of tonsillar position:** Described tonsils as "above the level of foramen magnum" when imaging shows herniation into or through the foramen magnum.
### Clinical Implications
**Sophia has acquired, progressive posterior fossa pathology consisting of:**
1. **Cisterna magna enlargement** causing brainstem compression
2. **Cerebellar tonsillar herniation** (acquired Chiari-like malformation) causing:
- CSF flow obstruction at foramen magnum
- Direct brainstem and upper spinal cord compression
- Autonomic dysfunction
- Positional symptoms
**These conditions:**
- Developed progressively over 4 days following the incident
- Represent structural/mechanical problems rather than purely hypoxic injury
- May be amenable to neurosurgical intervention (posterior fossa decompression)
- Have completely different prognostic implications than diffuse hypoxic-ischemic injury
**The absence of significant restricted diffusion on DWI sequences argues strongly against extensive acute hypoxic-ischemic injury as the primary diagnosis.** The radiologist's conclusion of "global hypoxic injury" is not supported by the imaging findings and completely misses the progressive structural pathology that is the dominant finding.
### Mechanism of Injury: Implications
The progressive development of both cisterna magna enlargement and tonsillar herniation between May 2 and May 6 suggests several possible mechanisms:
1. **Post-traumatic CSF accumulation** - Disruption of arachnoid membranes or posterior fossa structures allowing CSF to accumulate, with increasing pressure causing downward cerebellar displacement and tonsillar herniation
2. **Impaired CSF circulation** - Damage to CSF resorption pathways leading to focal accumulation, with subsequent mass effect causing tonsillar descent and further CSF flow obstruction, creating a progressive cycle
3. **Combined mechanism** - Initial CSF accumulation leading to tonsillar herniation, which then further impairs CSF flow and perpetuates the enlargement
The key point is that these are **progressive, acquired processes that may be reversible or amenable to treatment** - fundamentally different from the fixed, irreversible hypoxic injury diagnosis that was given.
**This analysis fundamentally changes the understanding of Sophia's condition and has profound implications for both prognosis and treatment options, including the critical potential role for neurosurgical intervention to address:**
- Mechanical brainstem compression from cisterna magna
- Cerebellar tonsillar herniation and CSF flow obstruction
- Progressive posterior fossa pathology that was actively worsening and prompted urgent clinical concern
The medical team's urgent imaging on May 6 due to concerns about "worsening pressure" was clinically appropriate - they were observing the effects of these progressive structural changes. The tragedy is that the radiologist completely missed documenting this progressive pathology, instead attributing everything to irreversible hypoxic injury.
---
**Document prepared by:** Johan Jongsma
**Date:** November 10, 2025
**Assistance:** Claude Opus AI
**Purpose:** Medical consultation with neurology specialist

View File

@ -0,0 +1,44 @@
#!/bin/bash
# Build script for pulseox-monitor v3.60
echo "=== Building PulseOx Monitor v3.60 (Unified Detection) ==="
echo ""
# List of all source files needed
FILES="pulseox-monitor.go types.go helpers.go frame_source.go processor.go validators.go ocr.go layout_detection.go detection.go config.go homeassistant.go html_report.go timestamp_ocr.go"
# Check if all files exist
MISSING=0
for file in $FILES; do
if [ ! -f "$file" ]; then
echo "❌ Missing file: $file"
MISSING=1
fi
done
if [ $MISSING -eq 1 ]; then
echo ""
echo "Error: Missing source files. Please ensure all files are present."
exit 1
fi
echo "✓ All source files present"
echo ""
echo "Compiling..."
# Build
go build -o pulseox-monitor $FILES
if [ $? -eq 0 ]; then
echo ""
echo "✓ Build successful: pulseox-monitor"
echo ""
echo "Test with:"
echo " ./pulseox-monitor raw_frames/raw_20251028-00123.png # Single frame"
echo " ./pulseox-monitor # Live stream"
echo ""
else
echo ""
echo "❌ Build failed"
exit 1
fi

View File

@ -0,0 +1,41 @@
package main
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Camera struct {
RTSPURL string `yaml:"rtsp_url"`
} `yaml:"camera"`
HomeAssistant struct {
URL string `yaml:"url"`
Token string `yaml:"token"`
} `yaml:"home_assistant"`
Processing struct {
SampleInterval int `yaml:"sample_interval"`
ChangeThresholdPercent int `yaml:"change_threshold_percent"`
MaxDriftSeconds int `yaml:"max_drift_seconds"`
} `yaml:"processing"`
Logging struct {
Level string `yaml:"level"`
File string `yaml:"file"`
} `yaml:"logging"`
}
func LoadConfig(filename string) (*Config, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
return &config, nil
}

View File

@ -0,0 +1,25 @@
# Pulse Oximeter Monitor Configuration
camera:
# Tapo C110 RTSP URL (pulse ox monitoring camera)
rtsp_url: "rtsp://tapohass:!!Helder06@192.168.2.183:554/stream1"
home_assistant:
# TS140 server IP where HASS is hosted
url: "http://192.168.1.252:8123"
# Long-lived access token for pulse-monitor
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzOTMxMTA4MjczYzI0NDU1YjIzOGJlZWE0Y2NkM2I1OCIsImlhdCI6MTc2MTExNTQxNywiZXhwIjoyMDc2NDc1NDE3fQ.URFS4M0rX78rW27gQuBX-PyrPYMLlGujF16jIBHXYOw"
processing:
# How often to capture and process frames (seconds)
sample_interval: 1
# Reject readings that change more than this percent
change_threshold_percent: 5
# Maximum allowed drift INCREASE in seconds (detects camera lag/freeze)
# Camera can be 10s behind server consistently - that's fine
# But if drift increases by more than this, frame is stale
max_drift_seconds: 3
logging:
level: "info" # debug, info, warn, error
file: "./logs/pulse-monitor.log"

View File

@ -0,0 +1,20 @@
#!/bin/bash
# Backup script for pulse-monitor before refactoring
# Created: 2025-10-30
BACKUP_DIR="backups/backup_20251030_043342"
mkdir -p "$BACKUP_DIR"
# Copy all source files
cp pulse-monitor.go "$BACKUP_DIR/"
cp ocr.go "$BACKUP_DIR/"
cp layout_detection.go "$BACKUP_DIR/"
cp normalize.go "$BACKUP_DIR/"
cp config.go "$BACKUP_DIR/"
cp homeassistant.go "$BACKUP_DIR/"
cp html_report.go "$BACKUP_DIR/"
cp PROJECT_STATE.md "$BACKUP_DIR/"
cp config.yaml "$BACKUP_DIR/"
echo "✓ Backup completed to $BACKUP_DIR"
ls -lh "$BACKUP_DIR"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,237 @@
package main
import (
"fmt"
"image"
"sync"
"time"
"gocv.io/x/gocv"
)
// FrameSource provides frames to process
type FrameSource interface {
// Next returns the next frame and whether to continue processing
// Returns (frame, shouldContinue, error)
Next() (gocv.Mat, bool, error)
// NextImmediate returns the very next frame (ignoring skip count)
// Used for immediate retry after low confidence/corruption
NextImmediate() (gocv.Mat, bool, error)
// Close cleans up resources
Close() error
// Name returns a description of the source
Name() string
// IsActive returns false if source has been closed
IsActive() bool
}
// RTSPSource reads from an RTSP stream
type RTSPSource struct {
url string
stream *gocv.VideoCapture
frameNum int
minFrameInterval time.Duration // Minimum time between processed frames
lastProcessedTime time.Time // Last time we processed a frame
lastAcquiredTime time.Time // Last time we acquired a frame (for logging)
closed bool
closeOnce sync.Once
}
// NewRTSPSource creates a new RTSP frame source
// Processes frames at 4 fps (250ms minimum between frames)
func NewRTSPSource(url string) (*RTSPSource, error) {
stream, err := gocv.VideoCaptureFile(url)
if err != nil {
return nil, fmt.Errorf("failed to connect to RTSP stream: %w", err)
}
logMessage(Console, Info, "📊 Processing frames at 4 fps (250ms minimum interval)")
return &RTSPSource{
url: url,
stream: stream,
minFrameInterval: 250 * time.Millisecond,
lastProcessedTime: time.Time{}, // Zero time = process first frame immediately
}, nil
}
// readFrame reads and validates the next frame from the stream
func (s *RTSPSource) readFrame() (gocv.Mat, bool, error) {
// Check if source was closed
if s.closed {
return gocv.NewMat(), false, nil
}
// Check if stream is valid
if s.stream == nil {
return gocv.NewMat(), false, fmt.Errorf("stream is null")
}
frame := gocv.NewMat()
if ok := s.stream.Read(&frame); !ok {
frame.Close()
// Check again before reconnecting
if s.closed {
return gocv.NewMat(), false, nil
}
// Try to reconnect
s.stream.Close()
time.Sleep(5 * time.Second)
// Check again after sleep
if s.closed {
return gocv.NewMat(), false, nil
}
newStream, err := gocv.VideoCaptureFile(s.url)
if err != nil {
s.stream = nil // Set to nil to prevent further read attempts
s.closed = true // Mark as closed
return gocv.NewMat(), false, fmt.Errorf("reconnect failed: %w", err)
}
s.stream = newStream
return gocv.NewMat(), true, nil // Signal to retry
}
s.frameNum++
// Check frame validity
if frame.Empty() || frame.Cols() < 640 || frame.Rows() < 480 {
frame.Close()
return gocv.NewMat(), true, nil // Signal to retry
}
return frame, true, nil
}
func (s *RTSPSource) Next() (gocv.Mat, bool, error) {
for {
frame, shouldContinue, err := s.readFrame()
if err != nil || !shouldContinue {
return frame, shouldContinue, err
}
if frame.Empty() {
// readFrame returned empty (reconnecting or invalid), retry
continue
}
// Check if enough time has passed since last processed frame was ACQUIRED
now := time.Now()
if !s.lastProcessedTime.IsZero() {
elapsed := now.Sub(s.lastProcessedTime)
if elapsed < s.minFrameInterval {
// Not enough time passed, skip this frame
frame.Close()
continue
}
}
// Enough time passed, mark acquisition time NOW (before processing)
s.lastProcessedTime = now
// Log frame acquisition interval
if !s.lastAcquiredTime.IsZero() {
interval := now.Sub(s.lastAcquiredTime).Milliseconds()
logMessage(LogFile, Debug, " [TIMING] Frame acquired: +%dms since last (target: 250ms)", interval)
}
s.lastAcquiredTime = now
return frame, true, nil
}
}
func (s *RTSPSource) NextImmediate() (gocv.Mat, bool, error) {
// Get the very next frame (no skip count)
for {
frame, shouldContinue, err := s.readFrame()
if err != nil || !shouldContinue {
return frame, shouldContinue, err
}
if frame.Empty() {
// readFrame returned empty (reconnecting or invalid), retry
continue
}
return frame, true, nil
}
}
func (s *RTSPSource) Close() error {
s.closeOnce.Do(func() {
s.closed = true
// DON'T close the stream here - it causes segfault if read is in progress
// The stream will be cleaned up when main() exits
})
return nil
}
func (s *RTSPSource) IsActive() bool {
return !s.closed
}
func (s *RTSPSource) Name() string {
return fmt.Sprintf("RTSP stream: %s", s.url)
}
// FileSource reads a single frame from a file
type FileSource struct {
path string
done bool
}
// NewFileSource creates a new file frame source
func NewFileSource(path string) *FileSource {
return &FileSource{
path: path,
done: false,
}
}
func (s *FileSource) Next() (gocv.Mat, bool, error) {
if s.done {
return gocv.NewMat(), false, nil
}
frame := gocv.IMRead(s.path, gocv.IMReadColor)
if frame.Empty() {
return gocv.NewMat(), false, fmt.Errorf("failed to load frame: %s", s.path)
}
s.done = true
return frame, false, nil // Return frame, but signal to stop after this
}
func (s *FileSource) NextImmediate() (gocv.Mat, bool, error) {
// For file source, NextImmediate is same as Next
return s.Next()
}
func (s *FileSource) Close() error {
return nil
}
func (s *FileSource) IsActive() bool {
return !s.done
}
func (s *FileSource) Name() string {
return fmt.Sprintf("File: %s", s.path)
}
// preprocessFrame crops timestamp and rotates
func preprocessFrame(frame gocv.Mat) gocv.Mat {
// Crop timestamp (top 68 pixels)
noTs := frame.Region(image.Rect(0, 68, frame.Cols(), frame.Rows()))
rotated := gocv.NewMat()
gocv.Rotate(noTs, &rotated, gocv.Rotate90Clockwise)
noTs.Close()
return rotated
}

View File

@ -0,0 +1,9 @@
module pulse-monitor
go 1.24.4
require (
github.com/otiai10/gosseract/v2 v2.4.1 // indirect
gocv.io/x/gocv v0.42.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -0,0 +1,7 @@
github.com/otiai10/gosseract/v2 v2.4.1 h1:G8AyBpXEeSlcq8TI85LH/pM5SXk8Djy2GEXisgyblRw=
github.com/otiai10/gosseract/v2 v2.4.1/go.mod h1:1gNWP4Hgr2o7yqWfs6r5bZxAatjOIdqWxJLWsTsembk=
gocv.io/x/gocv v0.42.0 h1:AAsrFJH2aIsQHukkCovWqj0MCGZleQpVyf5gNVRXjQI=
gocv.io/x/gocv v0.42.0/go.mod h1:zYdWMj29WAEznM3Y8NsU3A0TRq/wR/cy75jeUypThqU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,117 @@
package main
import (
"fmt"
"io"
"time"
)
// LogTarget specifies where to write the log message
type LogTarget int
const (
Console LogTarget = iota
LogFile
Both
)
// LogLevel specifies the severity/type of log message
type LogLevel int
const (
Debug LogLevel = iota
Info
Warning
Error
)
// Global logger - set once during initialization
var globalLogger io.Writer = nil
// logMessage writes a formatted log message to the specified target(s)
// Debug messages are automatically filtered out when DEBUG_MODE is false
//
// Usage examples:
// logMessage(Console, Info, "SpO2=%d%%, HR=%d bpm", spo2, hr)
// logMessage(Both, Debug, "Before preprocess: %dx%d", cols, rows)
// logMessage(Both, Error, "Layout detection failed: %v", err)
// logMessage(Both, Warning, "Low confidence (#%d)", count)
func logMessage(target LogTarget, level LogLevel, format string, args ...interface{}) {
// Filter debug messages when not in debug mode
if level == Debug && !DEBUG_MODE {
return
}
// Format the message
message := fmt.Sprintf(format, args...)
// Single-letter prefix for fixed-width alignment
var levelChar string
switch level {
case Debug:
levelChar = "D"
case Info:
levelChar = "I"
case Warning:
levelChar = "W"
case Error:
levelChar = "E"
}
// Format with timestamp
timestamp := time.Now().Format("15:04:05.000")
formattedMessage := fmt.Sprintf("[%s] %s %s\n", timestamp, levelChar, message)
// Write to target(s)
switch target {
case Console:
fmt.Print(formattedMessage)
case LogFile:
if globalLogger != nil {
fmt.Fprint(globalLogger, formattedMessage)
}
case Both:
fmt.Print(formattedMessage)
if globalLogger != nil {
fmt.Fprint(globalLogger, formattedMessage)
}
}
}
// logf is DEPRECATED - use logMessage() instead
// Kept temporarily for reference, but no longer used in the codebase
/*
func logf(logger io.Writer, format string, args ...interface{}) {
if logger != nil {
fmt.Fprintf(logger, format, args...)
}
}
*/
// printTimingTable prints timing data in horizontal table format
// Prints header every 20 frames for readability
// Note: Acquire is separate (camera waiting time)
// Note: Total is wall-clock processing time (may not equal sum due to logging/overhead)
// Note: HASS is added in main loop after function returns
func printTimingTable(timing TimingData, showHeader bool) {
if !TIMING_MODE {
return
}
if showHeader {
fmt.Println("")
fmt.Println("Frame | Acquire | Thresh | Prep | Scale | OCR_SpO2 | OCR_HR | Valid | FileIO | HASS | Total")
fmt.Println("------|---------|--------|------|-------|----------|--------|-------|--------|------|-------")
}
fmt.Printf("#%-4d | %5dms | %5dms | %3dms | %4dms | %7dms | %5dms | %4dms | %5dms | %3dms | %4dms\n",
timing.FrameNum,
timing.Acquire,
timing.Threshold,
timing.Preprocess,
timing.Scale,
timing.OCR_SpO2,
timing.OCR_HR,
timing.Validation,
timing.FileIO,
timing.HASS,
timing.Total)
}

Some files were not shown because too many files have changed in this diff Show More