feat(v2): add realtime command and release tooling
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"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}
|
||||
window, err := ui.NewWindow(settingsFor(cfg), 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
|
||||
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()
|
||||
}()
|
||||
runtime, err := spike.OpenRuntime(controller.config.Model.ONNXPath, controller.config.Model.ONNXRuntimeDLLPath)
|
||||
if err != nil {
|
||||
controller.window.ReportIssue("无法初始化 ONNX Runtime")
|
||||
return
|
||||
}
|
||||
engine, err := fall.NewEngine(fall.EngineConfig{
|
||||
KeypointConfidenceThreshold: controller.config.Event.KeypointConfidenceThreshold,
|
||||
SuspectWindowSeconds: controller.config.Event.SuspectWindowSeconds,
|
||||
ConfirmWindowSeconds: controller.config.Event.ConfirmWindowSeconds,
|
||||
RecoveryWindowSeconds: controller.config.Event.RecoveryWindowSeconds,
|
||||
CooldownSeconds: controller.config.Event.CooldownSeconds,
|
||||
RequireRapidDrop: controller.config.Event.RequireRapidDrop,
|
||||
RequireLowerBody: controller.config.Event.RequireLowerBody,
|
||||
HorizontalAngleThresholdDegrees: controller.config.Event.HorizontalAngleThresholdDegrees,
|
||||
ConfigVersion: controller.config.RuntimeConfigVersion,
|
||||
SessionID: time.Now().UTC().Format("20060102-150405"),
|
||||
})
|
||||
if err != nil {
|
||||
runtime.Close()
|
||||
controller.window.ReportIssue("无法初始化摔倒事件引擎")
|
||||
return
|
||||
}
|
||||
stream := source.Start(ctx, source.Config{
|
||||
SourceURL: controller.config.Source.URL, FFmpegPath: controller.config.Tools.FFmpegPath,
|
||||
FFprobePath: controller.config.Tools.FFprobePath, Transport: controller.config.Source.Transport,
|
||||
Timeout: controller.config.Source.Timeout, LowLatency: controller.config.Source.LowLatency,
|
||||
}, source.Dependencies{})
|
||||
monitored, err := monitor.New(
|
||||
stream, runtime, engine, controller.config.Model.ConfidenceThreshold,
|
||||
alert.NewDispatcher(controller.config.Artifacts.EventDirectory, controller.config.Source.ID), time.Now,
|
||||
)
|
||||
if err != nil {
|
||||
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) 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),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"silverpose/v2/internal/config"
|
||||
)
|
||||
|
||||
func TestValidateStartupRejectsMissingRuntimeWithoutLeakingSource(t *testing.T) {
|
||||
err := validateStartup(config.Config{
|
||||
Source: config.SourceConfig{URL: "rtsp://operator:secret@example/Streaming/Channels/101"},
|
||||
Model: config.ModelConfig{ONNXPath: "missing-model.onnx", SHA256: strings.Repeat("a", 64), ONNXRuntimeDLLPath: "missing-runtime.dll"},
|
||||
Tools: config.ToolConfig{FFmpegPath: "missing-ffmpeg.exe", FFprobePath: "missing-ffprobe.exe"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing runtime error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "rtsp://") || strings.Contains(err.Error(), "secret") {
|
||||
t.Fatalf("startup error leaked source: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,17 @@ func (window *Window) PresentAlert(record alert.Record) {
|
||||
})
|
||||
}
|
||||
|
||||
// ReportIssue communicates a recoverable startup/worker issue without using
|
||||
// critical-red fall language or exposing configuration values.
|
||||
func (window *Window) ReportIssue(message string) {
|
||||
window.main.Synchronize(func() {
|
||||
window.model.ConnectionText = "视频流:" + message
|
||||
window.model.Critical = false
|
||||
_ = window.connectionLabel.SetText(window.model.ConnectionText)
|
||||
_ = window.stateLabel.SetText("检测状态:正常")
|
||||
})
|
||||
}
|
||||
|
||||
func highestState(result fall.FrameResult) fall.State {
|
||||
state := fall.Normal
|
||||
for _, person := range result.People {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string] $OnnxPath,
|
||||
[Parameter(Mandatory = $true)] [string] $OrtDllPath,
|
||||
[Parameter(Mandatory = $true)] [string] $FfmpegPath,
|
||||
[Parameter(Mandatory = $true)] [string] $FfprobePath,
|
||||
[Parameter(Mandatory = $true)] [string] $OutputDirectory
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Resolve-RequiredFile([string] $Path, [string] $Name) {
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||
throw "$Name does not exist or is not a file: $Path"
|
||||
}
|
||||
return (Resolve-Path -LiteralPath $Path).Path
|
||||
}
|
||||
|
||||
function Get-ReleaseFileInfo([string] $Path, [string] $Name) {
|
||||
$item = Get-Item -LiteralPath $Path
|
||||
return [ordered]@{
|
||||
name = $Name
|
||||
sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
bytes = $item.Length
|
||||
}
|
||||
}
|
||||
|
||||
$onnx = Resolve-RequiredFile $OnnxPath 'ONNX model'
|
||||
$ort = Resolve-RequiredFile $OrtDllPath 'ONNX Runtime DLL'
|
||||
$ffmpeg = Resolve-RequiredFile $FfmpegPath 'FFmpeg executable'
|
||||
$ffprobe = Resolve-RequiredFile $FfprobePath 'FFprobe executable'
|
||||
$target = [System.IO.Path]::GetFullPath($OutputDirectory)
|
||||
if (Test-Path -LiteralPath $target) {
|
||||
$existing = Get-ChildItem -LiteralPath $target -Force
|
||||
if ($existing.Count -gt 0) {
|
||||
throw "OutputDirectory must be empty: $target"
|
||||
}
|
||||
} else {
|
||||
New-Item -ItemType Directory -Path $target | Out-Null
|
||||
}
|
||||
|
||||
$scriptRoot = Split-Path -Parent $PSCommandPath
|
||||
$v2Root = Split-Path -Parent $scriptRoot
|
||||
$runtime = Join-Path $target 'runtime'
|
||||
New-Item -ItemType Directory -Path $runtime | Out-Null
|
||||
|
||||
$oldCgo = $env:CGO_ENABLED
|
||||
$env:CGO_ENABLED = '1'
|
||||
try {
|
||||
Push-Location $v2Root
|
||||
go build -trimpath -ldflags '-s -w' -o (Join-Path $target 'silver-pose.exe') ./cmd/silver-pose
|
||||
} finally {
|
||||
Pop-Location
|
||||
$env:CGO_ENABLED = $oldCgo
|
||||
}
|
||||
|
||||
Copy-Item -LiteralPath $onnx -Destination (Join-Path $runtime 'best.onnx')
|
||||
Copy-Item -LiteralPath $ort -Destination (Join-Path $runtime 'onnxruntime.dll')
|
||||
Copy-Item -LiteralPath $ffmpeg -Destination (Join-Path $runtime 'ffmpeg.exe')
|
||||
Copy-Item -LiteralPath $ffprobe -Destination (Join-Path $runtime 'ffprobe.exe')
|
||||
Copy-Item -LiteralPath (Join-Path $v2Root 'config.example.json') -Destination (Join-Path $target 'config.example.json')
|
||||
Copy-Item -LiteralPath (Join-Path (Split-Path -Parent $v2Root) 'docs\v2-demo-runbook.md') -Destination (Join-Path $target 'v2-demo-runbook.md')
|
||||
|
||||
$ffmpegVersion = (& (Join-Path $runtime 'ffmpeg.exe') -version | Select-Object -First 1)
|
||||
$ffprobeVersion = (& (Join-Path $runtime 'ffprobe.exe') -version | Select-Object -First 1)
|
||||
$manifest = [ordered]@{
|
||||
format = 'silver-pose-v2-runtime-1'
|
||||
generated_at_utc = [DateTime]::UtcNow.ToString('o')
|
||||
go_version = (& go version)
|
||||
ffmpeg_version = $ffmpegVersion
|
||||
ffprobe_version = $ffprobeVersion
|
||||
files = @(
|
||||
(Get-ReleaseFileInfo (Join-Path $target 'silver-pose.exe') 'silver-pose.exe'),
|
||||
(Get-ReleaseFileInfo (Join-Path $runtime 'best.onnx') 'runtime/best.onnx'),
|
||||
(Get-ReleaseFileInfo (Join-Path $runtime 'onnxruntime.dll') 'runtime/onnxruntime.dll'),
|
||||
(Get-ReleaseFileInfo (Join-Path $runtime 'ffmpeg.exe') 'runtime/ffmpeg.exe'),
|
||||
(Get-ReleaseFileInfo (Join-Path $runtime 'ffprobe.exe') 'runtime/ffprobe.exe')
|
||||
)
|
||||
}
|
||||
$manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $target 'runtime-manifest.json') -Encoding UTF8
|
||||
|
||||
Write-Output "V2 release created: $target"
|
||||
Reference in New Issue
Block a user