feat(v2): add annotated evidence and alert dispatch

This commit is contained in:
ila
2026-07-22 21:17:50 +08:00
parent 3edda4fb62
commit d183170554
9 changed files with 424 additions and 5 deletions
+141
View File
@@ -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
}
+57
View File
@@ -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")
}
}
+174
View File
@@ -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
}
+37
View File
@@ -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")
}
}