Initial commit
|
|
@ -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
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
|
@ -0,0 +1,144 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gocv.io/x/gocv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// suppressStderr temporarily redirects stderr to /dev/null
|
||||||
|
func suppressStderr() (restore func()) {
|
||||||
|
stderr := os.Stderr
|
||||||
|
devNull, _ := os.Open(os.DevNull)
|
||||||
|
os.Stderr = devNull
|
||||||
|
// Also redirect the actual file descriptor for C code
|
||||||
|
oldFd, _ := syscall.Dup(2)
|
||||||
|
syscall.Dup2(int(devNull.Fd()), 2)
|
||||||
|
return func() {
|
||||||
|
syscall.Dup2(oldFd, 2)
|
||||||
|
syscall.Close(oldFd)
|
||||||
|
devNull.Close()
|
||||||
|
os.Stderr = stderr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture handles RTSP frame capture via GStreamer
|
||||||
|
type Capture struct {
|
||||||
|
stream *gocv.VideoCapture
|
||||||
|
url string
|
||||||
|
closed bool
|
||||||
|
closeOnce sync.Once
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCapture creates a new RTSP capture from URL
|
||||||
|
func NewCapture(rtspURL string) (*Capture, error) {
|
||||||
|
pipeline, err := buildPipeline(rtspURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
restore := suppressStderr()
|
||||||
|
stream, err := gocv.OpenVideoCapture(pipeline)
|
||||||
|
restore()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Capture{
|
||||||
|
stream: stream,
|
||||||
|
url: rtspURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPipeline creates GStreamer pipeline with credentials extracted
|
||||||
|
func buildPipeline(rtspURL string) (string, error) {
|
||||||
|
u, err := url.Parse(rtspURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var userID, userPW string
|
||||||
|
if u.User != nil {
|
||||||
|
userID = u.User.Username()
|
||||||
|
userPW, _ = u.User.Password()
|
||||||
|
}
|
||||||
|
|
||||||
|
location := fmt.Sprintf("rtsp://%s%s", u.Host, u.Path)
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"rtspsrc location=%s user-id=%s user-pw=%s latency=0 ! decodebin ! videoconvert ! appsink",
|
||||||
|
location, userID, userPW,
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextFrame returns the next frame from the stream
|
||||||
|
// Returns nil if stream is closed or error occurs
|
||||||
|
func (c *Capture) NextFrame() gocv.Mat {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.closed || c.stream == nil {
|
||||||
|
return gocv.NewMat()
|
||||||
|
}
|
||||||
|
|
||||||
|
frame := gocv.NewMat()
|
||||||
|
if ok := c.stream.Read(&frame); !ok {
|
||||||
|
frame.Close()
|
||||||
|
c.reconnect()
|
||||||
|
return gocv.NewMat()
|
||||||
|
}
|
||||||
|
|
||||||
|
if frame.Empty() {
|
||||||
|
frame.Close()
|
||||||
|
return gocv.NewMat()
|
||||||
|
}
|
||||||
|
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconnect attempts to reconnect to the stream
|
||||||
|
func (c *Capture) reconnect() {
|
||||||
|
if c.closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Reconnecting to stream...")
|
||||||
|
c.stream.Close()
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
|
||||||
|
pipeline, err := buildPipeline(c.url)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Reconnect failed: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
restore := suppressStderr()
|
||||||
|
newStream, err := gocv.OpenVideoCapture(pipeline)
|
||||||
|
restore()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Reconnect failed: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.stream = newStream
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the capture
|
||||||
|
func (c *Capture) Close() {
|
||||||
|
c.closeOnce.Do(func() {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.closed = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActive returns true if capture is still active
|
||||||
|
func (c *Capture) IsActive() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return !c.closed
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
# Pulse Monitor v2 - Project Context
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Pulse oximeter digit recognition system that reads SpO2 and HR values from a 7-segment LED display using camera capture and scan-line analysis.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- **main.go**: Core digit recognition using scan-line analysis at y=2/5 and y=4/5 heights
|
||||||
|
- **patterns.go**: Web-based training interface and pattern storage
|
||||||
|
- **hass.go**: Home Assistant integration for reporting values
|
||||||
|
|
||||||
|
## Digit Recognition Approach
|
||||||
|
|
||||||
|
### Fingerprint-Based Classification
|
||||||
|
Each digit is classified by its **fingerprint**: white pixel percentage at two scan lines, rounded to 5%.
|
||||||
|
|
||||||
|
Format: `{p2}_{p4}` where:
|
||||||
|
- `p2` = white pixels % at y = 2/5 of digit height
|
||||||
|
- `p4` = white pixels % at y = 4/5 of digit height
|
||||||
|
|
||||||
|
Example: A "9" might have fingerprint "75_90" meaning 75% white at 2/5 height, 90% white at 4/5 height.
|
||||||
|
|
||||||
|
### Learning System
|
||||||
|
1. **Pattern Store** (patterns.csv): Maps fingerprints to digit values
|
||||||
|
2. **Unlabeled Queue**: New fingerprints are saved with digit images
|
||||||
|
3. **Web GUI** (http://localhost:8090): Shows digit images for manual labeling
|
||||||
|
4. **Fallback Matcher**: Uses distance-based matching against initial guesses
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
1. Capture frame → extract digit regions
|
||||||
|
2. For each digit:
|
||||||
|
- Calculate fingerprint (p2_p4)
|
||||||
|
- Check patterns.csv for learned value
|
||||||
|
- If unknown: save image, queue for labeling, use fallback guess
|
||||||
|
3. Web GUI shows queued patterns with images
|
||||||
|
4. User labels patterns → saved to patterns.csv
|
||||||
|
5. Future recognition uses learned values
|
||||||
|
|
||||||
|
## Leading "1" Detection (3-digit numbers)
|
||||||
|
HR values 100-199 start with "1". Three strategies detect leading "1":
|
||||||
|
1. **Strategy 1**: Narrow both-white region followed by both-dark gap
|
||||||
|
2. **Strategy 2**: Narrow first run at y=58 when "1" merges with next digit at y=29
|
||||||
|
3. **Strategy 3**: Wide total content (>100px) with first y=58 run 40-65% of width (merged "1"+"0")
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
- `patterns.csv`: Learned fingerprint → digit mappings
|
||||||
|
- `digit_left_N_FP.png` / `digit_right_N_FP.png`: Saved digit images for labeling
|
||||||
|
- `debug_cuts_left.png` / `debug_cuts_right.png`: Debug visualization of digit boundaries
|
||||||
|
|
||||||
|
## Web Training Interface
|
||||||
|
- URL: http://localhost:8090
|
||||||
|
- Shows unlabeled digit images with fingerprints
|
||||||
|
- Enter digit value (0-9) and press Enter to save
|
||||||
|
- Enter "x" or "-1" to mark as ignore/invalid
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
- SpO2 recognition: Working (92 reads correctly)
|
||||||
|
- HR recognition: Needs fingerprint calibration
|
||||||
|
- Next step: Run monitor, open GUI, label patterns to build patterns.csv
|
||||||
|
|
||||||
|
## Run Commands
|
||||||
|
```bash
|
||||||
|
go build -o pulse-monitor .
|
||||||
|
./pulse-monitor
|
||||||
|
# Open http://localhost:8090 for training GUI
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Pulse Monitor v2 Configuration
|
||||||
|
|
||||||
|
camera:
|
||||||
|
rtsp_url: "rtsp://tapohass:!!Helder06@192.168.2.183:554/stream2"
|
||||||
|
|
||||||
|
home_assistant:
|
||||||
|
url: "http://192.168.1.252:8123"
|
||||||
|
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzOTMxMTA4MjczYzI0NDU1YjIzOGJlZWE0Y2NkM2I1OCIsImlhdCI6MTc2MTExNTQxNywiZXhwIjoyMDc2NDc1NDE3fQ.URFS4M0rX78rW27gQuBX-PyrPYMLlGujF16jIBHXYOw"
|
||||||
|
|
||||||
|
ocr:
|
||||||
|
api_key: "AIzaSyAsSUSCVs3SPXL7ugsbXa-chzcOKKJJrbA"
|
||||||
|
|
||||||
|
processing:
|
||||||
|
interval_seconds: 2
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 407 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 484 B |
|
After Width: | Height: | Size: 2.9 KiB |
|
|
@ -0,0 +1,8 @@
|
||||||
|
module pulse-monitor-v2
|
||||||
|
|
||||||
|
go 1.24.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
gocv.io/x/gocv v0.31.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
github.com/hybridgroup/mjpeg v0.0.0-20140228234708-4680f319790e/go.mod h1:eagM805MRKrioHYuU7iKLUyFPVKqVV6um5DAvCkUtXs=
|
||||||
|
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||||
|
gocv.io/x/gocv v0.31.0 h1:BHDtK8v+YPvoSPQTTiZB2fM/7BLg6511JqkruY2z6LQ=
|
||||||
|
gocv.io/x/gocv v0.31.0/go.mod h1:oc6FvfYqfBp99p+yOEzs9tbYF9gOrAQSeL/dyIPefJU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
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,75 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HASS handles Home Assistant communication
|
||||||
|
type HASS struct {
|
||||||
|
url string
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHASS creates a new Home Assistant client
|
||||||
|
func NewHASS(url, token string) *HASS {
|
||||||
|
return &HASS{
|
||||||
|
url: url,
|
||||||
|
token: token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostSpO2 posts SpO2 value to Home Assistant
|
||||||
|
func (h *HASS) PostSpO2(value int) error {
|
||||||
|
return h.post("sensor.pulse_spo2", value, "%", "Pulse Oximeter SpO2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostHR posts heart rate value to Home Assistant
|
||||||
|
func (h *HASS) PostHR(value int) error {
|
||||||
|
return h.post("sensor.pulse_hr", value, "bpm", "Pulse Oximeter Heart Rate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// post sends a value to Home Assistant
|
||||||
|
func (h *HASS) post(entityID string, value int, unit, friendlyName string) error {
|
||||||
|
url := fmt.Sprintf("%s/api/states/%s", h.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 err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+h.token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 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,169 @@
|
||||||
|
-u-/.^- o^|/ou| h47,100
|
||||||
|
ou-/.^- |o|/o^| h478,108
|
||||||
|
-oo/.oo |o|/-o| h377,128
|
||||||
|
ooo/.oo ou|/-o| h357,126
|
||||||
|
ooo/.oo oo|/-o| h3567,120
|
||||||
|
1 -oo/o^o o^|/ooo h2277,90
|
||||||
|
oo-/.o. |uo/o^| h7,114
|
||||||
|
ouo/.o. o^|/|o| h7,119
|
||||||
|
oo-/.o. oo|/o-| h57,115
|
||||||
|
ou-/.^- oo|/o-| h47,105
|
||||||
|
oo-/.oo |o|/|.| h4,121
|
||||||
|
oo|/.o| .u|/-^| h7,114
|
||||||
|
|-o/ooo ooo/ou| h2577,98
|
||||||
|
ooo/.o. |uo/|.|,111
|
||||||
|
oo-/.o. o-|/|o| h7,119
|
||||||
|
ou-/.o| ou|/o^| h7,114
|
||||||
|
ou-/.^- oo|/ou| h47,100
|
||||||
|
oo-/.o. oo|/oo| h677,118
|
||||||
|
ou-/.^- |^|/oo| h47,103
|
||||||
|
ooo/.oo oo|/-u| h357,120
|
||||||
|
|-o/oo| -oo/o^. h2,97
|
||||||
|
ouo/.^- oo|/|o| h47,109
|
||||||
|
oo|/.o| ouo/o-| h7,114
|
||||||
|
oo-/.o. oo|/ou| h677,118
|
||||||
|
ooo/.oo |u|/o^| h37,124
|
||||||
|
oo-/.o. o^|/oo| h7,119
|
||||||
|
ooo/.o. |u|/|.|,111
|
||||||
|
ou-/.^- |uo/oo| h477,105
|
||||||
|
oo-/.o. o^|/|o.,117
|
||||||
|
-oo/.oo oo|/-o| h3577,128
|
||||||
|
ouo/.^- oo|/oo| h4677,108
|
||||||
|
oo-/.oo |^|/ou| h3,123
|
||||||
|
ou-/.^- oo|/o-o h47,107
|
||||||
|
ouo/.o. |oo/|.|,111
|
||||||
|
oo-/.oo |oo/|.| h4,121
|
||||||
|
o-|/|o| oo|/ou| h2,93
|
||||||
|
oo-/.o- o^|/oo| h477,109
|
||||||
|
oo-/.oo o-|/oo| h36,122
|
||||||
|
-u-/.^- oo|/oo| h4677,108
|
||||||
|
oo-/.o. oo|/o^o h678,-1
|
||||||
|
ou-/.^- o^|/ou| h47,100
|
||||||
|
oo-/.oo |-|/-o| h3,122
|
||||||
|
-oo/.oo |-|/-o| h3,122
|
||||||
|
ou-/.o. o-|/oo| h578,112
|
||||||
|
-u-/.^- |u|/o^| h47,104
|
||||||
|
|^o/.oo .-|/..| h2,91
|
||||||
|
ou-/.o. oo|/o-| h7,113
|
||||||
|
-oo/.oo ou|/-o| h357,126
|
||||||
|
ooo/.oo oo|/-o| h357,120
|
||||||
|
oo-/.o. o^|/|^.,117
|
||||||
|
ou-/.oo |uo/o^| h7,114
|
||||||
|
ouo/.^o oo|/o.| h4,101
|
||||||
|
oo-/.oo |uo/o^| h7,114
|
||||||
|
|oo/ouo ooo/ou|,88
|
||||||
|
ou-/.o. |uo/o^| h7,114
|
||||||
|
o-o/|oo o-|/o-| h27,90
|
||||||
|
ou-/.^- |uo/o^| h47,104
|
||||||
|
ooo/.oo |uo/o^| h37,124
|
||||||
|
|-o/ooo oo./o-| h27,96
|
||||||
|
|-o/oo| -oo/oo. h2,97
|
||||||
|
ou-/.o. oo|/o-| h6,113
|
||||||
|
ou-/.^- |-|/|o| h4,102
|
||||||
|
ou-/.^- |-|/|^. h4,107
|
||||||
|
oo-/.o. o-|/|.|,111
|
||||||
|
|oo/o-| o-|/|oo h227,89
|
||||||
|
-o-/.oo |-|/-o| h3,122
|
||||||
|
-u-/.^- o-|/|o. h4,107
|
||||||
|
|-o/ooo oo|/o-| h27,96
|
||||||
|
|o|/o-o |oo/ou| h2277,88
|
||||||
|
oo-/.o. |u|/o^| h7,114
|
||||||
|
ou-/.o. |uo/oo| h677,-1
|
||||||
|
ooo/.oo |-|/-o| h3,122
|
||||||
|
ou-/.^- oo|/o-| h4,105
|
||||||
|
o-|/|o| |o|/ou| h2,93
|
||||||
|
oo-/.o. o^|/o-| h7,113
|
||||||
|
.oo/.oo --|/-^| h2,-1
|
||||||
|
oo-/.o| ou|/o-| h7,114
|
||||||
|
oo-/.oo |o|/--| h3,125
|
||||||
|
ou-/.^- oo|/|o| h47,109
|
||||||
|
ou-/.^- o-|/|o. h46,107
|
||||||
|
ou-/.^- |o|/o-| h4,103
|
||||||
|
oo-/.o. o-|/|-| h7,-1
|
||||||
|
ou-/.^- |o|/|-| h4,103
|
||||||
|
-oo/.oo oo|/-o| h357,120
|
||||||
|
-oo/.oo o-|/ooo h3,127
|
||||||
|
ou-/.o. o-|/oo| h6,112
|
||||||
|
ooo/.o. |oo/|.|,111
|
||||||
|
ouo/.^o ou|/o.| h4,101
|
||||||
|
ou-/.^- oo|/oo| h47,105
|
||||||
|
|-o/ooo oo|/ou| h2577,98
|
||||||
|
ooo/.oo oo|/-o| h3577,128
|
||||||
|
ooo/.o. oo|/o-| h7,116
|
||||||
|
oo-/.o. o^|/oo| h67,119
|
||||||
|
-u-/.^- |oo/oo| h47,103
|
||||||
|
ooo/.oo o-|/ooo h3,127
|
||||||
|
ouo/.^o |u|/|.| h4,101
|
||||||
|
.oo/.oo .-|/..| h3,91
|
||||||
|
oo-/.oo o^|/o-| h3,123
|
||||||
|
|-o/|oo ooo/ou| h2577,98
|
||||||
|
-u-/.^- |uo/o^| h47,104
|
||||||
|
ooo/.o. o^|/oo| h78,-1
|
||||||
|
ou-/.^- o-|/|o. h4,107
|
||||||
|
|-o/|o| o-|/|o| h27,99
|
||||||
|
oo-/.oo |o|/-u| h3,123
|
||||||
|
ooo/.oo o-|/oo. h3,127
|
||||||
|
|-o/oo| oo./ou| h2,95
|
||||||
|
-u-/.oo o-|/|.| h477,101
|
||||||
|
ou-/.o. o-|/o-| h7,110
|
||||||
|
ooo/.oo oo|/-o| h367,129
|
||||||
|
ou-/.^- ou|/ou| h47,106
|
||||||
|
oo-/.o| ouo/o-| h7,114
|
||||||
|
|-o/oo| -oo/.^. h2,97
|
||||||
|
|-o/oo| o-|/|o| h27,99
|
||||||
|
ou-/.^- o-|/|oo h4,107
|
||||||
|
o-|/|o| o^|/ooo h2,92
|
||||||
|
ooo/.o. oo|/oo| h677,118
|
||||||
|
oo-/.o. o-|/oo| h7,119
|
||||||
|
ou-/.^- |^|/ou| h4,103
|
||||||
|
|oo/ou| o-|/|oo h227,89
|
||||||
|
-u-/.^- o-|/|oo h4,107
|
||||||
|
ou-/.^- oo|/o-| h467,105
|
||||||
|
ou-/.^- oo|/oo| h477,108
|
||||||
|
oo-/.o. o-|/oo| h67,119
|
||||||
|
ooo/.oo |o|/-o| h377,128
|
||||||
|
ou-/.^- |-|/oo| h4,102
|
||||||
|
ouo/.^o |oo/|.| h4,101
|
||||||
|
oo-/.oo |-|/-u| h3,122
|
||||||
|
ooo/.o. oo|/ou| h677,118
|
||||||
|
oo-/.o. oo|/o-| h56,115
|
||||||
|
|-o/|o| -oo/.^. h2,97
|
||||||
|
ooo/.o- o^|/oo| h477,-1
|
||||||
|
-u-/.^- oo|/ou| h47,106
|
||||||
|
oo-/.o. oo|/o-| h7,113
|
||||||
|
oo-/.o. |oo/|.|,111
|
||||||
|
o-|/|o| o^|/ou| h2,93
|
||||||
|
oo-/.o. o^|/|o| h7,119
|
||||||
|
oo-/.oo oo|/oo| h3457,120
|
||||||
|
oo-/.o. oo|/o-| h6,113
|
||||||
|
ouo/.^o ooo/o.| h4,101
|
||||||
|
|oo/o-o ooo/ou|,88
|
||||||
|
-oo/.oo |u|/o^| h37,124
|
||||||
|
oo-/.o. oo|/ou| h577,118
|
||||||
|
ou-/.^- |^|/oo| h48,103
|
||||||
|
|^o/.oo .^|/..| h2,91
|
||||||
|
o-|/|o| o^|/oo. h2,92
|
||||||
|
-u-/.^- ou|/ou| h47,106
|
||||||
|
oo-/.o. oo|/o^| h678,-1
|
||||||
|
ooo/.o. oo|/o-| h57,115
|
||||||
|
ouo/.o| o-|/o-| h7,111
|
||||||
|
oo-/.o. o-|/o-| h7,110
|
||||||
|
oo-/.o. oo|/oo| h577,118
|
||||||
|
oo-/.o. o-|/oo| h6,112
|
||||||
|
ou-/.^- |u|/o^| h47,104
|
||||||
|
ou-/.^- oo|/oo| h4678,108
|
||||||
|
oo-/.oo o-|/o-| h7,110
|
||||||
|
ou-/.oo ouo/o^| h7,114
|
||||||
|
oo|/.o| .uo/-^| h7,114
|
||||||
|
|o|/ouo |oo/ou| h2277,88
|
||||||
|
ou-/.^- oo|/oo| h4677,108
|
||||||
|
oo-/.oo |^|/--| h3,123
|
||||||
|
o-|/oo| o^|/oo| h27,93
|
||||||
|
|oo/ouo o^|/o-|,88
|
||||||
|
ou-/.^- o^|/|o| h47,109
|
||||||
|
ou-/.^- o-|/|^. h4,107
|
||||||
|
oo-/.o. o^|/ou| h7,110
|
||||||
|
ou-/.^- |^|/o-| h4,103
|
||||||
|
ouo/.^o |o|/|.| h4,101
|
||||||
|
oo-/.o. o^|/o-| h6,113
|
||||||
|
oo-/.oo |-|/oo| h3,122
|
||||||
|
|
|
@ -0,0 +1,471 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PatternStore holds patterns and their values
|
||||||
|
type PatternStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
Patterns map[string]int // fingerprint -> value
|
||||||
|
file string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlabeled holds patterns waiting for manual labeling
|
||||||
|
type Unlabeled struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
ImagePath string `json:"imagePath"`
|
||||||
|
Side string `json:"side"` // "left" or "right"
|
||||||
|
}
|
||||||
|
|
||||||
|
var unlabeled []Unlabeled
|
||||||
|
var unlabeledMu sync.Mutex
|
||||||
|
var unlabeledID int
|
||||||
|
var patternStore *PatternStore
|
||||||
|
|
||||||
|
// NewPatternStore loads patterns from CSV file (fingerprint,value)
|
||||||
|
func NewPatternStore(file string) (*PatternStore, error) {
|
||||||
|
ps := &PatternStore{
|
||||||
|
Patterns: make(map[string]int),
|
||||||
|
file: file,
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(file)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
patternStore = ps
|
||||||
|
return ps, nil // Empty store
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := strings.LastIndex(line, ",")
|
||||||
|
if idx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fingerprint := line[:idx]
|
||||||
|
value, err := strconv.Atoi(line[idx+1:])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ps.Patterns[fingerprint] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
patternStore = ps
|
||||||
|
return ps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup returns the value for a fingerprint
|
||||||
|
func (ps *PatternStore) Lookup(fingerprint string) (int, bool) {
|
||||||
|
ps.mu.RLock()
|
||||||
|
defer ps.mu.RUnlock()
|
||||||
|
val, ok := ps.Patterns[fingerprint]
|
||||||
|
return val, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store saves a pattern -> value mapping
|
||||||
|
func (ps *PatternStore) Store(fingerprint string, value int) error {
|
||||||
|
ps.mu.Lock()
|
||||||
|
ps.Patterns[fingerprint] = value
|
||||||
|
count := len(ps.Patterns)
|
||||||
|
ps.mu.Unlock()
|
||||||
|
fmt.Printf("Stored pattern (total: %d)\n", count)
|
||||||
|
return ps.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes patterns to CSV file (fingerprint,value)
|
||||||
|
func (ps *PatternStore) Save() error {
|
||||||
|
ps.mu.RLock()
|
||||||
|
defer ps.mu.RUnlock()
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
for fp, val := range ps.Patterns {
|
||||||
|
lines = append(lines, fmt.Sprintf("%s,%d", fp, val))
|
||||||
|
}
|
||||||
|
return os.WriteFile(ps.file, []byte(strings.Join(lines, "\n")+"\n"), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns number of patterns
|
||||||
|
func (ps *PatternStore) Count() int {
|
||||||
|
ps.mu.RLock()
|
||||||
|
defer ps.mu.RUnlock()
|
||||||
|
return len(ps.Patterns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddUnlabeled adds a pattern for manual labeling, returns true if added (not duplicate)
|
||||||
|
func AddUnlabeled(fingerprint, imagePath, side string) bool {
|
||||||
|
unlabeledMu.Lock()
|
||||||
|
defer unlabeledMu.Unlock()
|
||||||
|
|
||||||
|
// Check if already in list
|
||||||
|
for _, u := range unlabeled {
|
||||||
|
if u.Fingerprint == fingerprint {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unlabeledID++
|
||||||
|
unlabeled = append(unlabeled, Unlabeled{
|
||||||
|
ID: unlabeledID,
|
||||||
|
Fingerprint: fingerprint,
|
||||||
|
ImagePath: imagePath,
|
||||||
|
Side: side,
|
||||||
|
})
|
||||||
|
fmt.Printf("New unlabeled %s: %s (queue: %d)\n", side, fingerprint, len(unlabeled))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveUnlabeled removes a pattern from the unlabeled list
|
||||||
|
func RemoveUnlabeled(fingerprint string) {
|
||||||
|
unlabeledMu.Lock()
|
||||||
|
defer unlabeledMu.Unlock()
|
||||||
|
|
||||||
|
for i, u := range unlabeled {
|
||||||
|
if u.Fingerprint == fingerprint {
|
||||||
|
unlabeled = append(unlabeled[:i], unlabeled[i+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartWebServer starts the training web interface
|
||||||
|
func StartWebServer(port int) {
|
||||||
|
http.HandleFunc("/", handleIndex)
|
||||||
|
http.HandleFunc("/api/unlabeled", handleUnlabeled)
|
||||||
|
http.HandleFunc("/api/label", handleLabel)
|
||||||
|
http.HandleFunc("/api/stats", handleStats)
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%d", port)
|
||||||
|
fmt.Printf("Training interface at http://localhost%s\n", addr)
|
||||||
|
go http.ListenAndServe(addr, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write([]byte(indexHTML))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUnlabeled(w http.ResponseWriter, r *http.Request) {
|
||||||
|
unlabeledMu.Lock()
|
||||||
|
defer unlabeledMu.Unlock()
|
||||||
|
|
||||||
|
// Build response with embedded images
|
||||||
|
type UnlabeledResponse struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
ImageData string `json:"imageData"`
|
||||||
|
Side string `json:"side"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp []UnlabeledResponse
|
||||||
|
for _, u := range unlabeled {
|
||||||
|
imgData, err := os.ReadFile(u.ImagePath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp = append(resp, UnlabeledResponse{
|
||||||
|
ID: u.ID,
|
||||||
|
Fingerprint: u.Fingerprint,
|
||||||
|
ImageData: base64.StdEncoding.EncodeToString(imgData),
|
||||||
|
Side: u.Side,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLabel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fingerprint := r.FormValue("fingerprint")
|
||||||
|
valueStr := r.FormValue("value")
|
||||||
|
|
||||||
|
if fingerprint == "" || valueStr == "" {
|
||||||
|
http.Error(w, "Missing fingerprint or value", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// "x" or "-1" means ignore this pattern
|
||||||
|
var value int
|
||||||
|
if valueStr == "x" || valueStr == "X" || valueStr == "-1" {
|
||||||
|
value = -1
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
value, err = strconv.Atoi(valueStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid value", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store pattern
|
||||||
|
if patternStore != nil {
|
||||||
|
if err := patternStore.Store(fingerprint, value); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("Learned: %s = %d\n", fingerprint, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from unlabeled
|
||||||
|
RemoveUnlabeled(fingerprint)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
unlabeledMu.Lock()
|
||||||
|
unlabeledCount := len(unlabeled)
|
||||||
|
unlabeledMu.Unlock()
|
||||||
|
|
||||||
|
patternCount := 0
|
||||||
|
if patternStore != nil {
|
||||||
|
patternCount = patternStore.Count()
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]int{
|
||||||
|
"patterns": patternCount,
|
||||||
|
"unlabeled": unlabeledCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexHTML = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Pulse Monitor Training</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #eee;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
h1 { color: #0f0; margin: 0 0 10px 0; }
|
||||||
|
.stats {
|
||||||
|
background: #16213e;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
.stat { text-align: center; }
|
||||||
|
.stat-value { font-size: 32px; font-weight: bold; color: #0f0; }
|
||||||
|
.stat-label { font-size: 12px; color: #888; }
|
||||||
|
.patterns {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
.pattern {
|
||||||
|
background: #16213e;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.pattern.left { border-color: #4a9; }
|
||||||
|
.pattern.right { border-color: #94a; }
|
||||||
|
.pattern img {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 10px;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
.pattern input {
|
||||||
|
width: 80px;
|
||||||
|
font-size: 20px;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #0f3460;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.pattern input:focus {
|
||||||
|
outline: 2px solid #0f0;
|
||||||
|
}
|
||||||
|
.fingerprint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
word-break: break-all;
|
||||||
|
max-width: 250px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.side-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.side-label.left { color: #4a9; }
|
||||||
|
.side-label.right { color: #94a; }
|
||||||
|
.empty {
|
||||||
|
color: #666;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.saved {
|
||||||
|
animation: flash 0.5s;
|
||||||
|
}
|
||||||
|
@keyframes flash {
|
||||||
|
0% { background: #0f0; }
|
||||||
|
100% { background: #16213e; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Pulse Monitor Training</h1>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value" id="patternCount">-</div>
|
||||||
|
<div class="stat-label">Learned Patterns</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value" id="unlabeledCount">-</div>
|
||||||
|
<div class="stat-label">Waiting for Label</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="patterns" id="patterns">
|
||||||
|
<div class="empty">Waiting for patterns...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let knownPatterns = new Set();
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
// Update stats
|
||||||
|
const stats = await fetch('/api/stats').then(r => r.json());
|
||||||
|
document.getElementById('patternCount').textContent = stats.patterns;
|
||||||
|
document.getElementById('unlabeledCount').textContent = stats.unlabeled;
|
||||||
|
|
||||||
|
// Get unlabeled patterns
|
||||||
|
const unlabeled = await fetch('/api/unlabeled').then(r => r.json());
|
||||||
|
const container = document.getElementById('patterns');
|
||||||
|
|
||||||
|
if (!unlabeled || unlabeled.length === 0) {
|
||||||
|
if (container.querySelector('.pattern')) {
|
||||||
|
container.innerHTML = '<div class="empty">All patterns labeled! Waiting for new ones...</div>';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new patterns
|
||||||
|
unlabeled.forEach(p => {
|
||||||
|
if (knownPatterns.has(p.fingerprint)) return;
|
||||||
|
knownPatterns.add(p.fingerprint);
|
||||||
|
|
||||||
|
// Remove empty message if present
|
||||||
|
const empty = container.querySelector('.empty');
|
||||||
|
if (empty) empty.remove();
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'pattern ' + p.side;
|
||||||
|
div.id = 'p-' + p.id;
|
||||||
|
div.innerHTML = ` + "`" + `
|
||||||
|
<div class="side-label ${p.side}">${p.side.toUpperCase()}</div>
|
||||||
|
<img src="data:image/png;base64,${p.imageData}">
|
||||||
|
<input type="text" placeholder="value" data-fp="${p.fingerprint}">
|
||||||
|
<div class="fingerprint">${p.fingerprint}</div>
|
||||||
|
` + "`" + `;
|
||||||
|
container.appendChild(div);
|
||||||
|
div.querySelector('input').addEventListener('keydown', handleKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove patterns that are no longer unlabeled
|
||||||
|
const currentFingerprints = new Set(unlabeled.map(p => p.fingerprint));
|
||||||
|
container.querySelectorAll('.pattern').forEach(div => {
|
||||||
|
const fp = div.querySelector('.fingerprint').textContent;
|
||||||
|
if (!currentFingerprints.has(fp)) {
|
||||||
|
div.classList.add('saved');
|
||||||
|
setTimeout(() => div.remove(), 500);
|
||||||
|
knownPatterns.delete(fp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus first input only if no input currently has focus
|
||||||
|
const activeEl = document.activeElement;
|
||||||
|
const hasInputFocus = activeEl && activeEl.tagName === 'INPUT' && activeEl.closest('.pattern');
|
||||||
|
if (!hasInputFocus) {
|
||||||
|
const firstInput = container.querySelector('.pattern input:not(:disabled)');
|
||||||
|
if (firstInput) firstInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleKey(e) {
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const input = e.target;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
const fingerprint = input.dataset.fp;
|
||||||
|
const card = input.closest('.pattern');
|
||||||
|
|
||||||
|
// Immediately find and focus next input BEFORE any async work
|
||||||
|
const allInputs = Array.from(document.querySelectorAll('.pattern input'));
|
||||||
|
const idx = allInputs.indexOf(input);
|
||||||
|
const nextInput = allInputs[idx + 1] || allInputs[0];
|
||||||
|
if (nextInput && nextInput !== input) {
|
||||||
|
nextInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable this input and mark card as saving
|
||||||
|
input.disabled = true;
|
||||||
|
card.style.opacity = '0.5';
|
||||||
|
|
||||||
|
// Save to server
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('fingerprint', fingerprint);
|
||||||
|
formData.append('value', value);
|
||||||
|
await fetch('/api/label', { method: 'POST', body: formData });
|
||||||
|
|
||||||
|
// Remove card
|
||||||
|
knownPatterns.delete(fingerprint);
|
||||||
|
card.classList.add('saved');
|
||||||
|
setTimeout(() => card.remove(), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for updates
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 1000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
// GenerateHTML creates training.html with unlabeled patterns (legacy)
|
||||||
|
func GenerateHTML(dir string) error {
|
||||||
|
// No longer needed - web server handles this
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |