feat(v2): add annotated evidence and alert dispatch
This commit is contained in:
@@ -57,6 +57,7 @@ V2 对应模块的当前落实与 T-304 目标如下:
|
|||||||
| 实时视频源 | `v2/internal/source` | 用 FFprobe 获取尺寸/FPS,以 FFmpeg 输出 BGR 帧;有界重连、取消、来源状态和单槽最新帧投递 | ONNX、事件判定、UI 绘制;错误状态不得含 URL |
|
| 实时视频源 | `v2/internal/source` | 用 FFprobe 获取尺寸/FPS,以 FFmpeg 输出 BGR 帧;有界重连、取消、来源状态和单槽最新帧投递 | ONNX、事件判定、UI 绘制;错误状态不得含 URL |
|
||||||
| ONNX Pose | `v2/internal/pose` | 114 letterbox、RGB/CHW、NMS、关键点及坐标还原 | 人员 ID、摔倒结论 |
|
| ONNX Pose | `v2/internal/pose` | 114 letterbox、RGB/CHW、NMS、关键点及坐标还原 | 人员 ID、摔倒结论 |
|
||||||
| 事件引擎 | `v2/internal/fall` | 复现 V1 的跟踪、证据、四态状态机和 `FallEvent` | 视频解码、声音、文件 |
|
| 事件引擎 | `v2/internal/fall` | 复现 V1 的跟踪、证据、四态状态机和 `FallEvent` | 视频解码、声音、文件 |
|
||||||
|
| 画面与证据 | `v2/internal/render`、`v2/internal/alert` | 叠加人员骨架/状态,红色仅表示 CONFIRMED;按事件 ID 保存一次 PNG 和 JSONL | 读取 RTSP、重跑事件规则、直接操作 Walk 控件 |
|
||||||
| Windows UI | `v2/internal/ui` | Walk 顶部“监控/设置”Tab、渲染最新帧和已计算状态 | 直接读 RTSP、执行 ONNX 或事件规则 |
|
| Windows UI | `v2/internal/ui` | Walk 顶部“监控/设置”Tab、渲染最新帧和已计算状态 | 直接读 RTSP、执行 ONNX 或事件规则 |
|
||||||
| 应用装配 | `v2/cmd/silver-pose`(T-304) | 管理 worker 生命周期、取消、最新帧投递和依赖注入 | 重写领域规则 |
|
| 应用装配 | `v2/cmd/silver-pose`(T-304) | 管理 worker 生命周期、取消、最新帧投递和依赖注入 | 重写领域规则 |
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ FallEvent = {
|
|||||||
|
|
||||||
`event_id` 带每次监控运行的会话前缀(`FALL-<session>-NNNNNN`),跨监控重启全局唯一;因此同一天目录内的截图不会被覆盖,`events.jsonl` 也不会出现同 id 不同内容的记录。
|
`event_id` 带每次监控运行的会话前缀(`FALL-<session>-NNNNNN`),跨监控重启全局唯一;因此同一天目录内的截图不会被覆盖,`events.jsonl` 也不会出现同 id 不同内容的记录。
|
||||||
|
|
||||||
|
### V2 本地事件证据
|
||||||
|
|
||||||
|
V2 的 `internal/render` 把 BGR 视频帧与已经完成的 `FrameResult` 合成为 RGBA 图像。它画 person box、17 点骨架、ID、状态和 UTC 时间;`NORMAL` 为绿色、`SUSPECT`/`RECOVERING` 为琥珀色,只有任一人员为 `CONFIRMED` 时才绘制红色骨架和六像素红色画面边框。
|
||||||
|
|
||||||
|
`internal/alert.Dispatcher` 只接收 `CONFIRMED` 的 `FallEvent` 和已叠加的图像。它在写文件前用 `event_id` 去重,写入 `artifacts.event_dir/YYYYMMDD/FALL-*.png` 与同目录 `events.jsonl`;失败会释放该 ID 使后续帧可以重试。JSONL 的字段与下文 `AlertRecord` 一致,且模块从不接收、保存或输出 RTSP URL。
|
||||||
|
|
||||||
T-202 的 `alerts` 按 `event_id` 去重,对首次 CONFIRMED 只触发一次副作用(声音、弹窗、截图、JSONL 一行)。JSONL 记录如下,文件名与字段不含 RTSP 地址、凭证或客户姓名:
|
T-202 的 `alerts` 按 `event_id` 去重,对首次 CONFIRMED 只触发一次副作用(声音、弹窗、截图、JSONL 一行)。JSONL 记录如下,文件名与字段不含 RTSP 地址、凭证或客户姓名:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ git commit -m "feat(v2): add reconnecting FFmpeg stream source"
|
|||||||
- Create: `v2/internal/alert/dispatcher_test.go`
|
- Create: `v2/internal/alert/dispatcher_test.go`
|
||||||
- Modify: `docs/api.md`
|
- Modify: `docs/api.md`
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing rendering and alert tests**
|
- [x] **Step 1: Write failing rendering and alert tests**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func TestRenderUsesRedBorderOnlyForConfirmed(t *testing.T) {
|
func TestRenderUsesRedBorderOnlyForConfirmed(t *testing.T) {
|
||||||
@@ -174,12 +174,12 @@ func TestDispatchWritesOnePNGAndRedactedJSONL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Verify tests fail**
|
- [x] **Step 2: Verify tests fail**
|
||||||
|
|
||||||
Run: `Set-Location v2; go test ./internal/render ./internal/alert -v`
|
Run: `Set-Location v2; go test ./internal/render ./internal/alert -v`
|
||||||
Expected: FAIL because both packages are absent.
|
Expected: FAIL because both packages are absent.
|
||||||
|
|
||||||
- [ ] **Step 3: Implement deterministic evidence outputs**
|
- [x] **Step 3: Implement deterministic evidence outputs**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func Render(bgr []byte, width, height int, result fall.FrameResult) *image.RGBA
|
func Render(bgr []byte, width, height int, result fall.FrameResult) *image.RGBA
|
||||||
@@ -189,12 +189,12 @@ func (d *Dispatcher) Dispatch(event fall.Event, image *image.RGBA) (Record, erro
|
|||||||
|
|
||||||
Draw a 17-point COCO skeleton, box and ASCII `track/state/time` label in the image. Normal/SUSPECT/RETRYING use green/amber/slate respectively; only `CONFIRMED` draws critical red skeleton and a six-pixel image border. Create `YYYYMMDD/FALL-*.png` and append one UTF-8 JSONL line with the V1-compatible public event fields plus source ID, UTC time and relative screenshot path. The dispatcher holds a seen-event-ID set before writing so retried UI messages cannot overwrite an image or replay an alert. Its notifier interface only reports a one-shot notification request; it does not own UI controls.
|
Draw a 17-point COCO skeleton, box and ASCII `track/state/time` label in the image. Normal/SUSPECT/RETRYING use green/amber/slate respectively; only `CONFIRMED` draws critical red skeleton and a six-pixel image border. Create `YYYYMMDD/FALL-*.png` and append one UTF-8 JSONL line with the V1-compatible public event fields plus source ID, UTC time and relative screenshot path. The dispatcher holds a seen-event-ID set before writing so retried UI messages cannot overwrite an image or replay an alert. Its notifier interface only reports a one-shot notification request; it does not own UI controls.
|
||||||
|
|
||||||
- [ ] **Step 4: Run focused tests**
|
- [x] **Step 4: Run focused tests**
|
||||||
|
|
||||||
Run: `Set-Location v2; go test ./internal/render ./internal/alert -v`
|
Run: `Set-Location v2; go test ./internal/render ./internal/alert -v`
|
||||||
Expected: PASS, including red-only-confirmed, PNG decodability, one JSONL line and no `rtsp`/credential substring.
|
Expected: PASS, including red-only-confirmed, PNG decodability, one JSONL line and no `rtsp`/credential substring.
|
||||||
|
|
||||||
- [ ] **Step 5: Commit the rendering and alert slice**
|
- [x] **Step 5: Commit the rendering and alert slice**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git add v2/internal/render v2/internal/alert docs/api.md
|
git add v2/internal/render v2/internal/alert docs/api.md
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ go 1.24.0
|
|||||||
require (
|
require (
|
||||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794
|
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794
|
||||||
github.com/yalue/onnxruntime_go v1.31.0
|
github.com/yalue/onnxruntime_go v1.31.0
|
||||||
|
golang.org/x/image v0.24.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+
|
|||||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||||
github.com/yalue/onnxruntime_go v1.31.0 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV8HdtFA=
|
github.com/yalue/onnxruntime_go v1.31.0 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV8HdtFA=
|
||||||
github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
||||||
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Package alert owns durable, one-shot fall-event evidence.
|
||||||
|
package alert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"silverpose/v2/internal/fall"
|
||||||
|
)
|
||||||
|
|
||||||
|
var eventIDPattern = regexp.MustCompile(`^[A-Za-z0-9-]+$`)
|
||||||
|
|
||||||
|
type Record struct {
|
||||||
|
Event fall.Event
|
||||||
|
ScreenshotPath string
|
||||||
|
LogPath string
|
||||||
|
CreatedAtUTC time.Time
|
||||||
|
Written bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatcher de-duplicates events before writing to avoid screenshot overwrite,
|
||||||
|
// duplicate JSONL records, duplicate sound, or duplicate popups downstream.
|
||||||
|
type Dispatcher struct {
|
||||||
|
eventDirectory string
|
||||||
|
sourceID string
|
||||||
|
mu sync.Mutex
|
||||||
|
seen map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDispatcher(eventDirectory, sourceID string) *Dispatcher {
|
||||||
|
return &Dispatcher{
|
||||||
|
eventDirectory: eventDirectory,
|
||||||
|
sourceID: sourceID,
|
||||||
|
seen: make(map[string]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dispatcher *Dispatcher) Dispatch(event fall.Event, canvas image.Image, now time.Time) (Record, error) {
|
||||||
|
if event.State != fall.Confirmed {
|
||||||
|
return Record{}, fmt.Errorf("only confirmed events can be persisted")
|
||||||
|
}
|
||||||
|
if !eventIDPattern.MatchString(event.EventID) {
|
||||||
|
return Record{}, fmt.Errorf("event ID is invalid")
|
||||||
|
}
|
||||||
|
if canvas == nil {
|
||||||
|
return Record{}, fmt.Errorf("event screenshot is missing")
|
||||||
|
}
|
||||||
|
if now.IsZero() {
|
||||||
|
now = time.Now()
|
||||||
|
}
|
||||||
|
now = now.UTC()
|
||||||
|
|
||||||
|
dispatcher.mu.Lock()
|
||||||
|
if _, alreadyWritten := dispatcher.seen[event.EventID]; alreadyWritten {
|
||||||
|
dispatcher.mu.Unlock()
|
||||||
|
return Record{Event: event, Written: false}, nil
|
||||||
|
}
|
||||||
|
dispatcher.seen[event.EventID] = struct{}{}
|
||||||
|
dispatcher.mu.Unlock()
|
||||||
|
|
||||||
|
record, err := dispatcher.write(event, canvas, now)
|
||||||
|
if err != nil {
|
||||||
|
dispatcher.mu.Lock()
|
||||||
|
delete(dispatcher.seen, event.EventID)
|
||||||
|
dispatcher.mu.Unlock()
|
||||||
|
return Record{}, err
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dispatcher *Dispatcher) write(event fall.Event, canvas image.Image, now time.Time) (Record, error) {
|
||||||
|
if dispatcher.eventDirectory == "" || dispatcher.sourceID == "" {
|
||||||
|
return Record{}, fmt.Errorf("event directory and source ID must be configured")
|
||||||
|
}
|
||||||
|
dayDirectory := filepath.Join(dispatcher.eventDirectory, now.Format("20060102"))
|
||||||
|
if err := os.MkdirAll(dayDirectory, 0o755); err != nil {
|
||||||
|
return Record{}, fmt.Errorf("create event directory: %w", err)
|
||||||
|
}
|
||||||
|
screenshotPath := filepath.Join(dayDirectory, event.EventID+".png")
|
||||||
|
file, err := os.Create(screenshotPath)
|
||||||
|
if err != nil {
|
||||||
|
return Record{}, fmt.Errorf("create screenshot: %w", err)
|
||||||
|
}
|
||||||
|
encodeErr := png.Encode(file, canvas)
|
||||||
|
closeErr := file.Close()
|
||||||
|
if encodeErr != nil || closeErr != nil {
|
||||||
|
_ = os.Remove(screenshotPath)
|
||||||
|
if encodeErr != nil {
|
||||||
|
return Record{}, fmt.Errorf("encode screenshot: %w", encodeErr)
|
||||||
|
}
|
||||||
|
return Record{}, fmt.Errorf("close screenshot: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
logPath := filepath.Join(dayDirectory, "events.jsonl")
|
||||||
|
relativeScreenshot := filepath.ToSlash(filepath.Join(now.Format("20060102"), event.EventID+".png"))
|
||||||
|
line, err := json.Marshal(struct {
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
TrackID string `json:"track_id"`
|
||||||
|
ConfigVersion string `json:"config_version"`
|
||||||
|
SourceID string `json:"source_id"`
|
||||||
|
State fall.State `json:"state"`
|
||||||
|
ConfirmedAtUTC string `json:"confirmed_at_utc"`
|
||||||
|
SuspectedAtMonotonic float64 `json:"suspected_at_monotonic"`
|
||||||
|
ConfirmedAtMonotonic float64 `json:"confirmed_at_monotonic"`
|
||||||
|
LatencySeconds float64 `json:"latency_seconds"`
|
||||||
|
Screenshot string `json:"screenshot"`
|
||||||
|
}{
|
||||||
|
EventID: event.EventID, TrackID: event.TrackID, ConfigVersion: event.ConfigVersion,
|
||||||
|
SourceID: dispatcher.sourceID, State: event.State, ConfirmedAtUTC: now.Format(time.RFC3339Nano),
|
||||||
|
SuspectedAtMonotonic: event.SuspectedAtMonotonic,
|
||||||
|
ConfirmedAtMonotonic: event.ConfirmedAtMonotonic,
|
||||||
|
LatencySeconds: event.LatencySeconds, Screenshot: relativeScreenshot,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(screenshotPath)
|
||||||
|
return Record{}, fmt.Errorf("encode event record: %w", err)
|
||||||
|
}
|
||||||
|
log, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(screenshotPath)
|
||||||
|
return Record{}, fmt.Errorf("open event log: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := log.Write(append(line, '\n')); err != nil {
|
||||||
|
_ = log.Close()
|
||||||
|
_ = os.Remove(screenshotPath)
|
||||||
|
return Record{}, fmt.Errorf("write event log: %w", err)
|
||||||
|
}
|
||||||
|
if err := log.Close(); err != nil {
|
||||||
|
return Record{}, fmt.Errorf("close event log: %w", err)
|
||||||
|
}
|
||||||
|
return Record{
|
||||||
|
Event: event, ScreenshotPath: screenshotPath, LogPath: logPath, CreatedAtUTC: now, Written: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package alert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"silverpose/v2/internal/fall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDispatchWritesOnePNGAndRedactedJSONL(t *testing.T) {
|
||||||
|
dispatcher := NewDispatcher(t.TempDir(), "lobby-camera-01")
|
||||||
|
event := fall.Event{
|
||||||
|
EventID: "FALL-run-000001", TrackID: "P-0001", ConfigVersion: "cfg-safe",
|
||||||
|
SuspectedAtMonotonic: 10, ConfirmedAtMonotonic: 11.8, LatencySeconds: 1.8, State: fall.Confirmed,
|
||||||
|
}
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||||
|
canvas.SetRGBA(0, 0, color.RGBA{R: 1, G: 2, B: 3, A: 255})
|
||||||
|
now := time.Date(2026, 7, 22, 8, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
first, err := dispatcher.Dispatch(event, canvas, now)
|
||||||
|
if err != nil || !first.Written {
|
||||||
|
t.Fatalf("first dispatch = %#v, %v", first, err)
|
||||||
|
}
|
||||||
|
second, err := dispatcher.Dispatch(event, canvas, now)
|
||||||
|
if err != nil || second.Written {
|
||||||
|
t.Fatalf("duplicate dispatch = %#v, %v", second, err)
|
||||||
|
}
|
||||||
|
if extension := filepath.Ext(first.ScreenshotPath); extension != ".png" {
|
||||||
|
t.Fatalf("screenshot extension = %q", extension)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(first.ScreenshotPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jsonl, err := os.ReadFile(first.LogPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if lines := strings.Count(strings.TrimSpace(string(jsonl)), "\n") + 1; lines != 1 {
|
||||||
|
t.Fatalf("JSONL lines = %d: %s", lines, jsonl)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(jsonl), "rtsp") || strings.Contains(string(jsonl), "secret") {
|
||||||
|
t.Fatalf("JSONL leaked source details: %s", jsonl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatchRejectsNonConfirmedEvent(t *testing.T) {
|
||||||
|
dispatcher := NewDispatcher(t.TempDir(), "lobby-camera-01")
|
||||||
|
_, err := dispatcher.Dispatch(fall.Event{EventID: "FALL-run-000001", State: fall.Suspect}, image.NewRGBA(image.Rect(0, 0, 1, 1)), time.Now())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-confirmed event error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Package render converts video frames into annotated display/evidence images.
|
||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/font/basicfont"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
|
|
||||||
|
"silverpose/v2/internal/fall"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
normalColor = color.RGBA{R: 21, G: 128, B: 61, A: 255}
|
||||||
|
cautionColor = color.RGBA{R: 180, G: 83, B: 9, A: 255}
|
||||||
|
criticalColor = color.RGBA{R: 198, G: 40, B: 40, A: 255}
|
||||||
|
)
|
||||||
|
|
||||||
|
var skeletonEdges = [][2]int{
|
||||||
|
{0, 1}, {0, 2}, {1, 3}, {2, 4}, {5, 6}, {5, 7}, {7, 9}, {6, 8}, {8, 10},
|
||||||
|
{5, 11}, {6, 12}, {11, 12}, {11, 13}, {13, 15}, {12, 14}, {14, 16},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render returns a stand-alone RGBA frame. The renderer accepts no source URL
|
||||||
|
// or credential and can therefore be used both for the UI and screenshot evidence.
|
||||||
|
func Render(bgr []byte, width, height int, result fall.FrameResult, capturedAt time.Time) (*image.RGBA, error) {
|
||||||
|
if width <= 0 || height <= 0 || len(bgr) != width*height*3 {
|
||||||
|
return nil, fmt.Errorf("BGR frame length = %d, want %d", len(bgr), width*height*3)
|
||||||
|
}
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
for y := 0; y < height; y++ {
|
||||||
|
for x := 0; x < width; x++ {
|
||||||
|
source := (y*width + x) * 3
|
||||||
|
destination := canvas.PixOffset(x, y)
|
||||||
|
canvas.Pix[destination] = bgr[source+2]
|
||||||
|
canvas.Pix[destination+1] = bgr[source+1]
|
||||||
|
canvas.Pix[destination+2] = bgr[source]
|
||||||
|
canvas.Pix[destination+3] = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
confirmed := false
|
||||||
|
for _, person := range result.People {
|
||||||
|
personColor := colorForState(person.State)
|
||||||
|
if person.State == fall.Confirmed {
|
||||||
|
confirmed = true
|
||||||
|
}
|
||||||
|
drawPerson(canvas, person, personColor, capturedAt)
|
||||||
|
}
|
||||||
|
if confirmed {
|
||||||
|
strokeRectangle(canvas, 0, 0, width-1, height-1, criticalColor, 6)
|
||||||
|
}
|
||||||
|
return canvas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawPerson(canvas *image.RGBA, person fall.PersonAnalysis, lineColor color.RGBA, capturedAt time.Time) {
|
||||||
|
pose := person.TrackedPose.Pose
|
||||||
|
box := pose.Box
|
||||||
|
strokeRectangle(canvas, round(box.Left), round(box.Top), round(box.Right), round(box.Bottom), lineColor, 2)
|
||||||
|
for _, edge := range skeletonEdges {
|
||||||
|
first := pose.Keypoints[edge[0]]
|
||||||
|
second := pose.Keypoints[edge[1]]
|
||||||
|
if first.Confidence > 0 && second.Confidence > 0 {
|
||||||
|
drawLine(canvas, round(first.X), round(first.Y), round(second.X), round(second.Y), lineColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, point := range pose.Keypoints {
|
||||||
|
if point.Confidence > 0 {
|
||||||
|
drawDot(canvas, round(point.X), round(point.Y), lineColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("%s %s", person.TrackedPose.TrackID, person.State)
|
||||||
|
if !capturedAt.IsZero() {
|
||||||
|
label += " " + capturedAt.UTC().Format("15:04:05")
|
||||||
|
}
|
||||||
|
drawText(canvas, round(box.Left), max(12, round(box.Top)-4), label, lineColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorForState(state fall.State) color.RGBA {
|
||||||
|
switch state {
|
||||||
|
case fall.Confirmed:
|
||||||
|
return criticalColor
|
||||||
|
case fall.Suspect, fall.Recovering:
|
||||||
|
return cautionColor
|
||||||
|
default:
|
||||||
|
return normalColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawText(canvas *image.RGBA, x, y int, text string, textColor color.RGBA) {
|
||||||
|
drawer := &font.Drawer{
|
||||||
|
Dst: canvas,
|
||||||
|
Src: image.NewUniform(textColor),
|
||||||
|
Face: basicfont.Face7x13,
|
||||||
|
Dot: fixed.P(x, y),
|
||||||
|
}
|
||||||
|
drawer.DrawString(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func strokeRectangle(canvas *image.RGBA, left, top, right, bottom int, lineColor color.RGBA, thickness int) {
|
||||||
|
if thickness < 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for offset := 0; offset < thickness; offset++ {
|
||||||
|
drawLine(canvas, left+offset, top+offset, right-offset, top+offset, lineColor)
|
||||||
|
drawLine(canvas, left+offset, bottom-offset, right-offset, bottom-offset, lineColor)
|
||||||
|
drawLine(canvas, left+offset, top+offset, left+offset, bottom-offset, lineColor)
|
||||||
|
drawLine(canvas, right-offset, top+offset, right-offset, bottom-offset, lineColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawLine(canvas *image.RGBA, x0, y0, x1, y1 int, lineColor color.RGBA) {
|
||||||
|
dx := abs(x1 - x0)
|
||||||
|
sx := -1
|
||||||
|
if x0 < x1 {
|
||||||
|
sx = 1
|
||||||
|
}
|
||||||
|
dy := -abs(y1 - y0)
|
||||||
|
sy := -1
|
||||||
|
if y0 < y1 {
|
||||||
|
sy = 1
|
||||||
|
}
|
||||||
|
err := dx + dy
|
||||||
|
for {
|
||||||
|
setPixel(canvas, x0, y0, lineColor)
|
||||||
|
if x0 == x1 && y0 == y1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
twiceError := 2 * err
|
||||||
|
if twiceError >= dy {
|
||||||
|
err += dy
|
||||||
|
x0 += sx
|
||||||
|
}
|
||||||
|
if twiceError <= dx {
|
||||||
|
err += dx
|
||||||
|
y0 += sy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawDot(canvas *image.RGBA, x, y int, dotColor color.RGBA) {
|
||||||
|
for vertical := -2; vertical <= 2; vertical++ {
|
||||||
|
for horizontal := -2; horizontal <= 2; horizontal++ {
|
||||||
|
if horizontal*horizontal+vertical*vertical <= 4 {
|
||||||
|
setPixel(canvas, x+horizontal, y+vertical, dotColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPixel(canvas *image.RGBA, x, y int, pixel color.RGBA) {
|
||||||
|
if !image.Pt(x, y).In(canvas.Bounds()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canvas.SetRGBA(x, y, pixel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func round(value float32) int { return int(math.Round(float64(value))) }
|
||||||
|
func abs(value int) int {
|
||||||
|
if value < 0 {
|
||||||
|
return -value
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
func max(left, right int) int {
|
||||||
|
if left > right {
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
return right
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"silverpose/v2/internal/fall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderUsesCriticalBorderOnlyForConfirmed(t *testing.T) {
|
||||||
|
bgr := make([]byte, 4*3*3)
|
||||||
|
for index := range bgr {
|
||||||
|
bgr[index] = 64
|
||||||
|
}
|
||||||
|
normal, err := Render(bgr, 4, 3, fall.FrameResult{}, time.Date(2026, 7, 22, 8, 0, 0, 0, time.UTC))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
confirmed, err := Render(bgr, 4, 3, fall.FrameResult{People: []fall.PersonAnalysis{{State: fall.Confirmed}}}, time.Date(2026, 7, 22, 8, 0, 0, 0, time.UTC))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := normal.RGBAAt(0, 0); got != (color.RGBA{R: 64, G: 64, B: 64, A: 255}) {
|
||||||
|
t.Fatalf("normal border = %#v", got)
|
||||||
|
}
|
||||||
|
if got := confirmed.RGBAAt(0, 0); got != criticalColor {
|
||||||
|
t.Fatalf("confirmed border = %#v, want %#v", got, criticalColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderRejectsInvalidBGRLength(t *testing.T) {
|
||||||
|
_, err := Render([]byte{1, 2, 3}, 2, 2, fall.FrameResult{}, time.Time{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid frame length error")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user