feat(v2): complete Go inference and UI spike
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"silverpose/v2/internal/spike"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ffmpegPath := flag.String("ffmpeg", "ffmpeg", "path to ffmpeg.exe")
|
||||
videoPath := flag.String("video", "..\\demo\\1.mp4", "local non-sensitive validation video")
|
||||
width := flag.Int("width", 848, "source video frame width")
|
||||
height := flag.Int("height", 480, "source video frame height")
|
||||
modelPath := flag.String("onnx", "assets\\best.onnx", "locked ONNX pose model path")
|
||||
runtimeDLLPath := flag.String("ort-dll", "", "absolute onnxruntime.dll path")
|
||||
flag.Parse()
|
||||
|
||||
if *runtimeDLLPath == "" {
|
||||
fail("--ort-dll is required")
|
||||
}
|
||||
if *width <= 0 || *height <= 0 {
|
||||
fail("--width and --height must be positive")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
raw, err := exec.CommandContext(ctx, *ffmpegPath,
|
||||
"-v", "error", "-i", *videoPath,
|
||||
"-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "bgr24", "-",
|
||||
).Output()
|
||||
if err != nil {
|
||||
fail("decode first BGR frame: %v", err)
|
||||
}
|
||||
expectedBytes := *width * *height * 3
|
||||
if len(raw) != expectedBytes {
|
||||
fail("decoded BGR frame has %d bytes, want %d; verify --width/--height", len(raw), expectedBytes)
|
||||
}
|
||||
|
||||
input, err := spike.LetterboxBGRToNCHW(raw, *width, *height, 640)
|
||||
if err != nil {
|
||||
fail("preprocess frame: %v", err)
|
||||
}
|
||||
output, err := spike.RunPose(*modelPath, *runtimeDLLPath, input)
|
||||
if err != nil {
|
||||
fail("run ONNX Pose: %v", err)
|
||||
}
|
||||
|
||||
max := float32(0)
|
||||
for _, value := range output {
|
||||
if value > max {
|
||||
max = value
|
||||
}
|
||||
}
|
||||
fmt.Printf("SPIKE_OK frame=%dx%d input=%d output=%d max=%.4f\n", *width, *height, len(input), len(output), max)
|
||||
}
|
||||
|
||||
func fail(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "spike: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"silverpose/v2/internal/ui"
|
||||
|
||||
. "github.com/lxn/walk/declarative"
|
||||
)
|
||||
|
||||
func main() {
|
||||
spec := ui.MonitorWindowSpec()
|
||||
|
||||
MainWindow{
|
||||
Title: spec.Title,
|
||||
MinSize: Size{Width: spec.Width, Height: spec.Height},
|
||||
Layout: VBox{},
|
||||
Children: []Widget{
|
||||
TabWidget{
|
||||
Pages: []TabPage{
|
||||
{
|
||||
Title: "监控",
|
||||
Layout: HBox{},
|
||||
Children: []Widget{
|
||||
GroupBox{
|
||||
Title: "实时画面",
|
||||
Layout: VBox{},
|
||||
Children: []Widget{
|
||||
TextLabel{Text: "视频帧将在此呈现"},
|
||||
},
|
||||
},
|
||||
GroupBox{
|
||||
Title: "当前状态",
|
||||
Layout: VBox{},
|
||||
Children: []Widget{
|
||||
TextLabel{Text: "检测状态:NORMAL"},
|
||||
TextLabel{Text: "事件:暂无"},
|
||||
TextLabel{Text: "运行时:Go + ONNX Runtime"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "设置",
|
||||
Layout: VBox{},
|
||||
Children: []Widget{
|
||||
TextLabel{Text: "设置将在 T-304 接入本机配置;不保存摄像头凭证。"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}.Run()
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
module silverpose/v2
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794
|
||||
github.com/yalue/onnxruntime_go v1.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw=
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||
github.com/yalue/onnxruntime_go v1.31.0 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV8HdtFA=
|
||||
github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
@@ -0,0 +1,63 @@
|
||||
package spike
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
ort "github.com/yalue/onnxruntime_go"
|
||||
)
|
||||
|
||||
const (
|
||||
poseInputSize = 640
|
||||
poseInputCount = 1 * 3 * poseInputSize * poseInputSize
|
||||
poseOutputCount = 1 * 56 * 8400
|
||||
)
|
||||
|
||||
// RunPose executes the locked YOLO pose ONNX graph once using the CPU runtime.
|
||||
// The caller supplies absolute model and DLL paths so no camera credential or
|
||||
// machine-specific path is retained in V2 configuration or source.
|
||||
func RunPose(modelPath, runtimeDLLPath string, input []float32) ([]float32, error) {
|
||||
if len(input) != poseInputCount {
|
||||
return nil, fmt.Errorf("pose input length = %d, want %d", len(input), poseInputCount)
|
||||
}
|
||||
|
||||
ort.SetSharedLibraryPath(runtimeDLLPath)
|
||||
if err := ort.InitializeEnvironment(); err != nil {
|
||||
return nil, fmt.Errorf("initialize ONNX Runtime: %w", err)
|
||||
}
|
||||
defer func() { _ = ort.DestroyEnvironment() }()
|
||||
|
||||
inputTensor, err := ort.NewTensor(ort.NewShape(1, 3, poseInputSize, poseInputSize), input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pose input tensor: %w", err)
|
||||
}
|
||||
defer func() { _ = inputTensor.Destroy() }()
|
||||
|
||||
outputTensor, err := ort.NewEmptyTensor[float32](ort.NewShape(1, 56, 8400))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pose output tensor: %w", err)
|
||||
}
|
||||
defer func() { _ = outputTensor.Destroy() }()
|
||||
|
||||
session, err := ort.NewAdvancedSession(
|
||||
modelPath,
|
||||
[]string{"images"},
|
||||
[]string{"output0"},
|
||||
[]ort.Value{inputTensor},
|
||||
[]ort.Value{outputTensor},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open pose ONNX session: %w", err)
|
||||
}
|
||||
defer func() { _ = session.Destroy() }()
|
||||
|
||||
if err := session.Run(); err != nil {
|
||||
return nil, fmt.Errorf("run pose ONNX session: %w", err)
|
||||
}
|
||||
|
||||
output := outputTensor.GetData()
|
||||
if len(output) != poseOutputCount {
|
||||
return nil, fmt.Errorf("pose output length = %d, want %d", len(output), poseOutputCount)
|
||||
}
|
||||
return append([]float32(nil), output...), nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package spike
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunPoseRejectsWrongInputLengthBeforeLoadingRuntime(t *testing.T) {
|
||||
_, err := RunPose("missing.onnx", "missing.dll", []float32{0})
|
||||
if err == nil {
|
||||
t.Fatal("RunPose accepted an input that is not 1x3x640x640")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "input length") {
|
||||
t.Fatalf("RunPose error = %q, want input length error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package spike
|
||||
|
||||
import "fmt"
|
||||
|
||||
// LetterboxBGRToNCHW converts one packed BGR frame into the RGB, CHW,
|
||||
// float32 tensor expected by the locked 640-pixel YOLO pose ONNX model.
|
||||
//
|
||||
// It intentionally uses nearest-neighbour scaling for this Spike. T-303
|
||||
// must replace or validate this preprocessing against the V1 implementation
|
||||
// before it becomes the V2 production preprocessing path.
|
||||
func LetterboxBGRToNCHW(frame []byte, width, height, target int) ([]float32, error) {
|
||||
if width <= 0 || height <= 0 || target <= 0 {
|
||||
return nil, fmt.Errorf("frame width, height and target must be positive")
|
||||
}
|
||||
if len(frame) != width*height*3 {
|
||||
return nil, fmt.Errorf("BGR frame length = %d, want %d", len(frame), width*height*3)
|
||||
}
|
||||
|
||||
scaleWidth := target
|
||||
scaleHeight := height * target / width
|
||||
if scaleHeight > target {
|
||||
scaleHeight = target
|
||||
scaleWidth = width * target / height
|
||||
}
|
||||
padX := (target - scaleWidth) / 2
|
||||
padY := (target - scaleHeight) / 2
|
||||
plane := target * target
|
||||
input := make([]float32, 3*plane)
|
||||
|
||||
for y := 0; y < scaleHeight; y++ {
|
||||
sourceY := y * height / scaleHeight
|
||||
for x := 0; x < scaleWidth; x++ {
|
||||
sourceX := x * width / scaleWidth
|
||||
source := (sourceY*width + sourceX) * 3
|
||||
destination := (padY+y)*target + padX + x
|
||||
input[destination] = float32(frame[source+2]) / 255.0
|
||||
input[plane+destination] = float32(frame[source+1]) / 255.0
|
||||
input[2*plane+destination] = float32(frame[source]) / 255.0
|
||||
}
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package spike
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLetterboxBGRToNCHWPlacesPixelInScaledImage(t *testing.T) {
|
||||
// One bright BGR pixel in a 2:1 frame should be resized into the centred
|
||||
// 640x320 image area. The remaining top/bottom area must stay black.
|
||||
frame := []byte{0, 0, 0, 255, 0, 0}
|
||||
|
||||
input, err := LetterboxBGRToNCHW(frame, 2, 1, 640)
|
||||
if err != nil {
|
||||
t.Fatalf("LetterboxBGRToNCHW returned an error: %v", err)
|
||||
}
|
||||
|
||||
const plane = 640 * 640
|
||||
if got := len(input); got != 3*plane {
|
||||
t.Fatalf("input length = %d, want %d", got, 3*plane)
|
||||
}
|
||||
if got := input[2*plane+159*640+320]; got != 0 {
|
||||
t.Fatalf("top padding blue channel = %v, want 0", got)
|
||||
}
|
||||
if got := input[0*plane+320*640+320]; got != 0 {
|
||||
t.Fatalf("red channel = %v, want 0", got)
|
||||
}
|
||||
if got := input[1*plane+320*640+320]; got != 0 {
|
||||
t.Fatalf("green channel = %v, want 0", got)
|
||||
}
|
||||
if got := input[2*plane+320*640+320]; got != 1 {
|
||||
t.Fatalf("blue channel = %v, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLetterboxBGRToNCHWRejectsIncorrectFrameSize(t *testing.T) {
|
||||
_, err := LetterboxBGRToNCHW([]byte{0}, 2, 1, 640)
|
||||
if err == nil {
|
||||
t.Fatal("LetterboxBGRToNCHW accepted a truncated BGR frame")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package ui
|
||||
|
||||
// WindowSpec locks the visual baseline tested by the Windows UI Spike. The
|
||||
// production V2 app will consume the same semantic colours and medium desktop
|
||||
// layout after the decoding/inference pipeline is complete.
|
||||
type WindowSpec struct {
|
||||
Title string
|
||||
Width int
|
||||
Height int
|
||||
BackgroundHex string
|
||||
AlertHex string
|
||||
}
|
||||
|
||||
func MonitorWindowSpec() WindowSpec {
|
||||
return WindowSpec{
|
||||
Title: "Silver Pose V2 · 技术 Spike",
|
||||
Width: 1120,
|
||||
Height: 720,
|
||||
BackgroundHex: "#F3F6FA",
|
||||
AlertHex: "#C62828",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ui
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMonitorWindowSpecUsesLightMonitoringLayout(t *testing.T) {
|
||||
spec := MonitorWindowSpec()
|
||||
if spec.Title != "Silver Pose V2 · 技术 Spike" {
|
||||
t.Fatalf("title = %q", spec.Title)
|
||||
}
|
||||
if spec.Width != 1120 || spec.Height != 720 {
|
||||
t.Fatalf("size = %dx%d, want 1120x720", spec.Width, spec.Height)
|
||||
}
|
||||
if spec.BackgroundHex != "#F3F6FA" || spec.AlertHex != "#C62828" {
|
||||
t.Fatalf("unexpected colours: background=%s alert=%s", spec.BackgroundHex, spec.AlertHex)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user