Files
silver_pose/v2/cmd/silver-pose/main.go
T
ilaandClaude Opus 4.8 6c779aafb4 feat(v2): T-309 SQLite fall-event persistence (mattn/go-sqlite3)
Persist confirmed fall events to a local SQLite DB under artifacts/:
camera host/port/channel + event id/track/source/config version/state/
latency/screenshot path/confirmed-at. Schema stores no password (guarded
by test). Wire openRecorder/saveRecord into the alerts loop; failures are
non-fatal. Re-applied the SILVER-POSE vendored os_windows.go patch that
go mod vendor reverted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:15:55 +08:00

274 lines
8.5 KiB
Go

package main
import (
"context"
"crypto/sha256"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"silverpose/v2/internal/alert"
"silverpose/v2/internal/config"
"silverpose/v2/internal/fall"
"silverpose/v2/internal/monitor"
"silverpose/v2/internal/source"
"silverpose/v2/internal/spike"
"silverpose/v2/internal/store"
"silverpose/v2/internal/ui"
)
func main() {
configPath := flag.String("config", "config.local.json", "local V2 JSON configuration without RTSP credentials")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatal("配置无效:", err)
}
if err := validateStartup(cfg); err != nil {
log.Fatal("启动预检失败:", err)
}
controller := &controller{config: cfg, configPath: *configPath}
window, err := ui.NewWindow(settingsFor(cfg, *configPath), ui.Commands{Start: controller.Start, Stop: controller.Stop})
if err != nil {
log.Fatal("创建操作窗口失败:", err)
}
controller.window = window
window.Run()
controller.Stop()
}
type controller struct {
config config.Config
configPath string
window *ui.Window
mu sync.Mutex
cancel func()
runID uint64
}
func (controller *controller) Start() {
controller.mu.Lock()
if controller.cancel != nil {
controller.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(context.Background())
controller.cancel = cancel
controller.runID++
runID := controller.runID
controller.mu.Unlock()
go controller.run(ctx, cancel, runID)
}
func (controller *controller) Stop() {
controller.mu.Lock()
cancel := controller.cancel
controller.cancel = nil
controller.mu.Unlock()
if cancel != nil {
cancel()
}
}
func (controller *controller) run(ctx context.Context, cancel func(), runID uint64) {
defer func() {
controller.mu.Lock()
if controller.runID == runID {
controller.cancel = nil
}
controller.mu.Unlock()
cancel()
}()
cfg, err := config.Load(controller.configPath)
if err != nil {
controller.window.ReportIssue("配置无效,无法开始监控")
return
}
runtime, err := spike.OpenRuntime(cfg.Model.ONNXPath, cfg.Model.ONNXRuntimeDLLPath)
if err != nil {
log.Printf("初始化 ONNX Runtime 失败:%v", err)
controller.window.ReportIssue("无法初始化 ONNX Runtime")
return
}
engine, err := fall.NewEngine(fall.EngineConfig{
KeypointConfidenceThreshold: cfg.Event.KeypointConfidenceThreshold,
SuspectWindowSeconds: cfg.Event.SuspectWindowSeconds,
ConfirmWindowSeconds: cfg.Event.ConfirmWindowSeconds,
RecoveryWindowSeconds: cfg.Event.RecoveryWindowSeconds,
CooldownSeconds: cfg.Event.CooldownSeconds,
RequireRapidDrop: cfg.Event.RequireRapidDrop,
RequireLowerBody: cfg.Event.RequireLowerBody,
HorizontalAngleThresholdDegrees: cfg.Event.HorizontalAngleThresholdDegrees,
ConfigVersion: cfg.RuntimeConfigVersion,
SessionID: time.Now().UTC().Format("20060102-150405"),
})
if err != nil {
log.Printf("初始化摔倒事件引擎失败:%v", err)
runtime.Close()
controller.window.ReportIssue("无法初始化摔倒事件引擎")
return
}
stream := source.Start(ctx, source.Config{
SourceURL: cfg.Source.URL, FFmpegPath: cfg.Tools.FFmpegPath,
FFprobePath: cfg.Tools.FFprobePath, Transport: cfg.Source.Transport,
Timeout: cfg.Source.Timeout, LowLatency: cfg.Source.LowLatency,
}, source.Dependencies{})
monitored, err := monitor.New(
stream, runtime, engine, cfg.Model.ConfidenceThreshold,
alert.NewDispatcher(cfg.Artifacts.EventDirectory, cfg.Source.ID), time.Now,
)
if err != nil {
log.Printf("启动监控管线失败:%v", err)
runtime.Close()
controller.window.ReportIssue("无法启动监控管线")
return
}
recorder := openRecorder(cfg)
if recorder != nil {
defer recorder.Close()
}
go monitored.Run(ctx)
updates, alerts := monitored.Updates(), monitored.Alerts()
for updates != nil || alerts != nil {
select {
case update, open := <-updates:
if !open {
updates = nil
continue
}
controller.window.Present(update)
case record, open := <-alerts:
if !open {
alerts = nil
continue
}
controller.window.PresentAlert(record)
saveRecord(recorder, cfg, record)
}
}
}
// openRecorder opens the local SQLite fall database under the event directory.
// A failure is logged and monitoring continues without persistence.
func openRecorder(cfg config.Config) *store.Store {
if err := os.MkdirAll(cfg.Artifacts.EventDirectory, 0o755); err != nil {
log.Printf("创建事件目录失败:%v", err)
return nil
}
recorder, err := store.Open(filepath.Join(cfg.Artifacts.EventDirectory, "fall-events.db"))
if err != nil {
log.Printf("打开摔倒数据库失败:%v", err)
return nil
}
return recorder
}
func saveRecord(recorder *store.Store, cfg config.Config, record alert.Record) {
if recorder == nil {
return
}
if err := recorder.Save(store.Record{
EventID: record.Event.EventID, TrackID: record.Event.TrackID, SourceID: cfg.Source.ID,
CameraHost: cfg.Source.Host, CameraPort: cfg.Source.Port, CameraChannel: cfg.Source.Channel,
ConfigVersion: record.Event.ConfigVersion, State: fmt.Sprint(record.Event.State),
LatencySeconds: record.Event.LatencySeconds, ScreenshotPath: record.ScreenshotPath,
ConfirmedAtUTC: record.CreatedAtUTC.UTC().Format(time.RFC3339),
}); err != nil {
log.Printf("写入摔倒记录失败:%v", err)
}
}
func validateStartup(cfg config.Config) error {
for _, path := range []string{cfg.Model.ONNXPath, cfg.Model.ONNXRuntimeDLLPath, cfg.Tools.FFmpegPath, cfg.Tools.FFprobePath} {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return fmt.Errorf("必需运行文件缺失或不可读")
}
}
actual, err := sha256File(cfg.Model.ONNXPath)
if err != nil {
return fmt.Errorf("无法校验 ONNX 模型")
}
if !strings.EqualFold(actual, cfg.Model.SHA256) {
return fmt.Errorf("ONNX 模型哈希与配置不匹配")
}
return nil
}
func sha256File(path string) (string, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return "", err
}
defer file.Close()
digest := sha256.New()
if _, err := io.Copy(digest, file); err != nil {
return "", err
}
return fmt.Sprintf("%x", digest.Sum(nil)), nil
}
func settingsFor(cfg config.Config, configPath string) ui.Settings {
return ui.Settings{
SourceEnvironment: cfg.Source.RTSPURLEnv,
ModelSHA256: cfg.Model.SHA256,
RuntimeSummary: "ONNX Runtime、FFmpeg 和 FFprobe 已通过启动预检",
EventSummary: fmt.Sprintf("确认 %.1f 秒;水平角 %.0f°;快速下移=%t;下肢=%t",
cfg.Event.ConfirmWindowSeconds, cfg.Event.HorizontalAngleThresholdDegrees,
cfg.Event.RequireRapidDrop, cfg.Event.RequireLowerBody),
FFprobePath: cfg.Tools.FFprobePath,
ConfigPath: configPath,
Camera: cameraFieldsFrom(configPath),
Params: ui.ParamFields{
KeypointConfidence: float64(cfg.Event.KeypointConfidenceThreshold),
ConfirmWindowSeconds: cfg.Event.ConfirmWindowSeconds,
HorizontalAngleDegrees: float64(cfg.Event.HorizontalAngleThresholdDegrees),
ModelConfidence: float64(cfg.Model.ConfidenceThreshold),
RequireRapidDrop: cfg.Event.RequireRapidDrop,
RequireLowerBody: cfg.Event.RequireLowerBody,
},
}
}
// cameraFieldsFrom pre-fills the settings form from the untracked local config's
// source block. It reads credentials only to display them in the (masked) form;
// they are never logged.
func cameraFieldsFrom(configPath string) ui.CameraFields {
data, err := os.ReadFile(configPath)
if err != nil {
return ui.CameraFields{}
}
var document struct {
Source struct {
ID string `json:"id"`
Host string `json:"host"`
Port int `json:"port"`
Channel string `json:"channel"`
Username string `json:"username"`
Password string `json:"password"`
Transport string `json:"transport"`
TimeoutSeconds float64 `json:"timeout_seconds"`
LowLatency *bool `json:"low_latency"`
} `json:"source"`
}
if err := json.Unmarshal(data, &document); err != nil {
return ui.CameraFields{}
}
lowLatency := true
if document.Source.LowLatency != nil {
lowLatency = *document.Source.LowLatency
}
return ui.CameraFields{
ID: document.Source.ID, Host: document.Source.Host, Port: document.Source.Port,
Channel: document.Source.Channel, Username: document.Source.Username, Password: document.Source.Password,
Transport: document.Source.Transport, TimeoutSeconds: document.Source.TimeoutSeconds, LowLatency: lowLatency,
}
}