261 lines
9.3 KiB
Go
261 lines
9.3 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
)
|
||
|
||
type ReviewEntry struct {
|
||
FrameNum int
|
||
Timestamp string
|
||
SpO2Value int
|
||
SpO2LeftDigit int // tens digit (for compatibility)
|
||
SpO2LeftConf float64 // confidence score
|
||
SpO2RightDigit int // ones digit (for compatibility)
|
||
SpO2RightConf float64 // same as SpO2LeftConf for whole-number matching
|
||
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-low { color: #f44336; font-weight: bold; }
|
||
.crop-img { max-height: 60px; cursor: pointer; transition: transform 0.2s; border: 2px solid #444; }
|
||
.crop-img:hover { opacity: 0.8; border-color: #4CAF50; }
|
||
.ocr-value { font-size: 42px; 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; }
|
||
.approve-input { width: 60px; font-size: 18px; padding: 5px; text-align: center; background: #222; color: #fff; border: 2px solid #444; border-radius: 4px; }
|
||
.approve-input:focus { border-color: #4CAF50; outline: none; }
|
||
.approve-input.success { border-color: #4CAF50; background: #1a3a1a; }
|
||
.approve-input.error { border-color: #f44336; background: #3a1a1a; }
|
||
.approve-status { font-size: 12px; margin-top: 5px; }
|
||
</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';
|
||
}
|
||
}
|
||
|
||
function approve(input, frame, display) {
|
||
var number = parseInt(input.value);
|
||
if (isNaN(number) || number < 0 || number > 200) {
|
||
input.className = 'approve-input error';
|
||
return;
|
||
}
|
||
|
||
fetch('/approve', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({frame: frame, display: display, number: number})
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.status === 'ok') {
|
||
input.className = 'approve-input success';
|
||
input.disabled = true;
|
||
// Hide the image since it's been moved
|
||
var img = document.getElementById('img_' + frame + '_' + display);
|
||
if (img) img.style.opacity = '0.3';
|
||
} else {
|
||
input.className = 'approve-input error';
|
||
}
|
||
})
|
||
.catch(err => {
|
||
input.className = 'approve-input error';
|
||
console.error(err);
|
||
});
|
||
}
|
||
|
||
function handleKey(event, input, frame, display) {
|
||
if (event.key === 'Enter') {
|
||
approve(input, frame, display);
|
||
}
|
||
}
|
||
</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> Type correct number in input box and press Enter to save as template</p>
|
||
<p><em>Refresh page to see latest frames...</em></p>
|
||
<table>
|
||
<tr>
|
||
<th>Frame</th>
|
||
<th>Time</th>
|
||
<th>SpO2 / HR</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 >= 95 {
|
||
return "conf-high"
|
||
}
|
||
return "conf-low"
|
||
}
|
||
|
||
// Use left conf as the whole-number confidence
|
||
spo2Conf := e.SpO2LeftConf
|
||
hrConf := e.HRLeftConf
|
||
|
||
needsReview := spo2Conf < 95 || hrConf < 95
|
||
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 style="color: #ff6666;">
|
||
<strong style="color: #ff6666;">❌ %s</strong><br>
|
||
<div style="display: flex; gap: 40px; justify-content: center; align-items: center;">
|
||
<div style="display: flex; align-items: center; gap: 10px;">
|
||
<img id="img_%d_spo2" class="crop-img" src="f%d_spo2_crop.png" onclick="enlargeImage('f%d_spo2_crop.png')">
|
||
<div>
|
||
<span class="ocr-value">%d</span><br>
|
||
<span class="%s">(%.0f%%)</span><br>
|
||
<input class="approve-input" type="text" placeholder="#" onkeydown="handleKey(event, this, %d, 'spo2')">
|
||
</div>
|
||
</div>
|
||
<div style="display: flex; align-items: center; gap: 10px;">
|
||
<img id="img_%d_hr" class="crop-img" src="f%d_hr_crop.png" onclick="enlargeImage('f%d_hr_crop.png')">
|
||
<div>
|
||
<span class="ocr-value">%d</span><br>
|
||
<span class="%s">(%.0f%%)</span><br>
|
||
<input class="approve-input" type="text" placeholder="#" onkeydown="handleKey(event, this, %d, 'hr')">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`, e.FrameNum, reviewMarker, e.Timestamp,
|
||
e.FailureReason,
|
||
e.FrameNum, e.FrameNum, e.FrameNum,
|
||
e.SpO2Value, confClass(spo2Conf), spo2Conf, e.FrameNum,
|
||
e.FrameNum, e.FrameNum, e.FrameNum,
|
||
e.HRValue, confClass(hrConf), hrConf, e.FrameNum)
|
||
} 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>
|
||
<div style="display: flex; gap: 40px; justify-content: center; align-items: center;">
|
||
<div style="display: flex; align-items: center; gap: 10px;">
|
||
<img id="img_%d_spo2" class="crop-img" src="f%d_spo2_crop.png" onclick="enlargeImage('f%d_spo2_crop.png')">
|
||
<div>
|
||
<span class="ocr-value">%d</span><br>
|
||
<span class="%s">(%.0f%%)</span><br>
|
||
<input class="approve-input" type="text" placeholder="#" onkeydown="handleKey(event, this, %d, 'spo2')">
|
||
</div>
|
||
</div>
|
||
<div style="display: flex; align-items: center; gap: 10px;">
|
||
<img id="img_%d_hr" class="crop-img" src="f%d_hr_crop.png" onclick="enlargeImage('f%d_hr_crop.png')">
|
||
<div>
|
||
<span class="ocr-value">%d</span><br>
|
||
<span class="%s">(%.0f%%)</span><br>
|
||
<input class="approve-input" type="text" placeholder="#" onkeydown="handleKey(event, this, %d, 'hr')">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`, e.FrameNum, reviewMarker, unstableMarker, e.Timestamp,
|
||
e.FrameNum, e.FrameNum, e.FrameNum,
|
||
e.SpO2Value, confClass(spo2Conf), spo2Conf, e.FrameNum,
|
||
e.FrameNum, e.FrameNum, e.FrameNum,
|
||
e.HRValue, confClass(hrConf), hrConf, e.FrameNum)
|
||
}
|
||
|
||
// 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 ≥95%</span>,
|
||
<span class="conf-low">Red <95%</span>
|
||
</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()
|
||
}
|