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