diff --git a/apps/studio/config/compound_eye.png b/apps/studio/config/compound_eye.png deleted file mode 100644 index c50ca69..0000000 Binary files a/apps/studio/config/compound_eye.png and /dev/null differ diff --git a/docs/architecture/studio-3d-contracts.pyi b/docs/architecture/studio-3d-contracts.pyi new file mode 100644 index 0000000..e09c0a2 --- /dev/null +++ b/docs/architecture/studio-3d-contracts.pyi @@ -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: ... diff --git a/docs/architecture/studio-3d-design.md b/docs/architecture/studio-3d-design.md new file mode 100644 index 0000000..1541b02 --- /dev/null +++ b/docs/architecture/studio-3d-design.md @@ -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": "", + "coordinate_frame": "sensor", + "length_unit": "m", + "compatible": {"sensor_model": "finger", "hardware_revisions": [""]}, + "field_domain": { + "id": "", "shape_hw": [300, 280], + "index_order": "row-major", "origin": "top-left", + "source_scale": 0.4, "flip_v": "", + "mapping_sha256": "", "camera_sha256": "" + }, + "parts": [ + {"id": "sensing_surface", "role": "sensing", "mesh": "sensing", "binding": "uv-lod0"}, + {"id": "housing", "role": "housing", "mesh": "housing"} + ], + "entries": { + "sensing/positions.npy": {"dtype": "", 3], "sha256": ""} + } +} +``` + +## 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、平台锁定、性能/长时/故障测试。 + +每项交付包括:实现、公共接口单测、输入样例、失败样例、兼容说明、平台诊断报告。先评审本文的坐标/场语义/资产合同,再开始组件并行开发;不要等画面完成后才决定数组每一维和单位的含义。