Initial commit

This commit is contained in:
Johan 2026-02-01 02:00:42 -05:00
commit e75e142c35
1787 changed files with 3321 additions and 0 deletions

BIN
._bar_cropped.png Executable file

Binary file not shown.

BIN
._bars_visualization.png Executable file

Binary file not shown.

BIN
._debug_columns.png Executable file

Binary file not shown.

BIN
._left-11-1.png Executable file

Binary file not shown.

BIN
._right-small.png Executable file

Binary file not shown.

31
.gitignore vendored Normal file
View File

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

BIN
bar_cropped.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
bars_visualization.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
bw_image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

144
capture.go Normal file
View File

@ -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
}

65
claude.md Normal file
View File

@ -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
```

14
config.yaml Normal file
View File

@ -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

BIN
debug_columns.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
debug_cuts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
debug_cuts_left.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
debug_cuts_right.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
debug_hr_crop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B

BIN
debug_scanlines.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
debug_spo2_crop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 B

BIN
debug_test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

8
go.mod Normal file
View File

@ -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
)

8
go.sum Normal file
View File

@ -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=

75
hass.go Normal file
View File

@ -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
}

2336
main.go Normal file

File diff suppressed because it is too large Load Diff

169
patterns.csv Normal file
View File

@ -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
1 -u-/.^- o^|/ou| h47 100
2 ou-/.^- |o|/o^| h478 108
3 -oo/.oo |o|/-o| h377 128
4 ooo/.oo ou|/-o| h357 126
5 ooo/.oo oo|/-o| h3567 120
6 1 -oo/o^o o^|/ooo h2277 90
7 oo-/.o. |uo/o^| h7 114
8 ouo/.o. o^|/|o| h7 119
9 oo-/.o. oo|/o-| h57 115
10 ou-/.^- oo|/o-| h47 105
11 oo-/.oo |o|/|.| h4 121
12 oo|/.o| .u|/-^| h7 114
13 |-o/ooo ooo/ou| h2577 98
14 ooo/.o. |uo/|.| 111
15 oo-/.o. o-|/|o| h7 119
16 ou-/.o| ou|/o^| h7 114
17 ou-/.^- oo|/ou| h47 100
18 oo-/.o. oo|/oo| h677 118
19 ou-/.^- |^|/oo| h47 103
20 ooo/.oo oo|/-u| h357 120
21 |-o/oo| -oo/o^. h2 97
22 ouo/.^- oo|/|o| h47 109
23 oo|/.o| ouo/o-| h7 114
24 oo-/.o. oo|/ou| h677 118
25 ooo/.oo |u|/o^| h37 124
26 oo-/.o. o^|/oo| h7 119
27 ooo/.o. |u|/|.| 111
28 ou-/.^- |uo/oo| h477 105
29 oo-/.o. o^|/|o. 117
30 -oo/.oo oo|/-o| h3577 128
31 ouo/.^- oo|/oo| h4677 108
32 oo-/.oo |^|/ou| h3 123
33 ou-/.^- oo|/o-o h47 107
34 ouo/.o. |oo/|.| 111
35 oo-/.oo |oo/|.| h4 121
36 o-|/|o| oo|/ou| h2 93
37 oo-/.o- o^|/oo| h477 109
38 oo-/.oo o-|/oo| h36 122
39 -u-/.^- oo|/oo| h4677 108
40 oo-/.o. oo|/o^o h678 -1
41 ou-/.^- o^|/ou| h47 100
42 oo-/.oo |-|/-o| h3 122
43 -oo/.oo |-|/-o| h3 122
44 ou-/.o. o-|/oo| h578 112
45 -u-/.^- |u|/o^| h47 104
46 |^o/.oo .-|/..| h2 91
47 ou-/.o. oo|/o-| h7 113
48 -oo/.oo ou|/-o| h357 126
49 ooo/.oo oo|/-o| h357 120
50 oo-/.o. o^|/|^. 117
51 ou-/.oo |uo/o^| h7 114
52 ouo/.^o oo|/o.| h4 101
53 oo-/.oo |uo/o^| h7 114
54 |oo/ouo ooo/ou| 88
55 ou-/.o. |uo/o^| h7 114
56 o-o/|oo o-|/o-| h27 90
57 ou-/.^- |uo/o^| h47 104
58 ooo/.oo |uo/o^| h37 124
59 |-o/ooo oo./o-| h27 96
60 |-o/oo| -oo/oo. h2 97
61 ou-/.o. oo|/o-| h6 113
62 ou-/.^- |-|/|o| h4 102
63 ou-/.^- |-|/|^. h4 107
64 oo-/.o. o-|/|.| 111
65 |oo/o-| o-|/|oo h227 89
66 -o-/.oo |-|/-o| h3 122
67 -u-/.^- o-|/|o. h4 107
68 |-o/ooo oo|/o-| h27 96
69 |o|/o-o |oo/ou| h2277 88
70 oo-/.o. |u|/o^| h7 114
71 ou-/.o. |uo/oo| h677 -1
72 ooo/.oo |-|/-o| h3 122
73 ou-/.^- oo|/o-| h4 105
74 o-|/|o| |o|/ou| h2 93
75 oo-/.o. o^|/o-| h7 113
76 .oo/.oo --|/-^| h2 -1
77 oo-/.o| ou|/o-| h7 114
78 oo-/.oo |o|/--| h3 125
79 ou-/.^- oo|/|o| h47 109
80 ou-/.^- o-|/|o. h46 107
81 ou-/.^- |o|/o-| h4 103
82 oo-/.o. o-|/|-| h7 -1
83 ou-/.^- |o|/|-| h4 103
84 -oo/.oo oo|/-o| h357 120
85 -oo/.oo o-|/ooo h3 127
86 ou-/.o. o-|/oo| h6 112
87 ooo/.o. |oo/|.| 111
88 ouo/.^o ou|/o.| h4 101
89 ou-/.^- oo|/oo| h47 105
90 |-o/ooo oo|/ou| h2577 98
91 ooo/.oo oo|/-o| h3577 128
92 ooo/.o. oo|/o-| h7 116
93 oo-/.o. o^|/oo| h67 119
94 -u-/.^- |oo/oo| h47 103
95 ooo/.oo o-|/ooo h3 127
96 ouo/.^o |u|/|.| h4 101
97 .oo/.oo .-|/..| h3 91
98 oo-/.oo o^|/o-| h3 123
99 |-o/|oo ooo/ou| h2577 98
100 -u-/.^- |uo/o^| h47 104
101 ooo/.o. o^|/oo| h78 -1
102 ou-/.^- o-|/|o. h4 107
103 |-o/|o| o-|/|o| h27 99
104 oo-/.oo |o|/-u| h3 123
105 ooo/.oo o-|/oo. h3 127
106 |-o/oo| oo./ou| h2 95
107 -u-/.oo o-|/|.| h477 101
108 ou-/.o. o-|/o-| h7 110
109 ooo/.oo oo|/-o| h367 129
110 ou-/.^- ou|/ou| h47 106
111 oo-/.o| ouo/o-| h7 114
112 |-o/oo| -oo/.^. h2 97
113 |-o/oo| o-|/|o| h27 99
114 ou-/.^- o-|/|oo h4 107
115 o-|/|o| o^|/ooo h2 92
116 ooo/.o. oo|/oo| h677 118
117 oo-/.o. o-|/oo| h7 119
118 ou-/.^- |^|/ou| h4 103
119 |oo/ou| o-|/|oo h227 89
120 -u-/.^- o-|/|oo h4 107
121 ou-/.^- oo|/o-| h467 105
122 ou-/.^- oo|/oo| h477 108
123 oo-/.o. o-|/oo| h67 119
124 ooo/.oo |o|/-o| h377 128
125 ou-/.^- |-|/oo| h4 102
126 ouo/.^o |oo/|.| h4 101
127 oo-/.oo |-|/-u| h3 122
128 ooo/.o. oo|/ou| h677 118
129 oo-/.o. oo|/o-| h56 115
130 |-o/|o| -oo/.^. h2 97
131 ooo/.o- o^|/oo| h477 -1
132 -u-/.^- oo|/ou| h47 106
133 oo-/.o. oo|/o-| h7 113
134 oo-/.o. |oo/|.| 111
135 o-|/|o| o^|/ou| h2 93
136 oo-/.o. o^|/|o| h7 119
137 oo-/.oo oo|/oo| h3457 120
138 oo-/.o. oo|/o-| h6 113
139 ouo/.^o ooo/o.| h4 101
140 |oo/o-o ooo/ou| 88
141 -oo/.oo |u|/o^| h37 124
142 oo-/.o. oo|/ou| h577 118
143 ou-/.^- |^|/oo| h48 103
144 |^o/.oo .^|/..| h2 91
145 o-|/|o| o^|/oo. h2 92
146 -u-/.^- ou|/ou| h47 106
147 oo-/.o. oo|/o^| h678 -1
148 ooo/.o. oo|/o-| h57 115
149 ouo/.o| o-|/o-| h7 111
150 oo-/.o. o-|/o-| h7 110
151 oo-/.o. oo|/oo| h577 118
152 oo-/.o. o-|/oo| h6 112
153 ou-/.^- |u|/o^| h47 104
154 ou-/.^- oo|/oo| h4678 108
155 oo-/.oo o-|/o-| h7 110
156 ou-/.oo ouo/o^| h7 114
157 oo|/.o| .uo/-^| h7 114
158 |o|/ouo |oo/ou| h2277 88
159 ou-/.^- oo|/oo| h4677 108
160 oo-/.oo |^|/--| h3 123
161 o-|/oo| o^|/oo| h27 93
162 |oo/ouo o^|/o-| 88
163 ou-/.^- o^|/|o| h47 109
164 ou-/.^- o-|/|^. h4 107
165 oo-/.o. o^|/ou| h7 110
166 ou-/.^- |^|/o-| h4 103
167 ouo/.^o |o|/|.| h4 101
168 oo-/.o. o^|/o-| h6 113
169 oo-/.oo |-|/oo| h3 122

471
patterns.go Normal file
View File

@ -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
}

BIN
pulse-monitor Executable file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

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