update 3d scafold

This commit is contained in:
ykcheungab
2026-09-14 13:55:36 +08:00
parent 4a482e579f
commit c10e579371
3 changed files with 792 additions and 0 deletions
+367
View File
@@ -0,0 +1,367 @@
"""Studio 3D v1 design contracts, 2026-09-14.
Specification only: split into the proposed modules when implementing.
No rendering, asset loading, decryption or SDK changes are implemented here.
All published NumPy arrays must be C-contiguous and read-only (frozen dataclasses
alone do not freeze ndarray contents). Shapes are contractual, not type-checked
by NDArray. See studio-3d-design.md for threading and validation requirements.
"""
from dataclasses import dataclass
from pathlib import Path
from typing import Generic, Literal, Mapping, Protocol, TypeAlias, TypeVar
from concurrent.futures import Executor
import numpy as np
from numpy.typing import NDArray
from PyQt5.QtCore import QEvent, pyqtBoundSignal
from PyQt5.QtGui import QMouseEvent, QOpenGLContext, QWheelEvent
from PyQt5.QtWidgets import QOpenGLWidget, QWidget
from orisys_app_shared.acquisition.models import SdkPacket
from orisys.finger.runtime import FingerRuntime
F32: TypeAlias = NDArray[np.float32]
F64: TypeAlias = NDArray[np.float64]
I32: TypeAlias = NDArray[np.int32]
U32: TypeAlias = NDArray[np.uint32]
Mask: TypeAlias = NDArray[np.bool_]
Role: TypeAlias = Literal['sensing', 'housing', 'reference']
Quality: TypeAlias = Literal['calibrated', 'approximate', 'display_only']
Profile: TypeAlias = Literal['desktop33', 'gles30']
OfferResult: TypeAlias = Literal['accepted', 'replaced', 'stale', 'disabled', 'invalid']
SceneState: TypeAlias = Literal['disabled', 'loading', 'ready', 'live', 'no_data', 'suspended', 'unavailable']
@dataclass(frozen=True)
class Diagnostic:
code: str # Stable machine-readable code, e.g. geometry_mismatch.
severity: Literal['info', 'warning', 'error']
message: str # Human-readable, never contains keys or decrypted asset data.
details: Mapping[str, str]
class ThreeDError(Exception):
"""Boundary exception; GUI converts to diagnostic and keeps 2D operational."""
diagnostic: Diagnostic
def __init__(self, diagnostic: Diagnostic) -> None: ...
@dataclass(frozen=True)
class FrameIdentity:
source_id: str # Live stream UUID or recording UUID; not just a filename.
session_id: int
frame_id: int
t_rel_s: float # Acquisition-relative monotonic time, never wall-clock for ordering.
generation: int # Increment on activation/source/geometry change.
request_id: int # Replay cursor request; 0 for ordinary live frames.
@dataclass(frozen=True)
class FieldDescriptor:
quantity: str # Examples: flow_magnitude, signed_normal_displacement, depth_response.
unit: str # px, m, or explicit algorithm-response unit; never inferred from shape.
quality: Quality
signed: bool
calibration_id: str | None
@dataclass(frozen=True)
class SurfaceFieldFrame:
identity: FrameIdentity
geometry_id: str
field_domain_id: str # Includes geometry, UV shape, crop/scale/flip convention.
scalar: F32 # [H,W]; measured/displayed quantity, not RGB.
scalar_descriptor: FieldDescriptor
valid: Mask # [H,W]; false or non-finite scalar means unavailable, not zero.
displacement_sensor_m: F32 | None # [H,W,3]; base displacement BEFORE display gain.
displacement_quality: Quality | None
diagnostic: Diagnostic | None
@dataclass(frozen=True)
class Material:
base_color_linear: tuple[float, float, float, float]
shading: Literal['unlit_scalar', 'diffuse']
double_sided: bool
@dataclass(frozen=True)
class MeshPart:
part_id: str
role: Role
positions_sensor_m: F32 # [N,3], baked coordinates.
normals_sensor: F32 # [N,3], finite unit normals.
triangles: U32 # [M,3], in [0,N).
material: Material
lod_id: str
@dataclass(frozen=True)
class SurfaceBinding:
part_id: str # The sensing part only.
geometry_id: str
field_domain_id: str
shape_hw: tuple[int, int]
sample_indices: I32 # [N,4], row-major y*W+x; unused index=-1 with weight=0.
sample_weights: F32 # [N,4], nonnegative, used weights sum to one.
@dataclass(frozen=True)
class CameraPreset:
name: str
eye_sensor_m: tuple[float, float, float]
target_sensor_m: tuple[float, float, float]
up_sensor: tuple[float, float, float]
vertical_fov_rad: float
@dataclass(frozen=True)
class ExpectedGeometry:
sensor_model: str
hardware_revision: str
geometry_id: str
field_domain_id: str
shape_hw: tuple[int, int]
@dataclass(frozen=True)
class SceneAssets:
asset_id: str
asset_version: str
asset_hash: str # Digest of canonical manifest/content, not encryption nonce.
geometry_id: str
parts: tuple[MeshPart, ...]
binding: SurfaceBinding # v1 supports exactly one sensing part.
camera_presets: tuple[CameraPreset, ...]
bounds_sensor_m: F32 # [2,3]: min, max.
@dataclass(frozen=True)
class ColorScale:
descriptor: FieldDescriptor
lower: float # Finite; lower < upper, in descriptor.unit.
upper: float
lut_id: str # Approved sequential/diverging LUT.
mode: Literal['fixed', 'automatic']
@dataclass(frozen=True)
class DisplaySettings:
display_gain: float # Finite positive; affects only dynamic positions.
deform_enabled: bool
color_scale: ColorScale
housing_visible: bool
max_fps: int # Display limit; never forwarded as camera/recording FPS.
@dataclass(frozen=True)
class VertexFields:
scalar: F32 # [N], unscaled scientific value.
valid: Mask # [N]; any positive-weight invalid sample invalidates the vertex.
displacement_sensor_m: F32 | None # [N,3].
@dataclass(frozen=True)
class RenderFrame:
identity: FrameIdentity
asset_hash: str
geometry_id: str
sensing_part_id: str
positions_sensor_m: F32 # [N,3], display displacement already applied.
normals_sensor: F32 # [N,3], valid even at vertices excluded by mask.
scalar: F32 # [N], BEFORE display gain.
valid: Mask # [N].
scalar_descriptor: FieldDescriptor
diagnostics: tuple[Diagnostic, ...]
@dataclass(frozen=True)
class CameraMatrices:
view: F32 # [4,4]; p_camera = view @ p_sensor.
projection: F32 # [4,4]; p_clip = projection @ p_camera.
@dataclass(frozen=True)
class GLCapabilities:
profile: Profile
vendor: str
renderer: str
gl_version: str
glsl_version: str
qt_platform: str
software_renderer: bool
max_texture_size: int
diagnostics: tuple[Diagnostic, ...]
@dataclass(frozen=True)
class RenderStats:
displayed_frames: int
superseded_frames: int
compose_ms: float
upload_ms: float # CPU submission time unless a separate GPU timer is used.
paint_ms: float
estimated_gpu_bytes: int
class KeyProvider(Protocol):
def get_content_key(self, key_id: str) -> bytes:
"""Return authorized content key, or raise ThreeDError; never log it."""
...
class AssetLoader(Protocol):
def load(self, path: Path, expected: ExpectedGeometry, keys: KeyProvider | None = None) -> SceneAssets:
"""CPU/IO thread. Authenticate and validate before publishing assets."""
...
def validate(self, assets: SceneAssets, expected: ExpectedGeometry) -> tuple[Diagnostic, ...]: ...
class LegacyFingerImporter(Protocol):
def convert(self, export_dir: Path, map_path: Path, *,
transform_sensor_from_source: F64, meters_per_source_unit: float) -> SceneAssets:
"""Offline. Scale source lengths to meters, then apply a rigid transform.
transform translation is meters; its rotation contains no extra scale.
"""
...
@dataclass(frozen=True)
class CompileOptions:
asset_id: str
version: str
expected: ExpectedGeometry
transform_sensor_from_glb: F64 # [4,4]; GLB lengths are already meters.
max_surface_vertices: int
max_housing_triangles: int
signing_key_reference: str # Build environment reference, not private key bytes.
encryption_key_id: str | None
@dataclass(frozen=True)
class CompileReport:
output: Path
asset_hash: str
diagnostics: tuple[Diagnostic, ...]
part_vertex_counts: Mapping[str, int]
part_triangle_counts: Mapping[str, int]
class SceneCompiler(Protocol):
def compile(self, surface: SceneAssets, housing_glb: Path | None, output: Path,
options: CompileOptions) -> CompileReport: ...
class FingerFrameAdapter(Protocol):
def snapshot(self, packet: SdkPacket, runtime: FingerRuntime,
identity: FrameIdentity) -> SurfaceFieldFrame | None:
"""Acquisition thread only. Copy/own one frame; no GL and no GUI calls."""
...
class BoxFrameAdapter(Protocol):
def snapshot(self, packet: SdkPacket, identity: FrameIdentity,
domain: ExpectedGeometry) -> SurfaceFieldFrame | None: ...
class SurfaceSampler(Protocol):
def sample(self, frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields: ...
class SceneComposer(Protocol):
def compose(self, assets: SceneAssets, frame: SurfaceFieldFrame,
settings: DisplaySettings) -> RenderFrame:
"""CPU worker. Validate identity/units, sample, deform, update normals."""
...
def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
"""Static reference; all measurement validity false, scalar unavailable."""
...
T = TypeVar('T')
class LatestFrameMailbox(Generic[T]):
def __init__(self) -> None: ...
def offer(self, frame: T) -> OfferResult:
"""Thread-safe capacity-one replacement, constant-time under the lock."""
...
def take(self) -> T | None: ...
def clear(self) -> None: ...
@property
def superseded_frames(self) -> int: ...
class OrbitCamera(Protocol):
def fit(self, bounds_sensor_m: F32, aspect: float) -> None: ...
def orbit(self, delta_px: tuple[float, float], viewport: tuple[int, int]) -> None: ...
def pan(self, delta_px: tuple[float, float], viewport: tuple[int, int]) -> None: ...
def zoom(self, wheel_steps: float) -> None: ...
def set_preset(self, preset: CameraPreset) -> None: ...
def matrices(self, viewport: tuple[int, int]) -> CameraMatrices: ...
def configure_surface_format(profile: Profile) -> None:
"""Before QApplication and before importing a platform-specific GL binding."""
...
class GLDispatch(Protocol):
def bind_current_context(self, context: QOpenGLContext) -> GLCapabilities:
"""GUI thread; Qt owns the current context and native surface."""
...
class GLRenderer(Protocol):
def initialize(self, capabilities: GLCapabilities) -> None: ...
def upload_scene(self, assets: SceneAssets) -> None: ...
def update_surface(self, frame: RenderFrame) -> None: ...
def draw(self, camera: CameraMatrices, settings: DisplaySettings,
framebuffer: int, viewport: tuple[int, int]) -> RenderStats: ...
def release(self) -> None:
"""Idempotent; correct owning context MUST be current."""
...
class Sensor3DWidget(QOpenGLWidget):
capabilitiesChanged: pyqtBoundSignal # Payload: GLCapabilities.
diagnosticRaised: pyqtBoundSignal # Payload: Diagnostic.
renderStatsChanged: pyqtBoundSignal # Payload: RenderStats; rate-limited.
def __init__(self, renderer: GLRenderer, camera: OrbitCamera,
parent: QWidget | None = None) -> None: ...
def set_scene(self, assets: SceneAssets) -> None:
"""GUI: retain CPU data, schedule upload when context is current."""
...
def present(self, frame: RenderFrame) -> None: ...
def set_settings(self, settings: DisplaySettings) -> None: ...
def initializeGL(self) -> None: ...
def paintGL(self) -> None: ...
def resizeGL(self, w: int, h: int) -> None: ...
def release_resources(self) -> None: ...
def mousePressEvent(self, event: QMouseEvent) -> None: ...
def mouseMoveEvent(self, event: QMouseEvent) -> None: ...
def wheelEvent(self, event: QWheelEvent) -> None: ...
def hideEvent(self, event: QEvent) -> None: ...
def showEvent(self, event: QEvent) -> None: ...
class ThreeDCoordinator:
def __init__(self, widget: Sensor3DWidget, composer: SceneComposer,
executor: Executor, max_fps: int = 30) -> None: ...
def activate(self, assets: SceneAssets, source_id: str) -> int: ...
def submit(self, frame: SurfaceFieldFrame) -> OfferResult: ...
def tick(self) -> None: ...
def set_visible(self, visible: bool) -> None: ...
def set_settings(self, settings: DisplaySettings) -> None: ...
def close(self) -> None: ...
@property
def state(self) -> SceneState: ...
@property
def generation(self) -> int: ...
class ThreeDPanel(QWidget):
"""UI-only controls; no asset IO, SDK reads, or direct GL calls."""
def __init__(self, coordinator: ThreeDCoordinator, widget: Sensor3DWidget,
parent: QWidget | None = None) -> None: ...
def set_diagnostic(self, diagnostic: Diagnostic | None) -> None: ...
def set_state(self, state: SceneState) -> None: ...
def set_render_stats(self, stats: RenderStats) -> None: ...
+425
View File
@@ -0,0 +1,425 @@
# Studio 三维显示架构与实施规范
版本:1.0 提案;日期:2026-09-14。目标读者:Studio、SDK、Blender 资产、ARM 平台和测试工程师。
本文的“必须”是接口正确性要求,“建议”是第一版实现选择,“待验证”不是已获硬件验收。本文配套 `studio-3d-contracts.pyi` 是接口骨架,不是可运行的三维功能。
## 1. 决策与实施范围
**推荐现有 PyQt5 的 QOpenGLWidget + PyOpenGL,使用现代着色器和 GPU 缓冲区;Open3D 保留为离线几何处理和开发验证工具。** 不为三维显示更换整个 Studio UI 技术栈。
目标为 Jetson Orin Nano、QCS6490、Ubuntu。目前没有两块实际部署板的 Ubuntu/BSP、Qt 构建方式、窗口系统和驱动版本;这不阻碍接口设计,但禁止承诺“装好 pip 包即可兼容 ARM”。
| 方案 | 适合的职责 | 对本项目的判断 |
|---|---|---|
| QOpenGLWidget + PyOpenGL | 嵌入现有 Qt、动态网格、固定色标、分层渲染 | 首选;自己维护相机、shader、资源生命周期,工作量可控 |
| Open3D | 点云、配准、网格处理、离线算法验证 | 不作为 Studio 运行时必需依赖;其 GUI 与 ARM 图形栈有额外限制 |
| Qt 自身 OpenGL 封装 | Qt 上下文与资源管理;可作为 GL 调用适配实现 | 若 PyOpenGL 与目标平台 EGL 函数加载不匹配,在 GLDispatch 内替换绑定;需验证实际 PyQt5 暴露的接口 |
| 更换完整场景引擎/Qt 大版本 | 重型场景、复杂材质、动画 | 第一版需求不足以抵消迁移成本,暂不做 |
Open3D 官方将 ARM64 支持标为实验性;其支持矩阵区分 Linux 桌面 OpenGL 与 OpenGL ES,后者不支持其 Visualizer/GUI。新渲染模块的文档另要求 OpenGL 4.1。不能把 Jetson 上的运行结果等同于 QCS6490,也不能把 Python 包可安装当作 GUI 可用。[Open3D ARM 支持](https://www.open3d.org/docs/release/arm.html)、[渲染要求](https://www.open3d.org/docs/release/tutorial/visualization/cpu_rendering.html)
第一版包括:静态完整外观、动态感知表面、旋转/缩放/平移/复位/预设视角、固定或手动色标、图例、显示倍率、外壳显隐、性能与不可用提示。透明外壳、阴影、PBR、多重后处理、局部矢量和三维拾取列为后续项。
## 2. 当前代码与资产事实
代码检查基于当前工作区;审查期间 app HEAD 已更新为 `0d8b79b`,另有滤波相关未提交修改。本提案不覆盖这些修改,不要求恢复已经删除的旧 `record/depth.py`。
| 现有文件/类 | 当前内容 | 复用方式 |
|---|---|---|
| `orisys-sdk/src/orisys/finger/exports.py` / `FingerExportPaths`、`load_finger_export_bundle()` | 寻找并加载 Blender 的网格、内参、位姿 | 保持 SDK 兼容;不要将显示外壳塞进算法网格 |
| `orisys-sdk/src/orisys/unroll/precompute.py` / `load_exported_data()` | 加载 vertices/faces/逐角 UV/K/world_to_cam | 离线导入与旧资产适配 |
| `orisys-sdk/src/orisys/finger/mesh.py` / `build_uv_surface_mesh()` | 从 UV `world_xyz` 构建表面,保存像素采样位置 | 复用几何算法;第一版在加载阶段构建,不每帧重新建拓扑 |
| 同文件 `sample_field_at_mesh_vertices()`、`lift_uv_flow_to_3d()` | 逐顶点采样与光流切向映射 | 封装在 Finger 适配器,不依赖 Open3D viewer |
| 同文件 `lift_uv_deformation_to_3d()`、`deform_mesh_vertices()` | 相对形变示意;存在 normal_gain、近似 pixel_to_world 与显示倍率 | 只能在明确标记 approximate 的通道使用 |
| `orisys-sdk/src/orisys/finger/viewer.py` | Open3D 网格创建与更新 | 留给 SDK 示例,Studio 不导入 |
| `orisys-sdk/examples/viz/mesh_camera.py` | 观察相机;含额外 X/Z 旋转 | 作为迁移对照;不能将旋转常量散落到 Studio shader |
| `orisys-app/shared/src/orisys_app_shared/acquisition/models.py` / `SdkPacket` | flow、depth、valid_mask、会话/帧身份 | 新增一个可选三维字段或独立显示订阅,避免多个散乱字段 |
| `orisys-app/apps/studio/src/orisys_studio/replay.py` | 当前回放视频和力,不保存/直接重算深度;重算属于采集入口 | 普通历史回放不能假装拥有三维测量数据 |
实际读取资产结构(`np.load(..., allow_pickle=False)`):
| 资产目录 | 内容 | 实际结构 |
|---|---|---|
| `sensors/finger/assets/30fps-0703` | `mesh.npz` | vertices float32[1032,3];faces int32[1974,3];uv_data float32[5922,2];loop_to_vertex int32[5922] |
| 同目录 | `camera_intrinsics.npz` / `world_to_cam.npy` | K float64[3,3];宽高 int32 标量;位姿 float32[4,4] |
| 同目录 | `pixel_to_xyz.npz` | xyz_map float32[720,1280,3] |
| 同目录 | `unroll_map_uv_s0.4.npz` | map_x/map_y/valid_mask 为 [300,280];world_xyz 为 [300,280,3];含 source_scale、flip_v、projection_K |
| `sensors/finger/assets/visualization` | `mesh.npz` | vertices[1005,3]、faces[1921,3],与运行网格不是同一个拓扑 |
| 同目录 | `pixel_to_xyz.npz` / `flatten_remap.npz` | xyz_map[500,500,3];展平映射及 flat_xyz[1024,1024,3] |
**不能把 1032 个导出顶点当作最终实时渲染顶点数。** 当前 UV 网格函数是每个有效 UV 像素生成一个顶点,300×280 的网格在全有效时上限为 84,000 顶点;实际数量需要按 valid_mask 统计。两个资产目录也不能按数组下标直接拼接。
`uv_data` 为每个面角的数据,不能当作每个顶点的一份 UV。UV 接缝处若一个几何顶点拥有多个 UV,显示导出必须拆成多个 render vertex,并保留采样映射;不得强行平均 UV。
## 3. 坐标、单位与测量含义
### 3.1 坐标契约
新增场景的公共坐标定义为 `sensor`:右手系、长度单位米。X/Y/Z 的物理方向由资产 manifest 明确描述,并与力轴约定核对;现有 Blender 文件不能在没有证据时自动认定为米或自动认定 +Z 为传感器长轴。
统一记号 `T_A_from_B`:列向量满足 `p_A = T_A_from_B @ p_B`。平移在最后一列。JSON 矩阵按行序列化;上传 shader 时显式按列主序打包,GLES 使用 transpose=false,不让 NumPy 存储布局决定坐标含义。
转换分成三种:
1. **算法相机**:原始 `K`、畸变、`world_to_cam`、裁剪/缩放/flip_v,服务像素到曲面的映射,保留原有精度和版本。
2. **资产坐标**:Blender/glTF/旧 NPZ 到 sensor 的转换。离线发布时一次性烘焙到网格、法线和映射;运行时不再重复应用同一转换。
3. **观察相机**:用户 orbit/pan/zoom,仅改变 view/projection,不改变算法输入、标定和力轴。默认观察相机是显示资产的一部分,不自动等同于真实摄像头。
glTF 使用右手系、Y 向上、长度米;仍需显式转换到本项目的 sensor 轴约定。负缩放或坐标翻转要同步修正三角形绕序;法线使用逆转置,不能直接乘含非均匀缩放的模型矩阵。[glTF 坐标与变换规范](https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#coordinate-system-and-units)
### 3.2 “形变”不是天然的毫米
当前 `lift_uv_deformation_to_3d()` 对 vnormal 求模,再乘法线;这不能保留一个任意有符号法向位移的含义。`mesh_uv_to_world_scale()` 是由网格跨度估计每 UV 像素对应的世界尺度,不是经过验证的位移标定。
因此帧契约必须分开:
- `scalar`:着色量,带 `quantity`、`unit`、`signed`、`quality`;如 `flow_magnitude / px / false / approximate`。
- `displacement_sensor_m`:若提供,必须说明是 calibrated、approximate 还是仅 display_only;approximate 即使以米存储,也不能以毫米测量值向用户呈现。
- `display_gain`:只作用于显示顶点 `p_display = p_rest + gain * displacement`,不得写回原始记录、改动标定或用放大后的值计算色标。
- 没有可靠位移时,默认只在静态曲面着色。用户启用示意形变时显示“相对形变示意 ×N”,不得把 FN/FX/FY 总力直接当作每个顶点高度。
跨帧比较默认固定色标;有符号量使用围绕零的发散色图,非负量使用顺序色图。无效点显示中性颜色或遮罩,不当零值,超范围值夹取颜色并保留超范围状态。
## 4. 感知表面与非感知结构怎样分开
建议一个场景、两个独立 draw part,不把整机合成一个动态网格:
| part_id / role | 几何来源 | 每帧更新 | 显示规则 |
|---|---|---|---|
| `sensing_surface` / sensing | 真实感知曲面或绑定其采样映射的显示 LOD | 顶点、必要时法线、scalar、valid | 科学色图;参与传感色标和数据查询 |
| `housing` / housing | Blender 简化外壳 | 无;VBO/EBO 只上传一次 | 灰/石墨材质;不参加测量色标和峰值统计 |
| `reference_axes` / reference | 显示辅助几何 | 相机变化时更新视图 | 用户可隐藏;不伪装成测量 |
两部分都转换到 sensor 坐标,分别持有索引和材质。感知面不与外壳共用可变顶点缓冲。Blender 推荐两个明确命名的 Collection/Object,导出器按角色读取,不以颜色猜角色。
第一版外壳使用不透明材质和简单漫反射;感知面采用保留色值的着色,光照不能让同一数值在不同朝向看起来像不同数值。先保留“外壳显示/隐藏”,透明外壳后做:它涉及排序和深度写入,不能只把 alpha 调小。
衔接原则:
- 模型接缝由美术在统一坐标中设计;外壳不复制一层完全重叠的感知面,避免 z-fighting。
- 若显示放大产生明显穿插,可提供“示意形变”倍率上限或仅着色模式;不能为美观强行锁定测量边界并改写数据。
- 外壳 LOD 可独立简化。感知面简化必须同时生成新的采样绑定,不能保留旧 vertex_id/UV 表后换拓扑。
- 拾取后续如加入,返回 part_id;点到 housing 时显示“非感知结构”,不能返回一个虚构力值。
## 5. 文件格式与复制保护
### 5.1 三层资产
| 层 | 推荐格式 | 发布策略 |
|---|---|---|
| 内部源文件 | `.blend`、高精度机械模型、原始相机标定 | 留在受控资产仓库;不随客户程序分发 |
| 制作交换 | 外观采用 `.glb`;算法几何/映射保留现有 NPZ/NPY | 用于美术与导出工具;GLB 是标准交换格式,不是保密格式 |
| Studio 运行包 | 自定义扩展名 `.oryscene`,内部为签名 manifest + 数值数组块;可选加密 | 渲染器仅接收已解包、已验证的 SceneAssets,不感知文件格式 |
第一版无需在 ARM 运行时实现完整 glTF/PBR 引擎:离线 exporter 将 GLB 的必要节点、变换、法线和基础材质编译成紧凑数组;运行时只支持本规范的固定 mesh/material 子集。GLB 动画、骨骼、任意扩展、Draco 等默认不进入第一版。
既有 SDK NPZ/NPY 路径继续兼容。若要加密算法资产,应另行给 SDK 增加只读 AssetProvider/内存流入口;仅加密 Studio 包而继续分发相同明文算法网格,不应宣称那些几何已得到保护。
### 5.2 明确保护目标
用户确认目标为**提高逆向和复制成本**,不是保证客户端无法提取模型。
建议依次落实:
1. **最小交付**:仅发布简化外壳与需要的感知几何;去掉内部机械结构、隐藏对象、原始命名、无用相机、设计历史和高精度源资产。
2. **完整性和来源**:构建机签名发布包,客户端只带验签公钥;manifest 的版本、资源角色、大小、哈希和兼容信息都纳入签名。SHA-256 只能检查内容,不等同于防篡改签名。
3. **可选加密**:使用成熟库的认证加密,例如 AES-GCM;同一密钥下每次包加密的 nonce 必须唯一,元数据作为关联数据绑定。不要自制 XOR、把密码藏在扩展名或把 ZIP 当加密。[认证加密接口及 nonce 约束](https://cryptography.io/en/latest/hazmat/primitives/aead/#cryptography.hazmat.primitives.ciphers.aead.AESGCM)
4. **密钥隔离**:签名私钥不能进入客户软件;内容密钥经 KeyProvider 获取,可按产品/客户授权。若需要离线,明确离线授权/缓存失效流程。Python 内硬编码全局密钥只能提供很弱的阻力;原生加载器、设备密钥保护可提高成本但不能防运行时捕获。
5. **解密范围**:尽量内存解密、不输出明文临时文件、日志不打印密钥;Python/驱动内存副本不能承诺被完全擦除。
即使加密,渲染最终仍需在 CPU/GPU 获得几何。若某结构绝不能泄露,就不要交付该结构,改成视觉代理模型。包格式和加密不能代替这一决策。
### 5.3 `.oryscene` v1 逻辑内容
建议封装采用有大小上限的 manifest 与数组条目,不执行脚本、不使用 pickle。压缩是容量优化,不属于安全机制。读取器拒绝路径穿越、未知必须扩展、异常尺寸、越界索引、非有限坐标及解压超限。
```text
envelope.json # magic、envelope_version、key_id、nonce、签名信息
payload # 可选认证加密;解开后是下列逻辑内容
manifest.json
sensing/positions.npy # float32[N,3],sensor 坐标、米
sensing/normals.npy # float32[N,3],单位向量
sensing/triangles.npy # uint32[M,3]
sensing/sample_indices.npy # int32[N,4],UV 网格展平下标
sensing/sample_weights.npy # float32[N,4],双线性权重
housing/positions.npy
housing/normals.npy
housing/triangles.npy
```
manifest 必须包含:schema_version、asset_id、asset_version、geometry_id、sensor_model、适用硬件修订范围、geometry_sha256、mapping_sha256、coordinate_frame、length_unit、源坐标到 sensor 的烘焙记录、算法网格/相机配置哈希、UV 宽高及原点方向、source_scale/crop/flip_v、各 part_id/role/LOD/材质/数组类型尺寸、所有内容哈希、默认观察相机、编译工具版本。
hash 的定义必须稳定:签名不包含自身;按规定的规范化 manifest 字节及有序条目摘要计算。明文内容哈希用于 geometry_id/兼容性,密文完整性由认证加密与签名处理,二者不能混称。
`sample_indices` 的每一行最多四个有效下标,未使用项为 -1 且权重 0;有效权重和为 1。加载后验证下标属于 manifest 声明的 UV 域。运行期若任一正权重点无效,默认将该顶点标无效,不跨遮罩补值。
编译器与加载器要共用 JSON Schema。下面仅为字段关系示例,尖括号值由编译器生成,不能作为有效发布包:
```json
{
"schema_version": "1.0",
"asset_id": "finger-display",
"asset_version": "1.0.0",
"geometry_id": "<geometry-digest>",
"coordinate_frame": "sensor",
"length_unit": "m",
"compatible": {"sensor_model": "finger", "hardware_revisions": ["<revision>"]},
"field_domain": {
"id": "<domain-digest>", "shape_hw": [300, 280],
"index_order": "row-major", "origin": "top-left",
"source_scale": 0.4, "flip_v": "<actual-value>",
"mapping_sha256": "<mapping-digest>", "camera_sha256": "<camera-digest>"
},
"parts": [
{"id": "sensing_surface", "role": "sensing", "mesh": "sensing", "binding": "uv-lod0"},
{"id": "housing", "role": "housing", "mesh": "housing"}
],
"entries": {
"sensing/positions.npy": {"dtype": "<f4", "shape": ["<N>", 3], "sha256": "<digest>"}
}
}
```
## 6. 模块边界与文件骨架
```text
orisys-app/shared/src/orisys_app_shared/scene3d/
contracts.py # DTO、枚举、形状/单位/身份校验,无 Qt/OpenGL
assets.py # AssetLoader、LegacyFingerImporter、包验证
sampling.py # SurfaceSampler:UV 场 → 顶点字段,纯 NumPy
composer.py # SceneComposer:字段 → RenderFrame,含法线/显示倍率
orisys-app/apps/studio/src/orisys_studio/scene3d/
coordinator.py # 订阅、latest-only 调度、generation/状态、错误
adapters.py # SdkPacket/FingerRuntime → SurfaceFieldFrame
gl_backend.py # GLDispatch、GLRenderer、shader、GPU 资源
camera.py # OrbitCamera;纯数学,不读取算法相机配置
widget.py # Sensor3DWidget(QOpenGLWidget);Qt 事件与上下文
panel.py # 视角/倍率/色标/外壳显隐,委托 coordinator
orisys-app/tools/scene_assets/
compile_scene.py # 离线旧 NPZ + GLB → 运行包
validate_scene.py # 数值、角色、坐标、兼容性和包完整性检查
probe_gl.py # 在实际 Qt 窗口中探测上下文并输出诊断 JSON
```
这些是建议职责,不要求第一天全部拆成独立大类。体量小时 `sampling/composer` 可同文件,shader 可与 backend 同目录;禁止把 SDK、解密、重建网格、OpenGL 和 UI 事件塞到一个 Widget。
依赖方向:
```mermaid
flowchart LR
SDK[SDK / 采集线程] --> A[FrameAdapter]
A --> Q[LatestFrameMailbox]
Q --> C[SceneComposer / CPU 工作线程]
P[AssetLoader / 已验证资产] --> C
C --> R[Latest RenderFrame]
R --> W[Sensor3DWidget / GUI线程]
W --> G[GLRenderer / 当前Qt上下文]
U[Panel / OrbitCamera] --> W
SDK --> D[原有全量记录通道]
```
三维帧丢弃只影响显示,不增加录像/CSV 丢帧数;不能给全量记录通道施加渲染背压。SDK 数学层也不能反向 import Studio 或 OpenGL。
## 7. 核心数据接口
完整签名见同目录 `studio-3d-contracts.pyi`。下面的 shape 是硬性契约;frozen dataclass 不会冻结 NumPy 数组,发布方必须复制或转移所有权并置只读。
| 类型 | 关键字段 | 不变量 |
|---|---|---|
| `FrameIdentity` | source_id、session_id、frame_id、t_rel_s、generation、request_id | source_id 区分实时/录像;generation 切换源时增加;request_id 区分回放请求 |
| `FieldDescriptor` | quantity、unit、quality、signed | 不允许没有单位和质量说明的深度/位移 |
| `SurfaceFieldFrame` | identity、geometry_id、field_domain_id、scalar[H,W]、valid[H,W]、可选 displacement[H,W,3] | 同一个采集帧、同一 UV 域、数组只读;缺失字段不能沿用前一帧 |
| `MeshPart` | part_id、role、positions[N,3]、normals[N,3]、triangles[M,3]、材质 | 米、sensor 坐标;索引小于 N;role 决定是否动态 |
| `SurfaceBinding` | sensing part_id、geometry_id、domain_id、shape_hw、indices[N,4]、weights[N,4] | 与 sensing 顶点数一致;LOD 改变必须更换绑定 |
| `SceneAssets` | asset_id/hash、geometry_id、parts、binding、camera_presets | 第一版一个 sensing part,0..多个 housing;不能把相机预设作为映射 |
| `DisplaySettings` | gain、deform_enabled、ColorScale、housing_visible、max_fps | 只影响显示;同一 scalar_descriptor 才能沿用色标 |
| `RenderFrame` | identity、asset_hash、geometry_id、动态 positions/normals/scalar/valid | 无 GPU handle;携带完整身份,用于拒绝旧结果 |
| `RenderStats` | displayed_frames、superseded_frames、compose_ms、upload_ms、paint_ms、estimated_gpu_bytes | 统计不和录制混用;CPU 提交时间不冒充真实 GPU 执行时间 |
## 8. 类与函数实施合同
### 8.1 资产加载与编译
| 类/函数 | 输入 | 输出 | 线程、错误与副作用 |
|---|---|---|---|
| `AssetLoader.load(path, expected, keys)` | 运行包路径、ExpectedGeometry、可选 KeyProvider | SceneAssets | CPU 工作线程;有文件 IO;签名/哈希/域/单位不匹配抛 ThreeDError,不能退回同名不同资产 |
| `AssetLoader.validate(assets, expected)` | 资产、期望设备/域 | tuple[Diagnostic,...] | 纯校验;error 级诊断禁止发布资产,warning 可显示 |
| `KeyProvider.get_content_key(key_id)` | 密钥标识 | bytes | 平台授权层;不得写日志;加载器不内置通用主密钥 |
| `LegacyFingerImporter.convert(export_dir, map_path, transform, units)` | 旧资产目录、运行 UV 映射、已确认的变换/尺度 | SceneAssets | 仅开发/离线;缺少坐标依据必须拒绝,不从文件名猜 |
| `SceneCompiler.compile(surface, housing_glb, output, options)` | 源资产、可选 GLB、输出、签名/打包选项 | CompileReport | 离线;校验后写临时包,再原子替换目标;私钥通过外部配置提供 |
旧 NPZ 导入器要先验证 `loop_to_vertex` 与面角顺序;LOD 导出后重新生成 binding;保持算法和显示资产各自版本,避免改外壳导致算法标定哈希变化。
`LegacyFingerImporter.convert()` 的 `meters_per_source_unit` 只负责先把源顶点长度换成米;`transform_sensor_from_source` 随后只包含刚体旋转和平移,平移单位为米,不重复含缩放。对 source 法线只应用旋转。GLB 本身使用米,其 transform 同样不重复进行单位换算。
### 8.2 字段适配、采样与合成
| 类/函数 | 输入 | 输出 | 线程与行为 |
|---|---|---|---|
| `FingerFrameAdapter.snapshot(packet, runtime, identity)` | 采集包、当前拥有的 FingerRuntime、身份 | SurfaceFieldFrame 或 None | 只能在 runtime 所在线程读其数组;没有三维订阅时返回 None;复制本帧所需数据,禁止 GUI 再追读 runtime |
| `BoxFrameAdapter.snapshot(packet, identity, domain)` | 平面数据及已确认域 | SurfaceFieldFrame 或 None | 第一版可只支持 scalar;未知空间尺度不能造毫米高度场 |
| `SurfaceSampler.sample(frame, binding)` | 只读 UV 字段、绑定 | VertexFields | CPU;按 indices/weights 向量化采样,检查域;不在每帧搜索最近三角形 |
| `SceneComposer.compose(assets, frame, settings)` | 场景、字段、显示设置 | RenderFrame | CPU 工作线程;应用位移/倍率并更新动态法线;不操作 Qt、不创建 GL handle |
| `SceneComposer.rest_frame(assets, identity)` | 场景与身份 | RenderFrame | 清除旧颜色/位移;用于刚加载、缺数据或断连后的静态参考 |
surface 原始坐标、动态位置和 scalar 分开持有。颜色映射建议在 shader 中用 scalar + LUT + 固定范围完成,避免每帧在 Python 分配大量 RGB 数组;适配器不得每帧遍历全部 Blender 面来建映射。
### 8.3 帧调度与状态
| 类/函数 | 输入 | 输出 | 线程与行为 |
|---|---|---|---|
| `LatestFrameMailbox.offer(frame)` | SurfaceFieldFrame | OfferResult | 采集线程可调用;容量 1,替换旧帧并计数,锁内只换引用 |
| `LatestFrameMailbox.take()` | 无 | frame 或 None | CPU 合成线程;取出并清空;没有帧不忙轮询 |
| `ThreeDCoordinator.activate(assets, source_id)` | 场景与数据源 | generation:int | GUI;取消旧请求、清空两个 mailbox、建立源身份 |
| `ThreeDCoordinator.submit(frame)` | 帧 | OfferResult | 任意生产线程;快速校验身份,错误/过期拒绝;不直接触发 paint |
| `ThreeDCoordinator.tick()` | 无 | None | GUI 定时器;收最新 RenderFrame,调用 widget.present(),按上限请求 update |
| `ThreeDCoordinator.set_visible(bool)` | 可见性 | None | GUI;隐藏时取消三维合成/上传/渲染任务和订阅;不停止采集/记录 |
| `ThreeDCoordinator.set_settings(settings)` | 显示设置 | None | GUI;色标/相机可独立刷新,位移倍率变化需重合成当前字段 |
| `ThreeDCoordinator.close()` | 无 | None | GUI;取消任务,停止定时器,清 mailbox,断开信号;幂等 |
状态机:Disabled → Loading → Ready → Live;缺字段为 NoData,资产/GL 失败为 Unavailable。隐藏为 Suspended,恢复时用最新帧;断连时清旧动态帧并显示参考模型/“无实时数据”。过期帧、旧 generation 和旧 request_id 不得重新覆盖新场景。
两个 mailbox 各容量 1,加一个正在计算的帧;Qt 信号只通知状态,不排队传递每帧大数组。CPU 任务不得无限提交给线程池。完整数据记录继续走原有通道。
标准失败语义:
| code | 触发 | 调用方处理 |
|---|---|---|
| `asset_auth_failed` | 签名/认证标签错误 | 拒绝资产,进入 Unavailable;不尝试明文回退 |
| `geometry_mismatch` | 场景、绑定、设备几何不一致 | 拒绝帧/资产,显示预期与实际 ID |
| `field_domain_mismatch` | H/W、scale、flip 或域不同 | 拒绝帧;不能自动 resize 后继续冒充原映射 |
| `field_unavailable` | 当前帧缺 scalar/有效数据 | NoData,清除旧测量颜色;允许参考模型 |
| `stale_frame` | generation/request_id 过期 | 安静丢弃并计数,不弹窗打断操作 |
| `gl_unsupported` | 实际上下文达不到所选 profile | Unavailable,二维照常;附 probe 信息 |
| `shader_compile_failed` | shader 编译/链接失败 | Unavailable,日志保留 shader 版本和错误信息 |
| `gpu_budget_exceeded` | 分配超过配置预算 | 显式选择更低 LOD 或拒绝三维,不无限重试 |
参考帧 `rest_frame()` 的 scalar 全部不可用,descriptor 使用 `quantity="unavailable"`、`unit="1"`、`quality="display_only"`,UI 隐藏测量色条。渲染器不得因为它与先前色标不匹配而留下旧颜色。
### 8.4 OpenGL 与 Qt
| 类/函数 | 输入 | 输出 | 限制 |
|---|---|---|---|
| `configure_surface_format(profile)` | desktop33 / gles30 | None | QApplication 创建之前;只选一个已验证平台配置 |
| `GLDispatch.bind_current_context()` | 已经 current 的 Qt context | GLCapabilities | 选择和实际 GL/ES 匹配的函数绑定;不自行创建第二套 EGL 窗口 |
| `GLRenderer.initialize(capabilities)` | 实际能力 | None | GUI/current context;编译对应 shader,失败抛 shader diagnostic |
| `GLRenderer.upload_scene(assets)` | 验证后的资产 | None | 静态 VBO/EBO/LUT 一次分配;建立预算 |
| `GLRenderer.update_surface(render_frame)` | 同资产动态帧 | None | 更新既有动态缓冲;不重建 housing/索引 |
| `GLRenderer.draw(camera, settings, framebuffer, viewport)` | 相机矩阵、设置、Qt FBO、物理像素尺寸 | RenderStats | 一次场景绘制,不读磁盘、不调用 SDK、不处理 Qt 事件循环 |
| `GLRenderer.release()` | 无 | None | 必须正确 context current;幂等;GPU 资源清零 |
| `Sensor3DWidget.initializeGL()` | Qt 调用 | None | 创建 backend,连接 context.aboutToBeDestroyed;失败进入 Unavailable |
| `Sensor3DWidget.paintGL()` | Qt 调用 | None | 验证最新帧身份,上传并绘制;无新帧只在相机/设置变化时重画 |
| `Sensor3DWidget.resizeGL(w,h)` | Qt 尺寸 | None | 校准 DPR 与实际 framebuffer 尺寸,更新投影;不缓存旧 FBO |
| `Sensor3DWidget.present(frame)` | RenderFrame | None | GUI,仅保留 CPU 帧并调用 update;不要求调用点已 current |
| `Sensor3DWidget.release_resources()` | 无 | None | makeCurrent → release → doneCurrent;销毁/重建/关闭均能安全重复 |
Qt 的 widget 使用自身 FBO,不能把默认目标绑回 0;Qt 生命周期入口已经提供 current context,其他入口需显式管理。重新挂接窗口可能导致 context 重建,要从 CPU 资产恢复 GPU 资源。清理绑定 aboutToBeDestroyed,同时在关闭时显式执行。[Qt 5.15 QOpenGLWidget](https://doc.qt.io/qt-5/qopenglwidget.html)
shader 维护两个头部变体:桌面 `#version 330 core`;ES `#version 300 es`,显式 float/int precision。共享计算逻辑,禁止 glBegin/glEnd、固定管线矩阵、GL_QUADS、GLU/GLUT。第一版不依赖 geometry shader、compute shader、桌面专用 polygon mode。
buffer 使用连续 float32 和合法的 uint32 索引;draw call 按 part 合批。若最终必须支持 GLES2,作为独立降级项目处理着色器、VAO 和 16 位索引限制,不能以把版本号改成 2.0 作为完成。
### 8.5 相机和 UI 接口
| OrbitCamera 方法 | 输入 | 输出/行为 |
|---|---|---|
| `fit(bounds, aspect)` | 场景包围盒、宽高比 | None;设置 center/distance/near/far,使完整模型可见 |
| `orbit(delta_px, viewport)` | 鼠标二维增量、视口 | None;围绕 target 旋转,不旋转模型数据 |
| `pan(delta_px, viewport)` | 鼠标增量、视口 | None;按距离/投影换算世界平移 |
| `zoom(wheel_steps)` | 滚轮步长 | None;指数缩放并限制近远范围,避免穿模 |
| `set_preset(preset)` | CameraPreset | None;前/侧/顶/等轴测/默认 |
| `matrices(viewport)` | 物理视口尺寸 | CameraMatrices(view, projection) |
建议交互:左键旋转,Shift+左键或中键平移,滚轮缩放,复位按钮回资产默认视角。手势保存在 panel/widget,具体轨迹计算在 OrbitCamera。相机、外壳显隐、倍率与色标可存 UI preferences,不改变 SDK 参数。
Panel 控件:三维开关、预设视角、复位、外壳显隐、显示形变开关与倍率、色标范围/自动范围、量名称/单位/quality、帧号/数据不可用提示。默认不把算法相机内参暴露成用户的观察相机控件。
## 9. 现有文件怎样接入
| 文件 | 类/函数入口 | 最小修改 |
|---|---|---|
| `apps/studio/src/orisys_studio/main.py` | `main()` | 在 QApplication 前选择 GL profile;避免 app_window 的导入链提前加载错误 PyOpenGL platform |
| `shared/.../acquisition/models.py` | `SdkPacket` | 可选 `surface_frame` 或显示订阅 DTO;向后兼容默认 None,模型不 import Qt/OpenGL |
| `shared/.../acquisition/finger_worker.py` | 实际采集循环的组包位置 | 仅订阅时,在 SDK 已计算完且 runtime 线程拥有数据时调用 adapter;发布带 UV 域身份的只读快照 |
| `shared/.../acquisition/sdk_worker.py` | 平面组包位置 | 输出已经确认语义的 scalar/有效遮罩;无位移不伪造 |
| `apps/studio/src/orisys_studio/app_window.py` | `StudioMainWindow.__init__()`、`_on_run_tick()`、`_render_run_image()`、`closeEvent()` | 只装配 panel/coordinator、切换视图与关闭;不写 GL 调用、资产解密或顶点映射 |
| `apps/studio/src/orisys_studio/replay.py` | 打开录像、`set_position()`、关闭 | 对三维能力显式返回 unavailable;只有独立重算/保存的场数据源才发布 SurfaceFieldFrame |
| `apps/studio/pyproject.toml` 或项目依赖入口 | optional dependencies | 新增 three-d 安装项及每平台锁定文件;Open3D 不进入默认 Studio 依赖 |
| `tools/` 与 CI | 新 probe/export/validate 测试 | 纯 CPU 合同测试与真窗口 GL 冒烟分开 |
当前工作区正在改动采集滤波,工程师接入前应按函数职责定位,不依据本提案的旧行号覆盖文件。不得从 GUI 线程直接读取 worker.sensor 或修改它的图形状态。
## 10. 实时、回放与记录
实时:感知图/三维/力曲线共享 FrameIdentity;显示可降帧,记录通道保持既有语义。新 scalar 或 displacement 的计算不得为三维单独再滤一次,导致与二维不一致。
普通现有记录仅有视频和力时:显示静态参考模型并提示“三维场未记录”。完整重算必须通过独立数据源,携带 SDK/算法配置/几何/标定版本与恢复质量;不能自动重启已经删除的旧深度重算类。
未来如增加可追溯三维记录,建议另存分块 SurfaceFieldFrame 数据与 frame_id 索引,记录 quantity/unit/quality/domain_id/geometry_id,以及场是否 calibrated;不要仅保存着色后的网格或 UI 屏幕。session.json 增加可选 `visualization3d` 能力/资产版本块,旧文件缺此块视为不支持。此记录扩展不是第一版实时渲染的前置条件。
回放拖动时递增 request_id,CPU 合成结束后再次核对 request_id;无效/缺帧必须清理上次动态结果,不能让光标已前进但三维还冒充当前帧。
## 11. ARM 与安装/打包规范
### 11.1 依赖
- 继续使用项目验证过的 Python、PyQt5、NumPy;新增 PyOpenGL。先建立兼容版本范围,PoC 通过后分别锁定 Windows/Jetson/QCS6490 的版本与 wheel 哈希,不在文档虚构已验证版本。
- PyOpenGL_accelerate 是可选优化,不作为首轮 ARM 安装前提;官方也将它列为可选项。[PyOpenGL 安装](https://pyopengl.sourceforge.net/documentation/installation.html)
- 加密发布模式另引入成熟密码库;Blender、GLB 转换工具只在资产构建环境,客户运行时不依赖 Blender。
- PyOpenGL 不自带 GPU 驱动。不要为了满足依赖让 pip/系统包覆盖板卡 BSP 指定的 EGL/GLES 库。
拟定安装入口(工程师实现 extras 后才可执行):
```text
python -m pip install -e ./apps/studio[three-d]
python tools/scene_assets/probe_gl.py --profile desktop33 --output gl-report.json
python tools/scene_assets/probe_gl.py --profile gles30 --output gl-report.json
```
两种 profile 是不同启动配置,先按实际平台选其一;不要求同一进程依次更换 Qt/PyOpenGL 平台。PyOpenGL 的 platform 选择必须在第一次 import OpenGL 之前完成,并匹配 Qt 实际使用的 GLX/EGL;不能对所有 Linux 机器一律强设 EGL。探测程序必须建立实际 Qt widget,输出取得的 context,而非只查询系统库是否存在。
### 11.2 待补验收矩阵
| 平台 | 首轮验证路径 | 必须记录 |
|---|---|---|
| 开发机 Windows | desktop33 | GPU/驱动、Qt/Python/PyOpenGL、实际 GL/GLSL、DPR |
| Jetson Orin Nano / Ubuntu | BSP 支持的 desktop GL;需要时另验证 ES | 板卡内存、JetPack/BSP、桌面会话、功耗模式、驱动/renderer、Qt 实际 backend |
| QCS6490 / Ubuntu | BSP 支持的 EGL/GLES3 路径优先验证 | 模组厂商/BSP、Ubuntu、Wayland/X11、EGL/GLES、Qt 构建、驱动能力 |
不根据 SoC 名称猜桌面图形支持。QCS6490 与 Jetson 最终 profile 由探测报告决定;若驱动/绑定不满足,保持二维功能并显示具体原因。不得把 llvmpipe 等软件渲染误报为目标 GPU 验收通过。
## 12. 性能预算与验收用例
建议起始目标,不是目前测量结果:动态感知面约 20k 顶点/40k 三角形,外壳约 10k–20k 三角形;实际 LOD 由曲面误差和上板性能共同决定。将 full UV 网格保留为参考质量模式。
建议 30 FPS 显示上限;低配可明确降为 15 FPS,采集和记录速率不能因此下降。以 20k 顶点、position/normal/scalar/valid 约 32 字节计算,单份动态数组约 0.64 MB;此估算不含多份 CPU/GPU 缓冲、输入 UV 场、纹理和驱动开销。
| 测试 | 通过标准 |
|---|---|
| 原点/轴/尺度 | 三个非共线已知点,表面与外壳对齐;法线方向、绕序、米/毫米换算正确 |
| 资产身份 | 1005/1032 拓扑混用、UV 尺寸不同、flip_v 不同、错误哈希均被拒绝 |
| 采样 | 已知常量/斜坡/单点脉冲/NaN/遮罩场与 CPU 参考一致;接缝无跨域插值 |
| 几何分层 | 改 scalar/gain 只更新 sensing;housing 顶点和 GPU 静态资源不变 |
| 测量含义 | approximate 不显示成已标定位移;增大 gain 不改变原始 scalar 和力值 |
| 生命周期 | 隐藏/显示、反复 resize、切换布局、断连、源切换、关闭以及上下文重建无崩溃/泄漏 |
| 过期帧 | 旧 source/generation/request_id 永远不能覆盖新场景;无数据不显示旧测量 |
| 渲染限制 | shader 故障/GL 不足时降到二维并给诊断;无软件 renderer 冒充 GPU |
| 记录隔离 | 同样输入下,开/关三维的 CSV 行序和有效样本相同;三维超时不阻塞记录 |
| 性能 | 上板统计 p50/p95 合成、上传提交、绘制、总响应延迟,报告分辨率/网格/功耗配置;无无限任务增长 |
| 长时 | 建议 2 小时采集+记录+三维后,CPU/GPU 内存不持续增长;报告最大 RSS 和队列上界 |
| 包保护 | 改 manifest/密文/条目哈希被拒绝;无明文临时资产;客户包无签名私钥/内部源模型 |
CPU 合同测试在无 GUI 环境运行;GL 测试必须使用可见/实际窗口系统的上下文,offscreen UI 测试不能代替 ARM GPU 验证。
## 13. 实施拆分与交付物
1. **平台 PoC**:probe_gl、三角形、静态感知网格+外壳、resize/关闭测试;两块 ARM 至少拿到能力报告,再冻结 GLDispatch/profile。
2. **资产合同**:旧 NPZ 导入、统一坐标、roles、manifest、绑定验证和一套简化外壳;不改 SDK 算法网格。
3. **实时数据**:FrameAdapter、SurfaceSampler、SceneComposer、最新帧调度;先 scalar 着色,再加明确标记的示意位移。
4. **Qt 产品接入**:Sensor3DWidget、相机/色标/倍率/显隐、异常与生命周期、现有记录隔离测试。
5. **发布保护与性能**:精简/签名包、可选加密与 KeyProvider、平台锁定、性能/长时/故障测试。
每项交付包括:实现、公共接口单测、输入样例、失败样例、兼容说明、平台诊断报告。先评审本文的坐标/场语义/资产合同,再开始组件并行开发;不要等画面完成后才决定数组每一维和单位的含义。