Initial commit
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(./build.sh)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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/"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()">×</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 (<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 <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()
|
||||
}
|
||||
|
|
@ -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(¢erBoxesVis, image.Pt(scaledMinX, line50), image.Pt(scaledMaxX, line50), color.RGBA{255, 0, 0, 255}, 3)
|
||||
// Draw center boxes
|
||||
for _, box := range centerBoxes {
|
||||
gocv.Rectangle(¢erBoxesVis, 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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, ¢erBoxesVis, gocv.ColorGrayToBGR)
|
||||
// Draw 50% line
|
||||
gocv.Line(¢erBoxesVis, image.Pt(scaledMinX, line50), image.Pt(scaledMaxX, line50), color.RGBA{255, 0, 0, 255}, 3)
|
||||
// Draw center boxes
|
||||
for _, box := range centerBoxes {
|
||||
gocv.Rectangle(¢erBoxesVis, 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()
|
||||
}
|
||||
|
|
@ -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.")
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()">×</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 (<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 <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()
|
||||
}
|
||||
|
|
@ -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, ¢erBoxesVis, gocv.ColorGrayToBGR)
|
||||
// Draw 50% line
|
||||
gocv.Line(¢erBoxesVis, image.Pt(scaledMinX, line50), image.Pt(scaledMaxX, line50), color.RGBA{255, 0, 0, 255}, 3)
|
||||
// Draw center boxes
|
||||
for _, box := range centerBoxes {
|
||||
gocv.Rectangle(¢erBoxesVis, 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 881 B |
|
After Width: | Height: | Size: 823 B |
|
After Width: | Height: | Size: 809 B |
|
After Width: | Height: | Size: 694 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 668 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 726 B |
|
After Width: | Height: | Size: 774 B |
|
After Width: | Height: | Size: 825 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 728 B |
|
After Width: | Height: | Size: 886 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 767 B |
|
After Width: | Height: | Size: 774 B |
|
After Width: | Height: | Size: 720 B |
|
After Width: | Height: | Size: 875 B |
|
After Width: | Height: | Size: 781 B |
|
After Width: | Height: | Size: 793 B |
|
After Width: | Height: | Size: 755 B |
|
After Width: | Height: | Size: 793 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 829 B |
|
After Width: | Height: | Size: 890 B |
|
After Width: | Height: | Size: 897 B |
|
After Width: | Height: | Size: 726 B |
|
After Width: | Height: | Size: 800 B |
|
After Width: | Height: | Size: 786 B |
|
After Width: | Height: | Size: 901 B |
|
After Width: | Height: | Size: 816 B |
|
After Width: | Height: | Size: 770 B |
|
After Width: | Height: | Size: 754 B |
|
After Width: | Height: | Size: 771 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 795 B |
|
After Width: | Height: | Size: 783 B |
|
After Width: | Height: | Size: 780 B |
|
After Width: | Height: | Size: 756 B |
|
After Width: | Height: | Size: 716 B |
|
After Width: | Height: | Size: 777 B |
|
After Width: | Height: | Size: 673 B |
|
After Width: | Height: | Size: 651 B |
|
After Width: | Height: | Size: 769 B |
|
After Width: | Height: | Size: 398 B |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 455 B |
|
After Width: | Height: | Size: 3.6 KiB |
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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)
|
||||
}
|
||||