added dd06 test support, organized 2d viewer and 3d viewer code organization.

This commit is contained in:
ykcheungab
2026-07-26 17:03:44 +08:00
parent 28e6c7c8a9
commit 2e3d0a0609
32 changed files with 2326 additions and 936 deletions
+175
View File
@@ -0,0 +1,175 @@
"""
DD06 single-camera pipeline with polygon ROI rectification.
Flow:
1. Load sensor config + nested polygon ROI
2. Capture raw frames from the video/camera source
3. Perspective-warp the ROI into a rectangle sized to camera.resolution_stitch
4. Feed the rectified frame into Sensor.get_img(..., already_processed=True)
5. Compute deformation/contact and visualize
SDK version: 0.3.1
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import numpy as np
import orisys
_REPO_ROOT = Path(__file__).resolve().parents[1]
_EXAMPLES_DIR = _REPO_ROOT / "examples"
if str(_EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(_EXAMPLES_DIR))
from gui.sensors.roi_geometry import ( # noqa: E402
build_roi_warp,
load_json_config,
load_roi_polygon,
open_video_source,
rectified_output_size,
warp_roi_to_rectangle,
)
def main():
parser = argparse.ArgumentParser(
description="DD06 pipeline: polygon-ROI rectify -> Sensor.get_img(already_processed=True)"
)
parser.add_argument(
"--video",
"-v",
type=str,
default="./data/dd06/0726.mp4",
help="视频源:摄像头编号或视频文件路径",
)
parser.add_argument(
"--config",
"-c",
type=str,
default="./config/dd06-ov.json",
help="配置文件名称或路径",
)
parser.add_argument(
"--verbose",
"-verbose",
action="store_true",
default=True,
help="是否输出详细日志",
)
args = parser.parse_args()
config = load_json_config(args.config)
roi_points, roi_image_size = load_roi_polygon(config)
out_w, out_h = rectified_output_size(config, roi_points)
sensor = orisys.Sensor("external", config_name=args.config, verbose=args.verbose)
cap, source = open_video_source(args.video)
warp_matrix = None
warp_src = None
last_frame_size = None
print(
f"ROI points={len(roi_points)} source_size={roi_image_size} "
f"output={out_w}x{out_h} video={source}"
)
print("按键说明:'q' 退出程序,'r' 重置追踪器。")
try:
while True:
ok, frame = cap.read()
if not ok or frame is None:
print("Failed to read frame from video source.")
break
frame_h, frame_w = frame.shape[:2]
frame_size = (frame_w, frame_h)
if warp_matrix is None or last_frame_size != frame_size:
warp_matrix, warp_src, _ = build_roi_warp(
roi_points,
roi_image_size,
frame_size,
(out_w, out_h),
)
last_frame_size = frame_size
if args.verbose:
print(f"Built ROI warp for frame {frame_w}x{frame_h}:\n{warp_src}")
rectified = warp_roi_to_rectangle(frame, warp_matrix, (out_w, out_h))
sensor.get_img(frame=rectified, already_processed=True)
is_contact_now = sensor.compute_deformation(check_motion=True, threshold=0)
_ = is_contact_now
sensor.compute_contact()
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = (
sensor.read_info(
sensor.info.FPS,
sensor.info.VRAW,
sensor.info.VNORMAL,
sensor.info.IMG,
sensor.info.CONTOUR,
sensor.info.CENTROID,
sensor.info.DEPTH,
sensor.info.FNORMAL,
sensor.info.FSHEARX,
sensor.info.FSHEARY,
)
)
preview = frame.copy()
cv2.polylines(
preview,
[np.rint(warp_src).astype(np.int32).reshape(-1, 1, 2)],
True,
(0, 255, 255),
2,
cv2.LINE_AA,
)
arrows = orisys.util.draw_arrows(
img,
flow,
threshold=1,
grid_spacing=20,
arrow_scale=1.0,
)
cv2.imshow("形变矢量场", arrows)
div_abs = depth_map
if div_abs.max() > div_abs.min():
div_normalized = (
(div_abs - div_abs.min()) / (div_abs.max() - div_abs.min()) * 255
).astype(np.uint8)
else:
div_normalized = np.zeros_like(div_abs, dtype=np.uint8)
cv2.imshow("depth", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
print(
f"FPS={fps:.2f}, 法向力={fnormal:.4f}, 切向力X={fshearx:.4f}, "
f"切向力Y={fsheary:.4f}, 深度图尺寸={depth_map.shape}, "
f"原始分辨率={sensor.img_size_raw}, 矫正={rectified.shape[1]}x{rectified.shape[0]}"
)
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
print("\n正在退出示例程序...")
break
if key == ord("r"):
sensor.reset()
print("追踪器已重置。")
finally:
cap.release()
sensor.disconnect()
cv2.destroyAllWindows()
if __name__ == "__main__":
main()
+165
View File
@@ -0,0 +1,165 @@
"""Tests for Qt viewer backend force history and aspect-aware display helpers."""
from __future__ import annotations
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock
import numpy as np
import pytest
_REPO_ROOT = Path(__file__).resolve().parents[1]
_EXAMPLES_DIR = _REPO_ROOT / "examples"
if str(_EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(_EXAMPLES_DIR))
from gui.viewer2d.backend import ( # noqa: E402
QtViewerBackend,
image_shape_meta,
panel_width_for_aspect,
scale_bgr_to_fit,
select_display_image,
)
from gui.sensors import ( # noqa: E402
FrameSnapshot,
_snapshot_from_sensor,
calibrate_normal_force,
)
class FakeForceAdapter:
"""Minimal adapter that emits changing FN/FX/FY without a real sensor."""
def __init__(self):
self._i = 0
def read(self):
i = self._i
self._i += 1
return FrameSnapshot(
image=np.zeros((100, 200, 3), dtype=np.uint8),
normal_force=1000.0 + 50.0 * i,
shear_x=-200.0 + 10.0 * i,
shear_y=300.0 - 5.0 * i,
normal_force_calibrated=calibrate_normal_force(1000.0 + 50.0 * i),
fps=30.0,
timestamp=float(i) / 30.0,
flow_overlay=np.zeros((100, 200, 3), dtype=np.uint8),
record_frame=np.zeros((100, 200, 3), dtype=np.uint8),
)
def test_backend_force_history_tracks_all_channels():
adapter = FakeForceAdapter()
backend = QtViewerBackend(npoints=32, lowpass_cutoff_hz=0.0)
for _ in range(8):
snap = adapter.read()
backend.ingest(snap, now=snap.timestamp)
state = backend.plot_state()
assert state is not None
assert state.sample_count == 8
assert state.values.shape == (8, 3)
# All three channels must change across frames (not stay flat).
fn_span = float(state.values[:, 0].max() - state.values[:, 0].min())
fx_span = float(state.values[:, 1].max() - state.values[:, 1].min())
fy_span = float(state.values[:, 2].max() - state.values[:, 2].min())
assert fn_span > 100.0
assert fx_span > 20.0
assert fy_span > 10.0
# Raw FN is used for curves, not the tiny calibrated Newton scale.
assert float(state.values[-1, 0]) == pytest.approx(1000.0 + 50.0 * 7)
@pytest.mark.parametrize(
"shape,expected_aspect",
[
((100, 200, 3), 2.0), # wide
((120, 120, 3), 1.0), # square
((200, 100, 3), 0.5), # portrait
],
)
def test_backend_image_aspect_metadata(shape, expected_aspect):
img = np.zeros(shape, dtype=np.uint8)
h, w, aspect = image_shape_meta(img)
assert (h, w) == (shape[0], shape[1])
assert aspect == pytest.approx(expected_aspect)
snap = FrameSnapshot(flow_overlay=img, normal_force=1.0, shear_x=2.0, shear_y=3.0)
backend = QtViewerBackend(lowpass_cutoff_hz=0.0)
frame = backend.ingest(snap, now=0.0)
assert frame.image_height == shape[0]
assert frame.image_width == shape[1]
assert frame.aspect_ratio == pytest.approx(expected_aspect)
fitted = scale_bgr_to_fit(img, max_w=80, max_h=80)
fh, fw = fitted.shape[:2]
assert fw <= 80 and fh <= 80
assert abs((fw / fh) - expected_aspect) < 0.05
panel_w = panel_width_for_aspect(200, expected_aspect, min_size=160)
assert panel_w >= 160
assert panel_w == pytest.approx(max(160, int(round(200 * expected_aspect))))
def test_select_display_image_prefers_flow_overlay():
overlay = np.full((10, 20, 3), 7, dtype=np.uint8)
processed = np.full((10, 20, 3), 3, dtype=np.uint8)
snap = FrameSnapshot(flow_overlay=overlay, processed_image=processed, image=processed)
out = select_display_image(snap)
assert np.array_equal(out, overlay)
def test_snapshot_requests_hhd_every_frame_without_motion_gate():
"""Regression: force refresh must not use check_motion=True gating."""
sensor = MagicMock()
sensor.img = np.zeros((32, 32), dtype=np.uint8)
sensor.frame = np.zeros((32, 32), dtype=np.uint8)
sensor.info = types.SimpleNamespace(
FPS="FPS",
VRAW="VRAW",
VNORMAL="VNORMAL",
IMG="IMG",
CONTOUR="CONTOUR",
CENTROID="CENTROID",
DEPTH="DEPTH",
FNORMAL="FNORMAL",
FSHEARX="FSHEARX",
FSHEARY="FSHEARY",
)
sensor.read_info.return_value = (
30.0,
np.zeros((32, 32, 2), dtype=np.float32),
np.zeros((32, 32), dtype=np.float32),
sensor.img,
None,
None,
np.zeros((32, 32), dtype=np.float32),
12345.0,
-100.0,
50.0,
)
# Avoid depending on orisys.util.draw_arrows implementation details.
import gui.sensors.adapter as sa
original_util = sa.orisys.util
sa.orisys.util = types.SimpleNamespace(
draw_arrows=lambda *args, **kwargs: np.zeros((32, 32, 3), dtype=np.uint8)
)
try:
snap = _snapshot_from_sensor(sensor, raw_image=sensor.frame, processed_image=sensor.img)
finally:
sa.orisys.util = original_util
sensor.compute_deformation.assert_called_once_with(decompose=True, check_motion=False)
assert snap is not None
assert snap.normal_force == pytest.approx(12345.0)
assert snap.shear_x == pytest.approx(-100.0)
assert snap.shear_y == pytest.approx(50.0)
assert snap.normal_force_calibrated == pytest.approx(calibrate_normal_force(12345.0))