Files
orisys-sdk/tools/3d_patch_generation_blender/unroll_realtime.py
T
2026-06-14 20:58:46 +08:00

720 lines
28 KiB
Python

#!/usr/bin/env python3
"""
Real-time UV-based perspective correction for the exported finger mesh.
This version replaces the previous cylindrical approximation with a remap built
from the mesh UV parameterization. The remap is precomputed once and then
applied to each live frame with cv2.remap().
"""
import argparse
import sys
import time
import cv2
import numpy as np
import orisys
from orisys.configs import load_config
from orisys.frame_processors import UVUnrollFrameProcessor
from orisys.unroll import (
barycentric_grid,
build_or_load_domain,
compute_uv_distortion_metrics,
face_uvs_from_loop_data,
load_calib_json,
load_exported_data,
recommend_edge_expand,
resolve_uv_runtime_sizes,
scale_camera_geometry,
scaled_map_cache_path,
uv_triangle_to_image,
)
class StepTimer:
"""Accumulate per-step wall times for rolling runtime averages."""
def __init__(self):
self._starts = {}
self.totals = {}
self.counts = {}
def start(self, name):
self._starts[name] = time.perf_counter()
def stop(self, name):
dt = time.perf_counter() - self._starts.pop(name)
self.totals[name] = self.totals.get(name, 0.0) + dt
self.counts[name] = self.counts.get(name, 0) + 1
return dt
def avg_ms(self, name):
if self.counts.get(name, 0) == 0:
return 0.0
return 1000.0 * self.totals[name] / self.counts[name]
def benchmark_step(label, func, *args, **kwargs):
"""Run func once and print its wall time."""
t0 = time.perf_counter()
result = func(*args, **kwargs)
ms = (time.perf_counter() - t0) * 1000.0
print(f"[benchmark] {label}: {ms:.1f} ms")
return result
def print_runtime_benchmark(timer):
"""Format rolling average timings for the live loop."""
parts = [
f"capture={timer.avg_ms('capture'):.1f}ms",
f"uv_prep={timer.avg_ms('uv_prepare'):.1f}ms",
f"uv_remap={timer.avg_ms('uv_remap'):.1f}ms",
f"get_img={timer.avg_ms('get_img'):.1f}ms",
f"flow={timer.avg_ms('flow'):.1f}ms",
f"hhd={timer.avg_ms('hhd'):.1f}ms",
f"contact={timer.avg_ms('contact'):.1f}ms",
f"viz={timer.avg_ms('viz'):.1f}ms",
]
return "bench[" + ", ".join(parts) + "]"
def resize_to_height(image, target_height):
"""Resize an image to a target height while preserving aspect ratio."""
if image.shape[0] == target_height:
return image
scale = target_height / float(image.shape[0])
target_width = max(1, int(round(image.shape[1] * scale)))
return cv2.resize(image, (target_width, target_height), interpolation=cv2.INTER_AREA)
def resize_to_fit(image, target_width, target_height):
"""Resize with aspect ratio preserved and pad to exact tile size."""
h, w = image.shape[:2]
if h <= 0 or w <= 0:
return np.zeros((target_height, target_width, 3), dtype=np.uint8)
scale = min(target_width / float(w), target_height / float(h))
new_w = max(1, int(round(w * scale)))
new_h = max(1, int(round(h * scale)))
resized = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
if resized.ndim == 2:
resized = cv2.cvtColor(resized, cv2.COLOR_GRAY2BGR)
elif resized.ndim == 3 and resized.shape[2] == 1:
resized = cv2.cvtColor(resized[:, :, 0], cv2.COLOR_GRAY2BGR)
canvas = np.zeros((target_height, target_width, 3), dtype=np.uint8)
y0 = (target_height - new_h) // 2
x0 = (target_width - new_w) // 2
canvas[y0:y0 + new_h, x0:x0 + new_w] = resized
return canvas
def add_panel_title(image, title):
"""Overlay a simple title onto a display tile."""
out = image.copy()
cv2.rectangle(out, (0, 0), (out.shape[1], 36), (0, 0, 0), thickness=-1)
cv2.putText(out, title, (12, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2, cv2.LINE_AA)
return out
def apply_display_mask(image, valid_mask, border_value=0):
"""Zero invalid UV pixels in visualization panels."""
if image is None or valid_mask is None:
return image
out = image.copy()
mask = valid_mask.astype(np.uint8)
if mask.shape != out.shape[:2]:
mask = cv2.resize(mask, (out.shape[1], out.shape[0]), interpolation=cv2.INTER_NEAREST)
invalid = ~mask.astype(bool)
out[invalid] = border_value
return out
def contact_inside_mask(centroid, valid_mask):
if centroid is None or valid_mask is None:
return None
if len(centroid) < 2:
return None
cx, cy = int(centroid[0]), int(centroid[1])
if cy < 0 or cy >= valid_mask.shape[0] or cx < 0 or cx >= valid_mask.shape[1]:
return False
return bool(valid_mask[cy, cx])
def overlay_runtime_status(image, sensor, centroid=None):
"""Annotate a panel with mask-aware runtime diagnostics."""
out = image.copy()
stats = getattr(sensor, "mask_stats", {}) or {}
coverage = 100.0 * float(stats.get("coverage", 0.0))
flow_valid = float(stats.get("flow_mean_valid", 0.0))
flow_invalid = float(stats.get("flow_mean_invalid", 0.0))
centroid_ok = contact_inside_mask(centroid, getattr(sensor, "valid_mask", None))
centroid_label = "n/a" if centroid_ok is None else ("inside" if centroid_ok else "outside")
lines = [
f"mask {coverage:.1f}%",
f"flow valid {flow_valid:.3f}",
f"flow invalid {flow_invalid:.3f}",
f"contact {centroid_label}",
]
y = 54
for line in lines:
cv2.putText(out, line, (12, y), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 2, cv2.LINE_AA)
y += 24
return out
def make_tiled_dashboard(panels, tile_width=640, tile_height=360):
"""Arrange four panels into a 2x2 dashboard."""
tiles = [
add_panel_title(resize_to_fit(image, tile_width, tile_height), title)
for title, image in panels
]
top_row = np.hstack(tiles[:2])
bottom_row = np.hstack(tiles[2:4])
return np.vstack([top_row, bottom_row])
def make_uv_debug_image(data, out_width, out_height, flip_v=True, edge_band=0.12, edge_expand=1.0):
"""Create a UV-domain reference image to verify orientation of the remap."""
face_uvs = face_uvs_from_loop_data(data["faces"], data["uv_data"], data["loop_to_vertex"])
uv_img = np.zeros((out_height, out_width, 3), dtype=np.uint8)
label_color = (255, 255, 255)
for uv_tri in face_uvs:
tri = uv_triangle_to_image(
uv_tri,
out_width,
out_height,
flip_v=flip_v,
edge_band=edge_band,
edge_expand=edge_expand,
)
pts = np.round(tri).astype(np.int32)
mask = np.zeros((out_height, out_width), dtype=np.uint8)
cv2.fillConvexPoly(mask, pts, 255)
x0 = max(0, int(np.floor(np.min(tri[:, 0]))))
x1 = min(out_width - 1, int(np.ceil(np.max(tri[:, 0]))))
y0 = max(0, int(np.floor(np.min(tri[:, 1]))))
y1 = min(out_height - 1, int(np.ceil(np.max(tri[:, 1]))))
if x1 < x0 or y1 < y0:
continue
yy, xx = np.mgrid[y0:y1 + 1, x0:x1 + 1]
inside = mask[y0:y1 + 1, x0:x1 + 1] > 0
if not np.any(inside):
continue
bary = barycentric_grid(xx + 0.5, yy + 0.5, tri)
if bary is None:
continue
w0, w1, w2 = bary
valid = inside & (w0 >= -1e-6) & (w1 >= -1e-6) & (w2 >= -1e-6)
if not np.any(valid):
continue
uv = (
w0[valid, None] * uv_tri[0]
+ w1[valid, None] * uv_tri[1]
+ w2[valid, None] * uv_tri[2]
)
colors = np.zeros((uv.shape[0], 3), dtype=np.uint8)
colors[:, 0] = np.clip(uv[:, 0] * 255.0, 0, 255).astype(np.uint8)
colors[:, 1] = np.clip(uv[:, 1] * 255.0, 0, 255).astype(np.uint8)
colors[:, 2] = np.clip((1.0 - 0.5 * (uv[:, 0] + uv[:, 1])) * 255.0, 0, 255).astype(np.uint8)
uv_img[yy[valid], xx[valid]] = colors
cv2.polylines(uv_img, [pts.reshape(-1, 1, 2)], isClosed=True, color=(255, 255, 255), thickness=1)
cv2.arrowedLine(uv_img, (20, out_height - 20), (min(out_width - 20, 140), out_height - 20), (0, 0, 255), 2, tipLength=0.15)
cv2.arrowedLine(uv_img, (20, out_height - 20), (20, max(20, out_height - 140)), (0, 255, 0), 2, tipLength=0.15)
cv2.putText(uv_img, "U", (min(out_width - 30, 145), out_height - 28), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2, cv2.LINE_AA)
cv2.putText(uv_img, "V", (28, max(30, out_height - 145)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2, cv2.LINE_AA)
cv2.putText(uv_img, "UV Map", (20, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.9, label_color, 2, cv2.LINE_AA)
if edge_expand > 1.0:
cv2.putText(uv_img, f"Edge expand {edge_expand:.2f}x", (20, 64), cv2.FONT_HERSHEY_SIMPLEX, 0.55, label_color, 2, cv2.LINE_AA)
return uv_img
def make_uv_distortion_image(data, distortion, out_width, out_height, flip_v=True, edge_band=0.12, edge_expand=1.0):
"""Render UV distortion heatmap; red means more 3D surface per UV area."""
face_uvs = face_uvs_from_loop_data(data["faces"], data["uv_data"], data["loop_to_vertex"])
uv_img = np.zeros((out_height, out_width, 3), dtype=np.uint8)
rel = distortion["relative_scale"]
edge_mask = distortion["edge_mask"]
valid_rel = np.isfinite(rel) & (rel > 0)
if not np.any(valid_rel):
return uv_img
log_rel = np.log2(rel[valid_rel])
clip = max(0.25, float(np.percentile(np.abs(log_rel), 95)))
for idx, uv_tri in enumerate(face_uvs):
tri = uv_triangle_to_image(
uv_tri,
out_width,
out_height,
flip_v=flip_v,
edge_band=edge_band,
edge_expand=edge_expand,
)
pts = np.round(tri).astype(np.int32)
if not np.isfinite(rel[idx]) or rel[idx] <= 0:
color = (64, 64, 64)
else:
value = np.clip((np.log2(rel[idx]) + clip) / (2.0 * clip), 0.0, 1.0)
color_idx = np.uint8(round(value * 255.0))
color = tuple(int(c) for c in cv2.applyColorMap(np.array([[color_idx]], dtype=np.uint8), cv2.COLORMAP_JET)[0, 0])
cv2.fillConvexPoly(uv_img, pts, color)
edge_color = (255, 255, 255) if edge_mask[idx] else (180, 180, 180)
edge_thickness = 2 if edge_mask[idx] else 1
cv2.polylines(uv_img, [pts.reshape(-1, 1, 2)], isClosed=True, color=edge_color, thickness=edge_thickness)
cv2.putText(uv_img, "UV Distortion", (20, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2, cv2.LINE_AA)
cv2.putText(uv_img, "Blue: denser UV Red: sparser UV", (20, 64), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (220, 220, 220), 2, cv2.LINE_AA)
cv2.putText(uv_img, "White outlines: edge-band faces", (20, 92), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (220, 220, 220), 2, cv2.LINE_AA)
middle_stats = distortion["middle_stats"]
edge_stats = distortion["edge_stats"]
cv2.putText(
uv_img,
f"middle p05/p95: {middle_stats['relative_scale_p05']:.2f}x / {middle_stats['relative_scale_p95']:.2f}x",
(20, 120),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
(220, 220, 220),
2,
cv2.LINE_AA,
)
cv2.putText(
uv_img,
f"edge p05/p95: {edge_stats['relative_scale_p05']:.2f}x / {edge_stats['relative_scale_p95']:.2f}x",
(20, 148),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
(220, 220, 220),
2,
cv2.LINE_AA,
)
return uv_img
def open_source(video_path, camera_id):
return cv2.VideoCapture(video_path) if video_path else cv2.VideoCapture(camera_id)
PERF_TUNING_EPILOG = """
Performance tuning (config JSON + CLI):
camera.resolution_stitch [280, 300] UV output + SDK flow/HHD grid
camera.source_scale 0.4 shrink source before uv_prep/remap
tracker.dis_preset medium -> fast faster DIS optical flow
tracker.hhd_downsample 5 -> 4 smaller HHD Poisson grid
contact.denoise_sigma 12 -> 6 or 0 faster FoE contact step
--source_scale 0.4 override config source scale
--masked_poisson_solver sparse vs capfft benchmark HHD solve time
--no-viz skip dashboard ~20 ms/frame savings
Recommended dd02-ov fast profile:
resolution_stitch [280, 300], source_scale 0.4, hhd_downsample 5
Delete or rename stale unroll_map_uv.npz after changing output scale.
"""
def run_deformation_timed(sensor, timer, check_motion=True, threshold=0, tail_frames=10):
"""Run flow + gated HHD with separate benchmark buckets."""
timer.start("flow")
sensor.compute_flow()
timer.stop("flow")
timer.start("hhd")
run_hhd = True
if check_motion:
is_motion = sensor.detect_motion(threshold=threshold)
try:
tail_frames = max(0, int(tail_frames))
except (TypeError, ValueError):
tail_frames = 0
if is_motion:
sensor.tail_frames_count = 0
elif sensor.tail_frames_count < tail_frames:
sensor.tail_frames_count += 1
else:
run_hhd = False
sensor.tail_frames_count = tail_frames
if run_hhd:
sensor.compute_hhd()
timer.stop("hhd")
def configure_masked_poisson(sensor, args):
"""Apply masked-HHD solver settings to the externally fed SDK sensor."""
sensor.masked_poisson_solver = args.masked_poisson_solver
sensor.masked_poisson_max_boundary = args.masked_poisson_max_boundary
sensor.masked_poisson_boundary_stride = args.masked_poisson_boundary_stride
sensor.masked_poisson_max_iters = args.masked_poisson_max_iters
sensor.masked_poisson_tol = args.masked_poisson_tol
sensor.masked_poisson_reg = args.masked_poisson_reg
def resolve_runtime_geometry(args):
"""Apply config defaults and optional source-scale to UV/camera geometry."""
sdk_config = load_config(args.sdk_config)
camera_config = sdk_config.get("camera", {})
out_width, out_height, source_scale = resolve_uv_runtime_sizes(
camera_config,
out_width=args.out_width,
out_height=args.out_height,
source_scale=args.source_scale,
)
stitch = camera_config.get("resolution_stitch")
if stitch and len(stitch) >= 2 and (int(stitch[0]) != out_width or int(stitch[1]) != out_height):
print(
"Warning: UV output "
f"{out_width}x{out_height} differs from resolution_stitch "
f"{int(stitch[0])}x{int(stitch[1])} in {args.sdk_config}; "
"Sensor.get_img will resize again."
)
args.out_width = out_width
args.out_height = out_height
args.source_scale = source_scale
args.map_cache = scaled_map_cache_path(args.map_cache, source_scale)
return sdk_config, camera_config
def main():
parser = argparse.ArgumentParser(
description="Real-time UV-based finger unrolling",
epilog=PERF_TUNING_EPILOG,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--mesh", required=True, help="Exported mesh.npz path")
parser.add_argument("--K", dest="intrinsics_path", required=True, help="Exported camera_intrinsics.npz path")
parser.add_argument("--pose", required=True, help="Exported world_to_cam.npy path")
parser.add_argument("--calib", default=None, help="OpenCV calibration JSON for the live camera")
# With a 12% UV edge band on each side, only about 76% of each axis is the
# lower-distortion middle region. Use a slightly larger full remap so the
# useful center keeps roughly the same sampling density as the prior 506x550.
parser.add_argument("--out_width", type=int, default=None, help="UV flatten width (default: camera.resolution_stitch in sdk config)")
parser.add_argument("--out_height", type=int, default=None, help="UV flatten height (default: camera.resolution_stitch in sdk config)")
parser.add_argument(
"--source_scale",
type=float,
default=None,
help="Resize live source to this fraction before uv_prep/uv_remap; scales K accordingly (default: camera.source_scale or work4[3] in sdk config, else 1.0)",
)
parser.add_argument("--camera_id", type=int, default=0)
parser.add_argument("--video", help="Video file path (overrides camera)")
parser.add_argument("--map_cache", default="unroll_map_uv.npz")
parser.add_argument("--no_flip_v", action="store_true", help="Keep UV V increasing downward")
parser.add_argument("--no_flip_source_vertical", action="store_true", help="Do not vertically flip the live source before UV remap")
parser.add_argument("--edge_band", type=float, default=0.12, help="Normalized UV band near the boundary treated as high-distortion edge")
parser.add_argument("--edge_expand", type=float, default=0.0, help="Extra UV allocation for edge band; 0 chooses automatically from distortion stats")
parser.add_argument("--sdk_config", default="./config/dd02-ov.json", help="Orisys SDK config path/name")
parser.add_argument("--sdk_source", default=None, help="Dummy source used to initialize Sensor; defaults to --video or --camera_id")
parser.add_argument("--masked_poisson_solver", choices=["sparse", "capfft"], default="sparse", help="Masked HHD Poisson solver used inside the SDK")
parser.add_argument("--masked_poisson_max_boundary", type=int, default=96, help="Maximum number of UV-boundary samples used by the capfft solver")
parser.add_argument("--masked_poisson_boundary_stride", type=int, default=1, help="Subsample stride applied before capfft boundary sampling")
parser.add_argument("--masked_poisson_max_iters", type=int, default=3, help="Maximum capfft correction iterations per Poisson solve")
parser.add_argument("--masked_poisson_tol", type=float, default=1e-4, help="Residual tolerance used by the capfft correction loop")
parser.add_argument("--masked_poisson_reg", type=float, default=1e-8, help="Diagonal regularization applied to the capacitance matrix")
parser.add_argument("--verbose", action="store_true", help="Enable verbose SDK logs")
parser.add_argument(
"--no-viz",
action="store_true",
help="Skip OpenCV dashboard; print forces and benchmark timings only",
)
args = parser.parse_args()
_, camera_config = resolve_runtime_geometry(args)
print("[benchmark] Startup timing:")
data = benchmark_step("load_exported_data", load_exported_data, args.mesh, args.intrinsics_path, args.pose)
flip_v = not args.no_flip_v
flip_source_vertical = not args.no_flip_source_vertical
distortion = benchmark_step(
"compute_uv_distortion_metrics",
compute_uv_distortion_metrics,
data,
edge_band=args.edge_band,
)
distortion_stats = distortion["stats"]
middle_stats = distortion["middle_stats"]
edge_stats = distortion["edge_stats"]
edge_expand = args.edge_expand if args.edge_expand > 1.0 else recommend_edge_expand(distortion)
print(
"UV distortion stats: "
f"median mm/uv={distortion_stats['median_mm_per_uv']:.3f}, "
f"relative scale p05/p95={distortion_stats['relative_scale_p05']:.2f}x/"
f"{distortion_stats['relative_scale_p95']:.2f}x, "
f"anisotropy p50/p95={distortion_stats['anisotropy_p50']:.2f}x/"
f"{distortion_stats['anisotropy_p95']:.2f}x"
)
print(f"Using UV edge warp: band={args.edge_band:.2f}, expand={edge_expand:.2f}x")
print(
"UV middle-region stats: "
f"faces={middle_stats['face_count']}, "
f"relative scale p05/p95={middle_stats['relative_scale_p05']:.2f}x/"
f"{middle_stats['relative_scale_p95']:.2f}x, "
f"anisotropy p50/p95={middle_stats['anisotropy_p50']:.2f}x/"
f"{middle_stats['anisotropy_p95']:.2f}x"
)
print(
"UV edge-band stats: "
f"faces={edge_stats['face_count']}, "
f"relative scale p05/p95={edge_stats['relative_scale_p05']:.2f}x/"
f"{edge_stats['relative_scale_p95']:.2f}x, "
f"anisotropy p50/p95={edge_stats['anisotropy_p50']:.2f}x/"
f"{edge_stats['anisotropy_p95']:.2f}x"
)
if args.calib:
source_K, dist_coeffs, img_size = benchmark_step("load_calib_json", load_calib_json, args.calib)
native_width, native_height = img_size
print(f"Calibration loaded. Live image size: {(native_width, native_height)}")
else:
source_K = data["K"]
dist_coeffs = None
native_width = data["width"]
native_height = data["height"]
print("No calibration file provided; using exported intrinsics for projection.")
source_K, source_width, source_height = scale_camera_geometry(
source_K,
native_width,
native_height,
args.source_scale,
)
print(
"UV runtime geometry: "
f"native={native_width}x{native_height}, "
f"source={source_width}x{source_height} (scale={args.source_scale:g}), "
f"output={args.out_width}x{args.out_height}, "
f"map_cache={args.map_cache}"
)
domain = benchmark_step(
"build_or_load_domain",
build_or_load_domain,
args.mesh,
args.intrinsics_path,
args.pose,
args.map_cache,
source_K,
source_width,
source_height,
args.out_width,
args.out_height,
flip_v=flip_v,
edge_band=args.edge_band,
edge_expand=edge_expand,
include_surface=True,
source_scale=args.source_scale,
)
remap = domain.remap
remap.ensure_fast_maps()
edge_expand = float(remap.metadata.get("edge_expand", edge_expand))
uv_debug = benchmark_step(
"make_uv_debug_image",
make_uv_debug_image,
data,
out_width=args.out_width,
out_height=args.out_height,
flip_v=flip_v,
edge_band=args.edge_band,
edge_expand=edge_expand,
)
uv_distortion = benchmark_step(
"make_uv_distortion_image",
make_uv_distortion_image,
data,
distortion,
out_width=args.out_width,
out_height=args.out_height,
flip_v=flip_v,
edge_band=args.edge_band,
edge_expand=edge_expand,
)
uv_reference = benchmark_step(
"uv_reference_compose",
lambda: np.hstack(
[
resize_to_height(uv_debug, args.out_height),
resize_to_height(uv_distortion, args.out_height),
]
),
)
cap = open_source(args.video, args.camera_id)
if not cap.isOpened():
print("Cannot open camera/video source")
sys.exit(1)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, native_width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, native_height)
sensor = benchmark_step(
"sensor_init",
lambda: orisys.Sensor(
"external",
config_name=args.sdk_config,
verbose=args.verbose,
mirror=False,
),
)
configure_masked_poisson(sensor, args)
print(
"Masked HHD solver: "
f"{sensor.masked_poisson_solver} "
f"(max_boundary={sensor.masked_poisson_max_boundary}, "
f"stride={sensor.masked_poisson_boundary_stride}, "
f"iters={sensor.masked_poisson_max_iters}, "
f"tol={sensor.masked_poisson_tol:.1e})"
)
frame_processor = UVUnrollFrameProcessor(
domain.remap,
camera_matrix=source_K if dist_coeffs is not None else None,
dist_coeffs=dist_coeffs,
source_width=source_width,
source_height=source_height,
flip_source_vertical=flip_source_vertical,
border_value=(0, 0, 0),
)
ok, warmup_frame = cap.read()
if ok:
warmup_prepared = frame_processor.prepare_source(warmup_frame)
warmup_unrolled = frame_processor.flatten_prepared(warmup_prepared)
sensor.get_img(
frame=warmup_unrolled,
already_processed=True,
valid_mask=frame_processor.valid_mask,
)
benchmark_step("hhd_warmup", sensor.warmup_deformation)
else:
print("[benchmark] hhd_warmup: skipped (no frame from source)")
if args.no_viz:
print("Running with --no-viz (dashboard disabled).")
else:
print("Press 'q' to quit.")
runtime_timer = StepTimer()
try:
while True:
runtime_timer.start("capture")
ok, frame = cap.read()
runtime_timer.stop("capture")
if not ok:
break
runtime_timer.start("uv_prepare")
frame_source = frame_processor.prepare_source(frame)
runtime_timer.stop("uv_prepare")
runtime_timer.start("uv_remap")
unrolled = frame_processor.flatten_prepared(frame_source)
runtime_timer.stop("uv_remap")
runtime_timer.start("get_img")
sensor.get_img(
frame=unrolled,
already_processed=True,
valid_mask=frame_processor.valid_mask,
)
runtime_timer.stop("get_img")
run_deformation_timed(sensor, runtime_timer, check_motion=True, threshold=0)
runtime_timer.start("contact")
sensor.compute_contact()
runtime_timer.stop("contact")
fps = sensor._fps
fnormal = sensor.fnormal
fshearx = sensor.fshearx
fsheary = sensor.fsheary
contact_ok = contact_inside_mask(sensor.centroid, sensor.valid_mask)
if not args.no_viz:
runtime_timer.start("viz")
fps, flow, vnormal, vshear, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = sensor.read_info(
sensor.info.FPS,
sensor.info.VRAW,
sensor.info.VNORMAL,
sensor.info.VSHEAR,
sensor.info.IMG,
sensor.info.CONTOUR,
sensor.info.CENTROID,
sensor.info.DEPTH,
sensor.info.FNORMAL,
sensor.info.FSHEARX,
sensor.info.FSHEARY,
)
arrows = orisys.util.draw_arrows(
img,
flow,
threshold=0.4,
grid_spacing=10,
arrow_scale=1.0,
)
contact_img = orisys.util.draw_contact(img, contour, centroid)
depth_norm = depth_map
if depth_norm.size > 0 and depth_norm.max() > depth_norm.min():
depth_u8 = ((depth_norm - depth_norm.min()) / (depth_norm.max() - depth_norm.min()) * 255).astype(np.uint8)
else:
depth_u8 = np.zeros_like(depth_norm, dtype=np.uint8)
depth_colored = cv2.applyColorMap(depth_u8, cv2.COLORMAP_JET)
display_mask = sensor.valid_mask
arrows = apply_display_mask(arrows, display_mask, border_value=(0, 0, 0))
contact_img = apply_display_mask(contact_img, display_mask, border_value=(0, 0, 0))
depth_colored = apply_display_mask(depth_colored, display_mask, border_value=(0, 0, 0))
arrows = overlay_runtime_status(arrows, sensor, centroid=centroid)
contact_ok = contact_inside_mask(centroid, display_mask)
dashboard = make_tiled_dashboard(
[
("Original", frame_source),
("UV Map | Distortion", uv_reference),
("UV Unrolled", unrolled),
("Flow", arrows),
],
tile_width=640,
tile_height=360,
)
cv2.imshow("UV Unroll Dashboard", dashboard)
runtime_timer.stop("viz")
print(
f"FPS={fps:.2f}, 法向力={fnormal:.4f}, 切向力X={fshearx:.4f}, "
f"切向力Y={fsheary:.4f}, 展开图尺寸={unrolled.shape[:2]}, "
f"solver={sensor.masked_poisson_solver}, "
f"mask={sensor.mask_stats['coverage']:.3f}, "
f"flow(valid/invalid)=({sensor.mask_stats['flow_mean_valid']:.3f}/"
f"{sensor.mask_stats['flow_mean_invalid']:.3f}), "
f"contact_in_mask={contact_ok}, "
f"{print_runtime_benchmark(runtime_timer)}",
end="\r",
)
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
print("\n正在退出程序...")
break
if key == ord("r"):
sensor.reset()
print("\n追踪器已重置。")
finally:
cap.release()
sensor.disconnect()
cv2.destroyAllWindows()
if __name__ == "__main__":
main()