feat(win): T-WIN-002 REST 客户端与契约类型
This commit is contained in:
@@ -0,0 +1,170 @@
|
|||||||
|
class_name ApiClient
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
## T-WIN-002:Web 服务端 REST 客户端
|
||||||
|
##
|
||||||
|
## 只消费 opendance-web 冻结的契约(contracts/rest-api.yaml v0.1.0)。
|
||||||
|
## 所有方法返回 { ok: bool, status: int, data: Variant, error: String },
|
||||||
|
## 不抛异常,调用方统一判 ok。
|
||||||
|
|
||||||
|
signal request_completed(path: String, ok: bool, status: int)
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_SECONDS := 20.0
|
||||||
|
|
||||||
|
var base_url: String = "http://127.0.0.1:3000"
|
||||||
|
var timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
func _init(api_base_url: String = "") -> void:
|
||||||
|
if api_base_url != "":
|
||||||
|
base_url = api_base_url
|
||||||
|
|
||||||
|
|
||||||
|
## 规范化基地址:去掉尾部斜杠
|
||||||
|
func set_base_url(value: String) -> void:
|
||||||
|
base_url = value.strip_edges().rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
static func join_url(base: String, path: String) -> String:
|
||||||
|
var b := base.rstrip("/")
|
||||||
|
var p := path if path.begins_with("/") else "/" + path
|
||||||
|
return b + p
|
||||||
|
|
||||||
|
|
||||||
|
func get_json(path: String) -> Dictionary:
|
||||||
|
return await _request(HTTPClient.METHOD_GET, path)
|
||||||
|
|
||||||
|
|
||||||
|
func get_text(path: String) -> Dictionary:
|
||||||
|
return await _request(HTTPClient.METHOD_GET, path, {}, true)
|
||||||
|
|
||||||
|
|
||||||
|
## 下载二进制资源(模型文件等)
|
||||||
|
func get_binary(path: String) -> Dictionary:
|
||||||
|
return await _request(HTTPClient.METHOD_GET, path, {}, false, true)
|
||||||
|
|
||||||
|
|
||||||
|
func post_json(path: String, body: Dictionary = {}) -> Dictionary:
|
||||||
|
return await _request(HTTPClient.METHOD_POST, path, body)
|
||||||
|
|
||||||
|
|
||||||
|
## 健康检查:契约 GET /health
|
||||||
|
func health() -> Dictionary:
|
||||||
|
return await get_json("/health")
|
||||||
|
|
||||||
|
|
||||||
|
## 契约版本 + 目录指纹:GET /api/catalog/version
|
||||||
|
func catalog_version() -> Dictionary:
|
||||||
|
return await get_json("/api/catalog/version")
|
||||||
|
|
||||||
|
|
||||||
|
## 客户端一次性拉取已发布目录:GET /api/catalog/export
|
||||||
|
func catalog_export() -> Dictionary:
|
||||||
|
return await get_json("/api/catalog/export")
|
||||||
|
|
||||||
|
|
||||||
|
## 关键点序列(NDJSON):GET /api/motions/{id}/keypoints
|
||||||
|
func motion_keypoints(motion_id: int) -> Dictionary:
|
||||||
|
return await get_text("/api/motions/%d/keypoints" % motion_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _request(method: int, path: String, body: Dictionary = {}, as_text: bool = false, as_binary: bool = false) -> Dictionary:
|
||||||
|
var outcome := {
|
||||||
|
"ok": false,
|
||||||
|
"status": 0,
|
||||||
|
"data": null,
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
var http := HTTPRequest.new()
|
||||||
|
http.timeout = timeout_seconds
|
||||||
|
add_child(http)
|
||||||
|
|
||||||
|
var url := join_url(base_url, path)
|
||||||
|
var headers := PackedStringArray(["Accept: application/json"])
|
||||||
|
var payload := ""
|
||||||
|
if method != HTTPClient.METHOD_GET:
|
||||||
|
payload = JSON.stringify(body)
|
||||||
|
headers.append("Content-Type: application/json")
|
||||||
|
|
||||||
|
var err := http.request(url, headers, method, payload)
|
||||||
|
if err != OK:
|
||||||
|
outcome["error"] = "无法发起请求(错误码 %d):%s" % [err, url]
|
||||||
|
http.queue_free()
|
||||||
|
request_completed.emit(path, false, 0)
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
var response: Array = await http.request_completed
|
||||||
|
http.queue_free()
|
||||||
|
|
||||||
|
var result_code: int = response[0]
|
||||||
|
var status: int = response[1]
|
||||||
|
var raw: PackedByteArray = response[3]
|
||||||
|
outcome["status"] = status
|
||||||
|
|
||||||
|
if result_code != HTTPRequest.RESULT_SUCCESS:
|
||||||
|
outcome["error"] = _describe_result(result_code)
|
||||||
|
request_completed.emit(path, false, status)
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
var text := raw.get_string_from_utf8()
|
||||||
|
if status < 200 or status >= 300:
|
||||||
|
outcome["error"] = "服务端返回 %d:%s" % [status, _extract_error_message(text)]
|
||||||
|
request_completed.emit(path, false, status)
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
if as_binary:
|
||||||
|
outcome["ok"] = true
|
||||||
|
outcome["data"] = raw
|
||||||
|
request_completed.emit(path, true, status)
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
if as_text:
|
||||||
|
outcome["ok"] = true
|
||||||
|
outcome["data"] = text
|
||||||
|
request_completed.emit(path, true, status)
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
var parsed: Variant = JSON.parse_string(text)
|
||||||
|
if parsed == null and text.strip_edges() != "null":
|
||||||
|
outcome["error"] = "响应不是合法 JSON"
|
||||||
|
request_completed.emit(path, false, status)
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
outcome["ok"] = true
|
||||||
|
outcome["data"] = parsed
|
||||||
|
request_completed.emit(path, true, status)
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
|
||||||
|
## 解析契约统一错误体 { statusCode, message, error }
|
||||||
|
static func _extract_error_message(text: String) -> String:
|
||||||
|
var parsed: Variant = JSON.parse_string(text)
|
||||||
|
if typeof(parsed) != TYPE_DICTIONARY:
|
||||||
|
return text.strip_edges().left(200)
|
||||||
|
var dict: Dictionary = parsed
|
||||||
|
if not dict.has("message"):
|
||||||
|
return text.strip_edges().left(200)
|
||||||
|
var message: Variant = dict["message"]
|
||||||
|
if typeof(message) == TYPE_ARRAY:
|
||||||
|
var parts := PackedStringArray()
|
||||||
|
for item in message:
|
||||||
|
parts.append(str(item))
|
||||||
|
return "; ".join(parts)
|
||||||
|
return str(message)
|
||||||
|
|
||||||
|
|
||||||
|
static func _describe_result(code: int) -> String:
|
||||||
|
match code:
|
||||||
|
HTTPRequest.RESULT_CANT_CONNECT:
|
||||||
|
return "无法连接到服务端(请确认 Web 服务端已启动且地址正确)"
|
||||||
|
HTTPRequest.RESULT_CANT_RESOLVE:
|
||||||
|
return "无法解析服务端地址"
|
||||||
|
HTTPRequest.RESULT_CONNECTION_ERROR:
|
||||||
|
return "连接中断"
|
||||||
|
HTTPRequest.RESULT_TIMEOUT:
|
||||||
|
return "请求超时"
|
||||||
|
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
|
||||||
|
return "TLS 握手失败"
|
||||||
|
_:
|
||||||
|
return "请求失败(结果码 %d)" % code
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
class_name CatalogTypes
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
## 契约资源类型 —— contracts/data-models.md v0.1.0
|
||||||
|
## 字段名与 JSON 完全一致(camelCase),解析时做类型收敛。
|
||||||
|
|
||||||
|
## 来源(出处)五元组 —— 契约 §1
|
||||||
|
class SourceInfo extends RefCounted:
|
||||||
|
var source_type: String = ""
|
||||||
|
var source_name: String = ""
|
||||||
|
var source_url: String = ""
|
||||||
|
var license: String = ""
|
||||||
|
var author: String = ""
|
||||||
|
|
||||||
|
static func from_dict(d: Dictionary) -> SourceInfo:
|
||||||
|
var out := SourceInfo.new()
|
||||||
|
out.source_type = str(d.get("sourceType", ""))
|
||||||
|
out.source_name = str(d.get("sourceName", ""))
|
||||||
|
out.source_url = str(d.get("sourceUrl", ""))
|
||||||
|
out.license = str(d.get("license", ""))
|
||||||
|
out.author = str(d.get("author", ""))
|
||||||
|
return out
|
||||||
|
|
||||||
|
## 来源类型的中文标签(管理/展示用)
|
||||||
|
func type_label() -> String:
|
||||||
|
match source_type:
|
||||||
|
"original": return "原创"
|
||||||
|
"licensed": return "已获授权"
|
||||||
|
"cc": return "Creative Commons"
|
||||||
|
"public_domain": return "公共领域"
|
||||||
|
"user_upload": return "用户上传"
|
||||||
|
"other": return "其他"
|
||||||
|
return source_type
|
||||||
|
|
||||||
|
## 是否五项齐备 —— 契约要求任何资产都必须完整
|
||||||
|
func is_complete() -> bool:
|
||||||
|
return (
|
||||||
|
source_type != "" and source_name != "" and source_url != ""
|
||||||
|
and license != "" and author != ""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Song extends RefCounted:
|
||||||
|
var id: int = 0
|
||||||
|
var title: String = ""
|
||||||
|
var artist: String = ""
|
||||||
|
var album: String = ""
|
||||||
|
var language: String = ""
|
||||||
|
var duration_ms: int = 0
|
||||||
|
var bpm: float = 0.0
|
||||||
|
var difficulty: int = 0
|
||||||
|
var tags: PackedStringArray = PackedStringArray()
|
||||||
|
var cover_url: String = ""
|
||||||
|
var audio_url: String = ""
|
||||||
|
var source: SourceInfo = SourceInfo.new()
|
||||||
|
var status: String = ""
|
||||||
|
|
||||||
|
static func from_dict(d: Dictionary) -> Song:
|
||||||
|
var out := Song.new()
|
||||||
|
out.id = int(d.get("id", 0))
|
||||||
|
out.title = str(d.get("title", ""))
|
||||||
|
out.artist = str(d.get("artist", ""))
|
||||||
|
out.album = str(d.get("album", "") if d.get("album") != null else "")
|
||||||
|
out.language = str(d.get("language", "") if d.get("language") != null else "")
|
||||||
|
out.duration_ms = int(d.get("durationMs", 0) if d.get("durationMs") != null else 0)
|
||||||
|
out.bpm = float(d.get("bpm", 0.0) if d.get("bpm") != null else 0.0)
|
||||||
|
out.difficulty = int(d.get("difficulty", 0) if d.get("difficulty") != null else 0)
|
||||||
|
out.tags = CatalogTypes._strings(d.get("tags", []))
|
||||||
|
out.cover_url = CatalogTypes._nullable_string(d.get("coverUrl"))
|
||||||
|
out.audio_url = CatalogTypes._nullable_string(d.get("audioUrl"))
|
||||||
|
out.source = SourceInfo.from_dict(d)
|
||||||
|
out.status = str(d.get("status", ""))
|
||||||
|
return out
|
||||||
|
|
||||||
|
func duration_label() -> String:
|
||||||
|
if duration_ms <= 0:
|
||||||
|
return "--:--"
|
||||||
|
var total := int(duration_ms / 1000.0)
|
||||||
|
return "%d:%02d" % [total / 60, total % 60]
|
||||||
|
|
||||||
|
|
||||||
|
class Motion extends RefCounted:
|
||||||
|
var id: int = 0
|
||||||
|
var song_id: int = -1
|
||||||
|
var name: String = ""
|
||||||
|
var format: String = ""
|
||||||
|
var file_url: String = ""
|
||||||
|
var file_size: int = 0
|
||||||
|
var fps: float = 0.0
|
||||||
|
var frame_count: int = 0
|
||||||
|
var duration_ms: int = 0
|
||||||
|
var keypoint_format: String = ""
|
||||||
|
var keypoint_url: String = ""
|
||||||
|
var keypoint_frames: int = 0
|
||||||
|
var preprocess_state: String = "none"
|
||||||
|
var preprocess_error: String = ""
|
||||||
|
var source: SourceInfo = SourceInfo.new()
|
||||||
|
var status: String = ""
|
||||||
|
|
||||||
|
static func from_dict(d: Dictionary) -> Motion:
|
||||||
|
var out := Motion.new()
|
||||||
|
out.id = int(d.get("id", 0))
|
||||||
|
out.song_id = int(d.get("songId", -1) if d.get("songId") != null else -1)
|
||||||
|
out.name = str(d.get("name", ""))
|
||||||
|
out.format = str(d.get("format", ""))
|
||||||
|
out.file_url = CatalogTypes._nullable_string(d.get("fileUrl"))
|
||||||
|
out.file_size = int(d.get("fileSize", 0) if d.get("fileSize") != null else 0)
|
||||||
|
out.fps = float(d.get("fps", 0.0) if d.get("fps") != null else 0.0)
|
||||||
|
out.frame_count = int(d.get("frameCount", 0) if d.get("frameCount") != null else 0)
|
||||||
|
out.duration_ms = int(d.get("durationMs", 0) if d.get("durationMs") != null else 0)
|
||||||
|
out.keypoint_format = CatalogTypes._nullable_string(d.get("keypointFormat"))
|
||||||
|
out.keypoint_url = CatalogTypes._nullable_string(d.get("keypointUrl"))
|
||||||
|
out.keypoint_frames = int(d.get("keypointFrames", 0) if d.get("keypointFrames") != null else 0)
|
||||||
|
out.preprocess_state = str(d.get("preprocessState", "none"))
|
||||||
|
out.preprocess_error = CatalogTypes._nullable_string(d.get("preprocessError"))
|
||||||
|
out.source = SourceInfo.from_dict(d)
|
||||||
|
out.status = str(d.get("status", ""))
|
||||||
|
return out
|
||||||
|
|
||||||
|
## 是否可用于 DTW 参考序列
|
||||||
|
func is_scorable() -> bool:
|
||||||
|
return preprocess_state == "done" and keypoint_frames > 0
|
||||||
|
|
||||||
|
func preprocess_label() -> String:
|
||||||
|
match preprocess_state:
|
||||||
|
"none": return "未处理"
|
||||||
|
"running": return "处理中"
|
||||||
|
"done": return "已完成"
|
||||||
|
"failed": return "失败"
|
||||||
|
return preprocess_state
|
||||||
|
|
||||||
|
|
||||||
|
class Model extends RefCounted:
|
||||||
|
var id: int = 0
|
||||||
|
var name: String = ""
|
||||||
|
var format: String = ""
|
||||||
|
var vrm_version: String = ""
|
||||||
|
var is_primary: bool = false
|
||||||
|
var file_url: String = ""
|
||||||
|
var file_size: int = 0
|
||||||
|
var thumbnail_url: String = ""
|
||||||
|
var source: SourceInfo = SourceInfo.new()
|
||||||
|
var status: String = ""
|
||||||
|
|
||||||
|
static func from_dict(d: Dictionary) -> Model:
|
||||||
|
var out := Model.new()
|
||||||
|
out.id = int(d.get("id", 0))
|
||||||
|
out.name = str(d.get("name", ""))
|
||||||
|
out.format = str(d.get("format", ""))
|
||||||
|
out.vrm_version = CatalogTypes._nullable_string(d.get("vrmVersion"))
|
||||||
|
out.is_primary = bool(d.get("isPrimary", false))
|
||||||
|
out.file_url = CatalogTypes._nullable_string(d.get("fileUrl"))
|
||||||
|
out.file_size = int(d.get("fileSize", 0) if d.get("fileSize") != null else 0)
|
||||||
|
out.thumbnail_url = CatalogTypes._nullable_string(d.get("thumbnailUrl"))
|
||||||
|
out.source = SourceInfo.from_dict(d)
|
||||||
|
out.status = str(d.get("status", ""))
|
||||||
|
return out
|
||||||
|
|
||||||
|
func is_vrm() -> bool:
|
||||||
|
return format == "vrm"
|
||||||
|
|
||||||
|
|
||||||
|
## 契约 §6:客户端一次性拉取的目录
|
||||||
|
class CatalogBundle extends RefCounted:
|
||||||
|
var songs: Array = []
|
||||||
|
var motions: Array = []
|
||||||
|
var models: Array = []
|
||||||
|
var default_model_id: int = -1
|
||||||
|
var contract_version: Dictionary = {}
|
||||||
|
var generated_at: String = ""
|
||||||
|
|
||||||
|
static func from_dict(d: Dictionary) -> CatalogBundle:
|
||||||
|
var out := CatalogBundle.new()
|
||||||
|
for item in d.get("songs", []):
|
||||||
|
out.songs.append(Song.from_dict(item))
|
||||||
|
for item in d.get("motions", []):
|
||||||
|
out.motions.append(Motion.from_dict(item))
|
||||||
|
for item in d.get("models", []):
|
||||||
|
out.models.append(Model.from_dict(item))
|
||||||
|
var default_id: Variant = d.get("defaultModelId")
|
||||||
|
out.default_model_id = int(default_id) if default_id != null else -1
|
||||||
|
out.contract_version = d.get("contractVersion", {})
|
||||||
|
out.generated_at = str(d.get("generatedAt", ""))
|
||||||
|
return out
|
||||||
|
|
||||||
|
func find_song(id: int) -> Song:
|
||||||
|
for song in songs:
|
||||||
|
if song.id == id:
|
||||||
|
return song
|
||||||
|
return null
|
||||||
|
|
||||||
|
func find_motion(id: int) -> Motion:
|
||||||
|
for motion in motions:
|
||||||
|
if motion.id == id:
|
||||||
|
return motion
|
||||||
|
return null
|
||||||
|
|
||||||
|
func find_model(id: int) -> Model:
|
||||||
|
for model in models:
|
||||||
|
if model.id == id:
|
||||||
|
return model
|
||||||
|
return null
|
||||||
|
|
||||||
|
func motions_for_song(song_id: int) -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
for motion in motions:
|
||||||
|
if motion.song_id == song_id:
|
||||||
|
out.append(motion)
|
||||||
|
return out
|
||||||
|
|
||||||
|
## 契约 §4.1:默认模型由服务端解析好,客户端直接用 defaultModelId
|
||||||
|
func default_model() -> Model:
|
||||||
|
var model := find_model(default_model_id)
|
||||||
|
if model != null:
|
||||||
|
return model
|
||||||
|
for candidate in models:
|
||||||
|
if candidate.is_vrm():
|
||||||
|
return candidate
|
||||||
|
return models[0] if models.size() > 0 else null
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 解析辅助 ----------------
|
||||||
|
|
||||||
|
static func _strings(value: Variant) -> PackedStringArray:
|
||||||
|
var out := PackedStringArray()
|
||||||
|
if typeof(value) == TYPE_ARRAY:
|
||||||
|
for item in value:
|
||||||
|
out.append(str(item))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
static func _nullable_string(value: Variant) -> String:
|
||||||
|
if value == null:
|
||||||
|
return ""
|
||||||
|
return str(value)
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
class_name KeypointSequence
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
## 参考关键点序列 —— 契约 contracts/model-formats.md §2.1
|
||||||
|
##
|
||||||
|
## 服务端 T-WEB-006 预处理产出的 NDJSON,每行一帧:
|
||||||
|
## {"frame":0,"timeMs":0.0,"points":[[x,y,z,conf] × 33],"angles":[9 个余弦]}
|
||||||
|
##
|
||||||
|
## 本类把它解析为与实时链路**同构**的 PoseFeatures.FeatureVector 序列,
|
||||||
|
## 使 DTW 引擎只有一条输入通路(契约 skeleton-topology.md §5 的设计意图)。
|
||||||
|
|
||||||
|
const KeypointFrame = preload("res://src/net/keypoint_frame.gd")
|
||||||
|
const PoseFeatures = preload("res://src/score/pose_features.gd")
|
||||||
|
|
||||||
|
## 服务端产出的 keypointFormat 标识,必须完全匹配
|
||||||
|
const EXPECTED_FORMAT := "mediapipe33@0.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
class ParsedSequence extends RefCounted:
|
||||||
|
## Array[KeypointFrame.PoseFrame]
|
||||||
|
var frames: Array = []
|
||||||
|
## Array[PoseFeatures.FeatureVector]
|
||||||
|
var features: Array = []
|
||||||
|
## Array[PackedFloat32Array],服务端算好的 9 个关节角(用于交叉校验)
|
||||||
|
var server_angles: Array = []
|
||||||
|
var times_ms: PackedFloat32Array = PackedFloat32Array()
|
||||||
|
var skipped_lines: int = 0
|
||||||
|
|
||||||
|
func frame_count() -> int:
|
||||||
|
return frames.size()
|
||||||
|
|
||||||
|
func duration_ms() -> float:
|
||||||
|
if times_ms.is_empty():
|
||||||
|
return 0.0
|
||||||
|
return times_ms[times_ms.size() - 1]
|
||||||
|
|
||||||
|
## 依据时间戳推导源帧率
|
||||||
|
func estimated_fps() -> float:
|
||||||
|
if times_ms.size() < 2:
|
||||||
|
return 30.0
|
||||||
|
var span := times_ms[times_ms.size() - 1] - times_ms[0]
|
||||||
|
if span <= 0.0:
|
||||||
|
return 30.0
|
||||||
|
return float(times_ms.size() - 1) / (span / 1000.0)
|
||||||
|
|
||||||
|
|
||||||
|
## 解析 NDJSON;失败时返回 frames 为空的序列,并把原因写入 error
|
||||||
|
static func parse_ndjson(text: String) -> ParsedSequence:
|
||||||
|
var out := ParsedSequence.new()
|
||||||
|
if text.strip_edges() == "":
|
||||||
|
return out
|
||||||
|
|
||||||
|
var line_number := 0
|
||||||
|
for raw_line in text.split("\n"):
|
||||||
|
line_number += 1
|
||||||
|
var line := raw_line.strip_edges()
|
||||||
|
if line == "":
|
||||||
|
continue
|
||||||
|
|
||||||
|
var parsed: Variant = JSON.parse_string(line)
|
||||||
|
if typeof(parsed) != TYPE_DICTIONARY:
|
||||||
|
out.skipped_lines += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
var frame := _frame_from_dict(parsed)
|
||||||
|
if frame == null:
|
||||||
|
out.skipped_lines += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
out.frames.append(frame)
|
||||||
|
out.features.append(PoseFeatures.build(frame))
|
||||||
|
out.server_angles.append(_angles_from_dict(parsed))
|
||||||
|
out.times_ms.append(float(parsed.get("timeMs", 0.0)))
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
static func _frame_from_dict(data: Dictionary) -> KeypointFrame.PoseFrame:
|
||||||
|
var raw_points: Array = data.get("points", [])
|
||||||
|
if raw_points.size() != KeypointFrame.KEYPOINT_COUNT:
|
||||||
|
return null
|
||||||
|
|
||||||
|
var frame := KeypointFrame.PoseFrame.new()
|
||||||
|
frame.frame_id = int(data.get("frame", 0))
|
||||||
|
frame.points.resize(KeypointFrame.KEYPOINT_COUNT)
|
||||||
|
frame.confidence.resize(KeypointFrame.KEYPOINT_COUNT)
|
||||||
|
|
||||||
|
for i in KeypointFrame.KEYPOINT_COUNT:
|
||||||
|
var entry: Variant = raw_points[i]
|
||||||
|
if typeof(entry) != TYPE_ARRAY or (entry as Array).size() != 4:
|
||||||
|
return null
|
||||||
|
var point: Array = entry
|
||||||
|
frame.points[i] = Vector3(float(point[0]), float(point[1]), float(point[2]))
|
||||||
|
frame.confidence[i] = float(point[3])
|
||||||
|
|
||||||
|
return frame
|
||||||
|
|
||||||
|
|
||||||
|
static func _angles_from_dict(data: Dictionary) -> PackedFloat32Array:
|
||||||
|
var out := PackedFloat32Array()
|
||||||
|
var raw: Variant = data.get("angles", [])
|
||||||
|
if typeof(raw) == TYPE_ARRAY:
|
||||||
|
for value in raw:
|
||||||
|
out.append(float(value))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
## 校验服务端 angles 与本端 PoseFeatures 计算结果是否一致(跨实现交叉校验)
|
||||||
|
## 返回 { max_delta, mismatched_frames, checked }
|
||||||
|
static func compare_angles(sequence: ParsedSequence, tolerance: float = 1e-3) -> Dictionary:
|
||||||
|
var max_delta := 0.0
|
||||||
|
var mismatched := 0
|
||||||
|
var checked := 0
|
||||||
|
for i in sequence.features.size():
|
||||||
|
var server: PackedFloat32Array = sequence.server_angles[i]
|
||||||
|
if server.size() != PoseFeatures.ANGLE_COUNT:
|
||||||
|
continue
|
||||||
|
var local: PoseFeatures.FeatureVector = sequence.features[i]
|
||||||
|
var frame_ok := true
|
||||||
|
for a in PoseFeatures.ANGLE_COUNT:
|
||||||
|
var local_value: float = local.values[PoseFeatures.KEYPOINT_COUNT * 3 + a]
|
||||||
|
var delta := absf(local_value - server[a])
|
||||||
|
max_delta = maxf(max_delta, delta)
|
||||||
|
if delta > tolerance:
|
||||||
|
frame_ok = false
|
||||||
|
checked += 1
|
||||||
|
if not frame_ok:
|
||||||
|
mismatched += 1
|
||||||
|
return {"max_delta": max_delta, "mismatched_frames": mismatched, "checked": checked}
|
||||||
Reference in New Issue
Block a user