feat(v2): add fall event regression engine

This commit is contained in:
ila
2026-07-22 16:18:16 +08:00
parent 258fa49259
commit 1298369054
22 changed files with 1214 additions and 92 deletions
+88 -39
View File
@@ -12,6 +12,90 @@ const (
poseOutputCount = 1 * 56 * 8400
)
// Runtime owns one reusable ONNX session. V2 video replay must keep the
// model session alive across frames; recreating it for every frame would make
// event-latency measurements meaningless.
type Runtime struct {
input *ort.Tensor[float32]
output *ort.Tensor[float32]
session *ort.AdvancedSession
initialized bool
closed bool
}
func OpenRuntime(modelPath, runtimeDLLPath string) (*Runtime, error) {
ort.SetSharedLibraryPath(runtimeDLLPath)
if err := ort.InitializeEnvironment(); err != nil {
return nil, fmt.Errorf("initialize ONNX Runtime: %w", err)
}
runtime := &Runtime{initialized: true}
var err error
runtime.input, err = ort.NewEmptyTensor[float32](ort.NewShape(1, 3, poseInputSize, poseInputSize))
if err != nil {
runtime.Close()
return nil, fmt.Errorf("create pose input tensor: %w", err)
}
runtime.output, err = ort.NewEmptyTensor[float32](ort.NewShape(1, 56, 8400))
if err != nil {
runtime.Close()
return nil, fmt.Errorf("create pose output tensor: %w", err)
}
runtime.session, err = ort.NewAdvancedSession(
modelPath,
[]string{"images"},
[]string{"output0"},
[]ort.Value{runtime.input},
[]ort.Value{runtime.output},
nil,
)
if err != nil {
runtime.Close()
return nil, fmt.Errorf("open pose ONNX session: %w", err)
}
return runtime, nil
}
func (runtime *Runtime) Run(input []float32) ([]float32, error) {
if runtime == nil || runtime.closed {
return nil, fmt.Errorf("ONNX Runtime session is closed")
}
if len(input) != poseInputCount {
return nil, fmt.Errorf("pose input length = %d, want %d", len(input), poseInputCount)
}
copy(runtime.input.GetData(), input)
if err := runtime.session.Run(); err != nil {
return nil, fmt.Errorf("run pose ONNX session: %w", err)
}
output := runtime.output.GetData()
if len(output) != poseOutputCount {
return nil, fmt.Errorf("pose output length = %d, want %d", len(output), poseOutputCount)
}
return append([]float32(nil), output...), nil
}
func (runtime *Runtime) Close() {
if runtime == nil || runtime.closed {
return
}
if runtime.session != nil {
_ = runtime.session.Destroy()
runtime.session = nil
}
if runtime.output != nil {
_ = runtime.output.Destroy()
runtime.output = nil
}
if runtime.input != nil {
_ = runtime.input.Destroy()
runtime.input = nil
}
if runtime.initialized {
_ = ort.DestroyEnvironment()
runtime.initialized = false
}
runtime.closed = true
}
// 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.
@@ -19,45 +103,10 @@ func RunPose(modelPath, runtimeDLLPath string, input []float32) ([]float32, erro
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)
runtime, err := OpenRuntime(modelPath, runtimeDLLPath)
if err != nil {
return nil, fmt.Errorf("create pose input tensor: %w", err)
return nil, 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
defer runtime.Close()
return runtime.Run(input)
}
+10
View File
@@ -14,3 +14,13 @@ func TestRunPoseRejectsWrongInputLengthBeforeLoadingRuntime(t *testing.T) {
t.Fatalf("RunPose error = %q, want input length error", err)
}
}
func TestOpenRuntimeReportsMissingDLL(t *testing.T) {
_, err := OpenRuntime("missing.onnx", "missing.dll")
if err == nil {
t.Fatal("OpenRuntime accepted a missing ONNX Runtime DLL")
}
if !strings.Contains(err.Error(), "initialize ONNX Runtime") {
t.Fatalf("OpenRuntime error = %q", err)
}
}
+6 -35
View File
@@ -1,43 +1,14 @@
package spike
import "fmt"
import "silverpose/v2/internal/pose"
// 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.
// T-303 aligned the implementation with the fixed-shape Ultralytics
// letterbox used by the V1/ONNX baseline. New V2 code should import the pose
// package directly to retain the returned coordinate transform.
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
input, _, err := pose.PreprocessBGR(frame, width, height, target)
return input, err
}
+5 -4
View File
@@ -16,8 +16,9 @@ func TestLetterboxBGRToNCHWPlacesPixelInScaledImage(t *testing.T) {
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)
padding := float32(114.0 / 255.0)
if got := input[2*plane+159*640+320]; got != padding {
t.Fatalf("top padding blue channel = %v, want %v", got, padding)
}
if got := input[0*plane+320*640+320]; got != 0 {
t.Fatalf("red channel = %v, want 0", got)
@@ -25,8 +26,8 @@ func TestLetterboxBGRToNCHWPlacesPixelInScaledImage(t *testing.T) {
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)
if got := input[2*plane+320*640+320]; got != float32(128.0/255.0) {
t.Fatalf("blue channel = %v, want cv2 INTER_LINEAR value 128/255", got)
}
}