Adds a 测试连接摄像头 button next to save; it builds the RTSP URL from the current form fields and runs ffprobe (8s timeout) off the UI thread, then shows a result popup (success WxH@fps, or a redacted failure). Exports config.BuildRTSPURL and source.Probe/Redact for it. Cross-compiles for Windows; config/source tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
238 lines
7.2 KiB
Go
238 lines
7.2 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/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
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|