split example visualizaiton pipeline
This commit is contained in:
@@ -5,7 +5,7 @@ from scipy import ndimage
|
||||
import cv2
|
||||
from numba import njit
|
||||
|
||||
@njit(cache=True)
|
||||
@njit(cache=False)
|
||||
def _blend_kernel(blend_patch, wsum_patch, cov_patch, src_patch, w_patch):
|
||||
"""Fused incremental weighted-average composite (numba-accelerated)."""
|
||||
sh = src_patch.shape[0]
|
||||
|
||||
@@ -11,6 +11,7 @@ __author__ = "Orisys"
|
||||
from .sensor import Sensor
|
||||
from . import util
|
||||
from . import configs
|
||||
from . import finger
|
||||
|
||||
CompoundEyeSensor = Sensor
|
||||
CameraSensor = Sensor
|
||||
@@ -28,6 +29,9 @@ __all__ = [
|
||||
# util functions
|
||||
'util',
|
||||
|
||||
# finger runtime helpers
|
||||
'finger',
|
||||
|
||||
# configs utilities functions
|
||||
'list_config',
|
||||
'export_config'
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Finger-specific UV runtime helpers built on top of the Orisys SDK."""
|
||||
|
||||
from .exports import (
|
||||
DEFAULT_FINGER_EXPORT_DIR,
|
||||
DEFAULT_FINGER_MAP_CACHE,
|
||||
FingerExportBundle,
|
||||
FingerExportPaths,
|
||||
load_finger_export_bundle,
|
||||
resolve_export_paths,
|
||||
)
|
||||
from .mesh import (
|
||||
DEFAULT_SURFACE_COLOR,
|
||||
UVSurfaceMesh,
|
||||
build_uv_surface_mesh,
|
||||
compute_uv_surface_frames,
|
||||
deform_mesh_vertices,
|
||||
flow_magnitude_vertex_colors,
|
||||
lift_uv_deformation_to_3d,
|
||||
lift_uv_flow_to_3d,
|
||||
mesh_uv_to_world_scale,
|
||||
scalar_map_vertex_colors,
|
||||
sample_field_at_mesh_vertices,
|
||||
)
|
||||
from .runtime import (
|
||||
FingerGeometry,
|
||||
FingerRuntime,
|
||||
TactileProcessor,
|
||||
open_source,
|
||||
resolve_finger_geometry,
|
||||
)
|
||||
from .viewer import UVMeshViewer, init_uv_mesh_viewer, update_uv_mesh_viewer
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_FINGER_EXPORT_DIR",
|
||||
"DEFAULT_FINGER_MAP_CACHE",
|
||||
"DEFAULT_SURFACE_COLOR",
|
||||
"FingerExportBundle",
|
||||
"FingerExportPaths",
|
||||
"FingerGeometry",
|
||||
"FingerRuntime",
|
||||
"TactileProcessor",
|
||||
"UVSurfaceMesh",
|
||||
"UVMeshViewer",
|
||||
"build_uv_surface_mesh",
|
||||
"compute_uv_surface_frames",
|
||||
"deform_mesh_vertices",
|
||||
"flow_magnitude_vertex_colors",
|
||||
"init_uv_mesh_viewer",
|
||||
"lift_uv_deformation_to_3d",
|
||||
"lift_uv_flow_to_3d",
|
||||
"load_finger_export_bundle",
|
||||
"mesh_uv_to_world_scale",
|
||||
"open_source",
|
||||
"resolve_export_paths",
|
||||
"resolve_finger_geometry",
|
||||
"sample_field_at_mesh_vertices",
|
||||
"scalar_map_vertex_colors",
|
||||
"update_uv_mesh_viewer",
|
||||
]
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from ..unroll import (
|
||||
compute_uv_distortion_metrics,
|
||||
load_exported_data,
|
||||
recommend_edge_expand,
|
||||
scaled_map_cache_path,
|
||||
)
|
||||
|
||||
DEFAULT_FINGER_EXPORT_DIR = "assets/finger/30fps"
|
||||
DEFAULT_FINGER_MAP_CACHE = f"{DEFAULT_FINGER_EXPORT_DIR}/unroll_map_uv.npz"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FingerExportPaths:
|
||||
"""Resolved Blender export bundle paths for finger UV pipelines."""
|
||||
|
||||
export_dir: Path
|
||||
mesh: Path
|
||||
intrinsics: Path
|
||||
pose: Path
|
||||
|
||||
def as_tuple(self) -> tuple[str, str, str]:
|
||||
return (str(self.mesh), str(self.intrinsics), str(self.pose))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FingerExportBundle:
|
||||
"""Loaded Blender export data and UV map-cache settings."""
|
||||
|
||||
export_paths: FingerExportPaths
|
||||
data: dict
|
||||
map_cache: str
|
||||
edge_expand: float
|
||||
|
||||
|
||||
def _candidate_export_dirs(export_dir) -> list[Path]:
|
||||
raw = Path(export_dir).expanduser()
|
||||
if raw.is_absolute():
|
||||
return [raw]
|
||||
|
||||
here = Path(__file__).resolve()
|
||||
candidates = [
|
||||
Path.cwd() / raw, # repo root / user cwd
|
||||
here.parents[1] / raw, # packaged wheel: orisys/assets/...
|
||||
here.parents[3] / raw, # editable install: repo/assets/...
|
||||
]
|
||||
unique: list[Path] = []
|
||||
for candidate in candidates:
|
||||
resolved = candidate.resolve()
|
||||
if resolved not in unique:
|
||||
unique.append(resolved)
|
||||
return unique
|
||||
|
||||
|
||||
def resolve_export_paths(export_dir) -> FingerExportPaths:
|
||||
"""Return Blender export paths expected inside one folder."""
|
||||
checked: list[str] = []
|
||||
for root in _candidate_export_dirs(export_dir):
|
||||
paths = FingerExportPaths(
|
||||
export_dir=root,
|
||||
mesh=root / "mesh.npz",
|
||||
intrinsics=root / "camera_intrinsics.npz",
|
||||
pose=root / "world_to_cam.npy",
|
||||
)
|
||||
missing = [name for name, path in vars(paths).items() if name != "export_dir" and not path.is_file()]
|
||||
if not missing:
|
||||
return paths
|
||||
checked.append(f"{root} (missing: {', '.join(missing)})")
|
||||
|
||||
raise FileNotFoundError(
|
||||
f"Missing export file(s) for finger bundle '{export_dir}'. "
|
||||
f"Checked: {'; '.join(checked)}"
|
||||
)
|
||||
|
||||
|
||||
def load_finger_export_bundle(
|
||||
export_dir,
|
||||
*,
|
||||
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
||||
source_scale=1.0,
|
||||
) -> FingerExportBundle:
|
||||
"""Resolve export paths, load bundle data, and pick the UV map cache path."""
|
||||
export_paths = resolve_export_paths(export_dir)
|
||||
if Path(map_cache) == Path(DEFAULT_FINGER_MAP_CACHE):
|
||||
map_cache = str(export_paths.export_dir / "unroll_map_uv.npz")
|
||||
map_cache = scaled_map_cache_path(map_cache, source_scale)
|
||||
mesh_path, intrinsics_path, pose_path = export_paths.as_tuple()
|
||||
data = load_exported_data(mesh_path, intrinsics_path, pose_path)
|
||||
distortion = compute_uv_distortion_metrics(data)
|
||||
edge_expand = recommend_edge_expand(distortion)
|
||||
return FingerExportBundle(
|
||||
export_paths=export_paths,
|
||||
data=data,
|
||||
map_cache=map_cache,
|
||||
edge_expand=float(edge_expand),
|
||||
)
|
||||
@@ -0,0 +1,271 @@
|
||||
"""UV-domain finger surface mesh built from world_xyz and tactile field coloring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Default grey-white surface tone (linear RGB, 0-1).
|
||||
DEFAULT_SURFACE_COLOR = (0.88, 0.88, 0.90)
|
||||
|
||||
|
||||
class UVSurfaceMesh:
|
||||
"""Triangle mesh built from a UV flatten grid and world_xyz surface map."""
|
||||
|
||||
__slots__ = (
|
||||
"rest_vertices",
|
||||
"triangles",
|
||||
"vertex_ys",
|
||||
"vertex_xs",
|
||||
"index_map",
|
||||
"valid_mask",
|
||||
"shape",
|
||||
"world_xyz",
|
||||
"base_colors",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rest_vertices,
|
||||
triangles,
|
||||
vertex_ys,
|
||||
vertex_xs,
|
||||
index_map,
|
||||
valid_mask,
|
||||
world_xyz,
|
||||
base_colors,
|
||||
):
|
||||
self.rest_vertices = rest_vertices
|
||||
self.triangles = triangles
|
||||
self.vertex_ys = vertex_ys
|
||||
self.vertex_xs = vertex_xs
|
||||
self.index_map = index_map
|
||||
self.valid_mask = valid_mask
|
||||
self.shape = valid_mask.shape
|
||||
self.world_xyz = world_xyz
|
||||
self.base_colors = base_colors
|
||||
|
||||
|
||||
def build_uv_surface_mesh(
|
||||
world_xyz: np.ndarray,
|
||||
valid_mask: np.ndarray,
|
||||
base_color=DEFAULT_SURFACE_COLOR,
|
||||
) -> UVSurfaceMesh:
|
||||
"""Build a triangle mesh from UV grid world_xyz and valid_mask.
|
||||
|
||||
Each valid UV pixel becomes one vertex. Adjacent valid 2x2 cells form two
|
||||
triangles. Invalid or non-finite vertices are excluded.
|
||||
"""
|
||||
world_xyz = np.asarray(world_xyz, dtype=np.float64)
|
||||
valid_mask = np.asarray(valid_mask, dtype=bool)
|
||||
if world_xyz.shape[:2] != valid_mask.shape:
|
||||
raise ValueError("world_xyz and valid_mask must share the same HxW shape")
|
||||
|
||||
h, w = valid_mask.shape
|
||||
finite = np.all(np.isfinite(world_xyz), axis=2)
|
||||
vertex_mask = valid_mask & finite
|
||||
|
||||
index_map = np.full((h, w), -1, dtype=np.int32)
|
||||
ys, xs = np.nonzero(vertex_mask)
|
||||
num_vertices = len(ys)
|
||||
index_map[ys, xs] = np.arange(num_vertices, dtype=np.int32)
|
||||
|
||||
rest_vertices = world_xyz[ys, xs].astype(np.float64)
|
||||
color = np.asarray(base_color, dtype=np.float64).reshape(1, 3)
|
||||
base_colors = np.tile(color, (num_vertices, 1))
|
||||
|
||||
triangles = []
|
||||
for y in range(h - 1):
|
||||
for x in range(w - 1):
|
||||
i00 = index_map[y, x]
|
||||
i10 = index_map[y, x + 1]
|
||||
i01 = index_map[y + 1, x]
|
||||
i11 = index_map[y + 1, x + 1]
|
||||
if min(i00, i10, i01, i11) < 0:
|
||||
continue
|
||||
triangles.append((i00, i10, i01))
|
||||
triangles.append((i10, i11, i01))
|
||||
|
||||
if not triangles:
|
||||
raise ValueError("UV surface mesh has no valid triangles.")
|
||||
|
||||
return UVSurfaceMesh(
|
||||
rest_vertices=rest_vertices,
|
||||
triangles=np.asarray(triangles, dtype=np.int32),
|
||||
vertex_ys=ys.astype(np.int32),
|
||||
vertex_xs=xs.astype(np.int32),
|
||||
index_map=index_map,
|
||||
valid_mask=vertex_mask,
|
||||
world_xyz=world_xyz,
|
||||
base_colors=base_colors,
|
||||
)
|
||||
|
||||
|
||||
def compute_uv_surface_frames(world_xyz: np.ndarray, valid_mask: np.ndarray):
|
||||
"""Estimate tangent frame and normals on the UV surface grid."""
|
||||
valid_mask = np.asarray(valid_mask, dtype=bool)
|
||||
filled = np.asarray(world_xyz, dtype=np.float64).copy()
|
||||
invalid = ~valid_mask | ~np.all(np.isfinite(filled), axis=2)
|
||||
filled[invalid] = 0.0
|
||||
|
||||
d_y = np.gradient(filled, axis=0)
|
||||
d_x = np.gradient(filled, axis=1)
|
||||
normals = np.cross(d_x, d_y)
|
||||
norm = np.linalg.norm(normals, axis=2, keepdims=True)
|
||||
normals = np.divide(normals, norm, out=np.zeros_like(normals), where=norm > 1e-8)
|
||||
d_x[invalid] = 0.0
|
||||
d_y[invalid] = 0.0
|
||||
normals[invalid] = 0.0
|
||||
return d_x, d_y, normals
|
||||
|
||||
|
||||
def mesh_uv_to_world_scale(world_xyz: np.ndarray, valid_mask: np.ndarray) -> float:
|
||||
"""Approximate world-units per UV pixel for deformation scaling."""
|
||||
points = world_xyz[valid_mask]
|
||||
if len(points) == 0:
|
||||
return 1e-3
|
||||
extent = np.nanmax(points, axis=0) - np.nanmin(points, axis=0)
|
||||
mesh_span = float(np.max(extent))
|
||||
h, w = world_xyz.shape[:2]
|
||||
return mesh_span / max(h, w, 1)
|
||||
|
||||
|
||||
def lift_uv_deformation_to_3d(
|
||||
vshear: np.ndarray,
|
||||
vnormal: np.ndarray,
|
||||
tangent_x: np.ndarray,
|
||||
tangent_y: np.ndarray,
|
||||
normals: np.ndarray,
|
||||
normal_gain: float = 1.0,
|
||||
) -> np.ndarray:
|
||||
"""Lift UV-space shear/normal HHD fields to 3D tangent-plane displacement."""
|
||||
tx_norm = np.linalg.norm(tangent_x, axis=2, keepdims=True)
|
||||
ty_norm = np.linalg.norm(tangent_y, axis=2, keepdims=True)
|
||||
t1 = np.divide(tangent_x, tx_norm, out=np.zeros_like(tangent_x), where=tx_norm > 1e-8)
|
||||
t2 = np.divide(tangent_y, ty_norm, out=np.zeros_like(tangent_y), where=ty_norm > 1e-8)
|
||||
|
||||
shear_3d = vshear[:, :, 0:1] * t1 + vshear[:, :, 1:2] * t2
|
||||
normal_mag = np.linalg.norm(vnormal, axis=2, keepdims=True) * float(normal_gain)
|
||||
normal_3d = normal_mag * normals
|
||||
|
||||
normal_component = np.sum(shear_3d * normals, axis=2, keepdims=True)
|
||||
shear_tangent = shear_3d - normal_component * normals
|
||||
return shear_tangent + normal_3d
|
||||
|
||||
|
||||
def lift_uv_flow_to_3d(
|
||||
flow: np.ndarray,
|
||||
tangent_x: np.ndarray,
|
||||
tangent_y: np.ndarray,
|
||||
normals: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""Lift raw 2D optical flow on the UV grid to tangent-plane 3D displacement."""
|
||||
tx_norm = np.linalg.norm(tangent_x, axis=2, keepdims=True)
|
||||
ty_norm = np.linalg.norm(tangent_y, axis=2, keepdims=True)
|
||||
t1 = np.divide(tangent_x, tx_norm, out=np.zeros_like(tangent_x), where=tx_norm > 1e-8)
|
||||
t2 = np.divide(tangent_y, ty_norm, out=np.zeros_like(tangent_y), where=ty_norm > 1e-8)
|
||||
flow_3d = flow[:, :, 0:1] * t1 + flow[:, :, 1:2] * t2
|
||||
normal_component = np.sum(flow_3d * normals, axis=2, keepdims=True)
|
||||
return flow_3d - normal_component * normals
|
||||
|
||||
|
||||
def sample_field_at_mesh_vertices(field: np.ndarray, mesh: UVSurfaceMesh) -> np.ndarray:
|
||||
"""Sample a per-pixel UV field at mesh vertex locations."""
|
||||
sampled = field[mesh.vertex_ys, mesh.vertex_xs]
|
||||
if sampled.ndim == 1:
|
||||
return sampled.astype(np.float64)
|
||||
return sampled.astype(np.float64)
|
||||
|
||||
|
||||
def flow_magnitude_vertex_colors(
|
||||
flow: np.ndarray | None,
|
||||
mesh: UVSurfaceMesh,
|
||||
*,
|
||||
base_color=DEFAULT_SURFACE_COLOR,
|
||||
max_magnitude: float | None = None,
|
||||
threshold: float = 0.0,
|
||||
) -> np.ndarray:
|
||||
"""Color mesh vertices from UV-space optical-flow magnitude.
|
||||
|
||||
Magnitude matches ``orisys.util.draw_arrows``: ``2 * ||flow||``.
|
||||
Vertices below ``threshold`` keep the base grey-white surface color.
|
||||
"""
|
||||
colors = mesh.base_colors.copy()
|
||||
if flow is None or flow.size == 0:
|
||||
return colors
|
||||
|
||||
flow = np.asarray(flow, dtype=np.float64)
|
||||
mag = 2.0 * np.linalg.norm(flow, axis=2)
|
||||
vertex_mag = mag[mesh.vertex_ys, mesh.vertex_xs]
|
||||
finite = np.isfinite(vertex_mag)
|
||||
active = finite & (vertex_mag >= float(threshold))
|
||||
if not np.any(active):
|
||||
return colors
|
||||
|
||||
active_mag = vertex_mag[active]
|
||||
if max_magnitude is None or max_magnitude <= 0:
|
||||
max_magnitude = max(float(np.percentile(active_mag, 95)), 1e-6)
|
||||
|
||||
value = np.clip(vertex_mag / max_magnitude, 0.0, 1.0)
|
||||
heat = np.column_stack(
|
||||
(
|
||||
value,
|
||||
np.clip(1.0 - np.abs(value - 0.5) * 2.0, 0.0, 1.0),
|
||||
1.0 - value,
|
||||
)
|
||||
).astype(np.float64)
|
||||
base = np.asarray(base_color, dtype=np.float64)
|
||||
colors[active] = 0.25 * base + 0.75 * heat[active]
|
||||
return colors
|
||||
|
||||
|
||||
def scalar_map_vertex_colors(
|
||||
scalar_map: np.ndarray | None,
|
||||
mesh: UVSurfaceMesh,
|
||||
*,
|
||||
base_color=DEFAULT_SURFACE_COLOR,
|
||||
value_min: float | None = None,
|
||||
value_max: float | None = None,
|
||||
threshold: float = 0.0,
|
||||
) -> np.ndarray:
|
||||
"""Color mesh vertices from a scalar UV map such as depth/divergence."""
|
||||
colors = mesh.base_colors.copy()
|
||||
if scalar_map is None or scalar_map.size == 0:
|
||||
return colors
|
||||
|
||||
scalar_map = np.asarray(scalar_map, dtype=np.float64)
|
||||
vertex_values = scalar_map[mesh.vertex_ys, mesh.vertex_xs]
|
||||
finite = np.isfinite(vertex_values)
|
||||
active = finite & (vertex_values > float(threshold))
|
||||
if not np.any(active):
|
||||
return colors
|
||||
|
||||
active_values = vertex_values[active]
|
||||
if value_min is None:
|
||||
value_min = float(np.percentile(active_values, 5))
|
||||
if value_max is None:
|
||||
value_max = float(np.percentile(active_values, 95))
|
||||
if value_max <= value_min:
|
||||
value_max = value_min + 1e-6
|
||||
|
||||
value = np.clip((vertex_values - value_min) / (value_max - value_min), 0.0, 1.0)
|
||||
heat = np.column_stack(
|
||||
(
|
||||
value,
|
||||
np.clip(1.0 - np.abs(value - 0.5) * 2.0, 0.0, 1.0),
|
||||
1.0 - value,
|
||||
)
|
||||
).astype(np.float64)
|
||||
base = np.asarray(base_color, dtype=np.float64)
|
||||
colors[active] = 0.25 * base + 0.75 * heat[active]
|
||||
return colors
|
||||
|
||||
|
||||
def deform_mesh_vertices(
|
||||
mesh: UVSurfaceMesh,
|
||||
displacement_map: np.ndarray,
|
||||
pixel_to_world: float,
|
||||
deform_scale: float = 1.0,
|
||||
) -> np.ndarray:
|
||||
"""Apply lifted 3D displacement to rest mesh vertices."""
|
||||
disp = sample_field_at_mesh_vertices(displacement_map, mesh)
|
||||
return mesh.rest_vertices + disp * float(pixel_to_world) * float(deform_scale)
|
||||
@@ -0,0 +1,328 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from ..configs import load_config
|
||||
from ..frame_processors import UVUnrollFrameProcessor
|
||||
from ..sensor import Sensor
|
||||
from ..unroll import (
|
||||
build_or_load_domain,
|
||||
load_calib_json,
|
||||
resolve_uv_runtime_sizes,
|
||||
scale_camera_geometry,
|
||||
)
|
||||
from .exports import (
|
||||
DEFAULT_FINGER_EXPORT_DIR,
|
||||
DEFAULT_FINGER_MAP_CACHE,
|
||||
FingerExportPaths,
|
||||
load_finger_export_bundle,
|
||||
)
|
||||
|
||||
|
||||
def normalize_video_source(video):
|
||||
"""Accept camera index, numeric string, or filesystem video path."""
|
||||
if video is None:
|
||||
return 0
|
||||
if isinstance(video, str):
|
||||
stripped = video.strip().strip("/\\")
|
||||
if not stripped:
|
||||
return 0
|
||||
if stripped.isdigit():
|
||||
return int(stripped)
|
||||
return stripped
|
||||
return video
|
||||
|
||||
|
||||
def open_source(video, config):
|
||||
capture = cv2.VideoCapture(normalize_video_source(video))
|
||||
capture.set(cv2.CAP_PROP_FRAME_WIDTH, config.get("camera", {}).get("resolution_raw")[0])
|
||||
capture.set(cv2.CAP_PROP_FRAME_HEIGHT, config.get("camera", {}).get("resolution_raw")[1])
|
||||
capture.set(cv2.CAP_PROP_FPS, config.get("camera", {}).get("fps", 30))
|
||||
capture.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter.fourcc('M', 'J', 'P', 'G'))
|
||||
return capture
|
||||
|
||||
|
||||
class TactileProcessor:
|
||||
"""Narrow tactile-analysis wrapper around ``Sensor`` external-frame mode."""
|
||||
|
||||
def __init__(self, sensor: Sensor, *, default_valid_mask=None):
|
||||
self._sensor = sensor
|
||||
self._default_valid_mask = default_valid_mask
|
||||
|
||||
@property
|
||||
def raw_sensor(self) -> Sensor:
|
||||
"""Escape hatch for legacy callers that still need the full ``Sensor``."""
|
||||
return self._sensor
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
return self._sensor.info
|
||||
|
||||
@property
|
||||
def frame(self):
|
||||
return self._sensor.frame
|
||||
|
||||
@property
|
||||
def img(self):
|
||||
return self._sensor.img
|
||||
|
||||
@property
|
||||
def flow(self):
|
||||
return self._sensor.flow
|
||||
|
||||
@property
|
||||
def vnormal(self):
|
||||
return self._sensor.vnormal
|
||||
|
||||
@property
|
||||
def vshear(self):
|
||||
return self._sensor.vshear
|
||||
|
||||
@property
|
||||
def depth_map(self):
|
||||
return self._sensor.depth_map
|
||||
|
||||
@property
|
||||
def mask_stats(self):
|
||||
return self._sensor.mask_stats
|
||||
|
||||
@property
|
||||
def valid_mask(self):
|
||||
return self._sensor.valid_mask
|
||||
|
||||
def push_frame(self, frame, *, already_gray=False, valid_mask=None):
|
||||
"""Feed one processed tactile frame into the analysis pipeline."""
|
||||
mask = self._default_valid_mask if valid_mask is None else valid_mask
|
||||
return self._sensor.get_img(
|
||||
frame=frame,
|
||||
already_processed=True,
|
||||
already_gray=already_gray,
|
||||
valid_mask=mask,
|
||||
)
|
||||
|
||||
def warmup(self):
|
||||
return self._sensor.warmup_deformation()
|
||||
|
||||
def compute_deformation(self, *args, **kwargs):
|
||||
return self._sensor.compute_deformation(*args, **kwargs)
|
||||
|
||||
def compute_contact(self, *args, **kwargs):
|
||||
return self._sensor.compute_contact(*args, **kwargs)
|
||||
|
||||
def read_info(self, *args):
|
||||
return self._sensor.read_info(*args)
|
||||
|
||||
def reset(self):
|
||||
return self._sensor.reset()
|
||||
|
||||
def disconnect(self):
|
||||
return self._sensor.disconnect()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FingerGeometry:
|
||||
"""Resolved one-time geometry/bootstrap state for a finger UV runtime."""
|
||||
|
||||
config: dict
|
||||
camera_config: dict
|
||||
export_paths: FingerExportPaths
|
||||
data: dict
|
||||
source_K: np.ndarray
|
||||
dist_coeffs: np.ndarray | None
|
||||
native_width: int
|
||||
native_height: int
|
||||
source_width: int
|
||||
source_height: int
|
||||
out_width: int
|
||||
out_height: int
|
||||
source_scale: float
|
||||
map_cache: str
|
||||
edge_expand: float
|
||||
|
||||
|
||||
def resolve_finger_geometry(
|
||||
*,
|
||||
export_dir,
|
||||
config="./config/dd02-ov.json",
|
||||
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
||||
source_scale=None,
|
||||
calib=None,
|
||||
) -> FingerGeometry:
|
||||
"""Resolve config/export/camera geometry for a UV finger runtime."""
|
||||
config = load_config(config)
|
||||
camera_config = config.get("camera", {})
|
||||
out_width, out_height, source_scale = resolve_uv_runtime_sizes(
|
||||
camera_config,
|
||||
source_scale=source_scale,
|
||||
)
|
||||
bundle = load_finger_export_bundle(
|
||||
export_dir,
|
||||
map_cache=map_cache,
|
||||
source_scale=source_scale,
|
||||
)
|
||||
|
||||
if calib:
|
||||
source_K, dist_coeffs, img_size = load_calib_json(calib)
|
||||
native_width, native_height = img_size
|
||||
else:
|
||||
source_K = bundle.data["K"]
|
||||
dist_coeffs = None
|
||||
native_width = int(bundle.data["width"])
|
||||
native_height = int(bundle.data["height"])
|
||||
|
||||
source_K, source_width, source_height = scale_camera_geometry(
|
||||
source_K,
|
||||
native_width,
|
||||
native_height,
|
||||
source_scale,
|
||||
)
|
||||
|
||||
return FingerGeometry(
|
||||
config=config,
|
||||
camera_config=camera_config,
|
||||
export_paths=bundle.export_paths,
|
||||
data=bundle.data,
|
||||
source_K=source_K,
|
||||
dist_coeffs=dist_coeffs,
|
||||
native_width=native_width,
|
||||
native_height=native_height,
|
||||
source_width=source_width,
|
||||
source_height=source_height,
|
||||
out_width=out_width,
|
||||
out_height=out_height,
|
||||
source_scale=float(source_scale),
|
||||
map_cache=bundle.map_cache,
|
||||
edge_expand=bundle.edge_expand,
|
||||
)
|
||||
|
||||
|
||||
class FingerRuntime:
|
||||
"""Reusable UV finger runtime composed from capture, UV remap, and tactile analysis."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
geometry: FingerGeometry,
|
||||
domain,
|
||||
tactile: TactileProcessor,
|
||||
frame_processor: UVUnrollFrameProcessor,
|
||||
capture,
|
||||
):
|
||||
self.geometry = geometry
|
||||
self.domain = domain
|
||||
self.tactile = tactile
|
||||
self.sensor = tactile
|
||||
self.frame_processor = frame_processor
|
||||
self.capture = capture
|
||||
|
||||
@classmethod
|
||||
def open(
|
||||
cls,
|
||||
*,
|
||||
export_dir=DEFAULT_FINGER_EXPORT_DIR,
|
||||
config="./config/dd02-ov.json",
|
||||
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
||||
source_scale=None,
|
||||
calib=None,
|
||||
video=0,
|
||||
camera_id=None,
|
||||
flip_source_vertical=True,
|
||||
verbose=False,
|
||||
mirror=False,
|
||||
) -> "FingerRuntime":
|
||||
"""Open a live finger runtime from export bundle, config, and video source."""
|
||||
if camera_id is not None:
|
||||
video = camera_id
|
||||
video = normalize_video_source(video)
|
||||
geometry = resolve_finger_geometry(
|
||||
export_dir=export_dir,
|
||||
config=config,
|
||||
map_cache=map_cache,
|
||||
source_scale=source_scale,
|
||||
calib=calib,
|
||||
)
|
||||
|
||||
mesh_path, intrinsics_path, pose_path = geometry.export_paths.as_tuple()
|
||||
domain = build_or_load_domain(
|
||||
mesh_path,
|
||||
intrinsics_path,
|
||||
pose_path,
|
||||
geometry.map_cache,
|
||||
geometry.source_K,
|
||||
geometry.source_width,
|
||||
geometry.source_height,
|
||||
geometry.out_width,
|
||||
geometry.out_height,
|
||||
edge_expand=geometry.edge_expand,
|
||||
include_surface=True,
|
||||
verbose=verbose,
|
||||
source_scale=geometry.source_scale,
|
||||
)
|
||||
domain.remap.ensure_fast_maps()
|
||||
|
||||
capture = open_source(video, geometry.config)
|
||||
if not capture.isOpened():
|
||||
raise RuntimeError("Cannot open camera/video source")
|
||||
|
||||
sensor = Sensor("external", config_name=config, verbose=verbose, mirror=mirror)
|
||||
frame_processor = UVUnrollFrameProcessor(
|
||||
domain.remap,
|
||||
camera_matrix=geometry.source_K if geometry.dist_coeffs is not None else None,
|
||||
dist_coeffs=geometry.dist_coeffs,
|
||||
source_width=geometry.source_width,
|
||||
source_height=geometry.source_height,
|
||||
flip_source_vertical=flip_source_vertical,
|
||||
)
|
||||
tactile = TactileProcessor(sensor, default_valid_mask=frame_processor.valid_mask)
|
||||
return cls(
|
||||
geometry=geometry,
|
||||
domain=domain,
|
||||
tactile=tactile,
|
||||
frame_processor=frame_processor,
|
||||
capture=capture,
|
||||
)
|
||||
|
||||
@property
|
||||
def valid_mask(self):
|
||||
return self.frame_processor.valid_mask
|
||||
|
||||
def read_source(self):
|
||||
return self.capture.read()
|
||||
|
||||
def process_frame(self, frame, *, color=False):
|
||||
"""Prepare, unroll, and feed one source frame into the tactile processor."""
|
||||
if color:
|
||||
prepared = self.frame_processor.prepare_source(frame)
|
||||
unrolled = self.frame_processor.flatten_prepared(prepared)
|
||||
self.tactile.push_frame(unrolled)
|
||||
return unrolled
|
||||
|
||||
prepared_gray = self.frame_processor.prepare_source_gray(frame)
|
||||
unrolled = self.frame_processor.flatten_prepared_gray(prepared_gray)
|
||||
self.tactile.push_frame(unrolled, already_gray=True)
|
||||
return unrolled
|
||||
|
||||
def grab_and_process(self, *, color=False):
|
||||
ok, frame = self.read_source()
|
||||
if not ok:
|
||||
return False, None
|
||||
return True, self.process_frame(frame, color=color)
|
||||
|
||||
def get_img(self, *, color=False):
|
||||
"""Sensor-style alias: capture, unroll, and return the tactile frame or None."""
|
||||
ok, unrolled = self.grab_and_process(color=color)
|
||||
return unrolled if ok else None
|
||||
|
||||
def warmup(self, *, color=False):
|
||||
ok, unrolled = self.grab_and_process(color=color)
|
||||
if ok:
|
||||
self.tactile.warmup()
|
||||
return ok, unrolled
|
||||
|
||||
def close(self):
|
||||
if self.capture is not None:
|
||||
self.capture.release()
|
||||
self.tactile.disconnect()
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Open3D viewer for UV-domain finger surface meshes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .mesh import (
|
||||
UVSurfaceMesh,
|
||||
flow_magnitude_vertex_colors,
|
||||
scalar_map_vertex_colors,
|
||||
)
|
||||
|
||||
|
||||
def init_uv_mesh_viewer(
|
||||
mesh: UVSurfaceMesh,
|
||||
vertices: np.ndarray | None = None,
|
||||
vertex_colors: np.ndarray | None = None,
|
||||
window_name: str = "UV Finger Mesh 3D",
|
||||
):
|
||||
"""Create Open3D viewer with an unlit triangle mesh."""
|
||||
import open3d as o3d
|
||||
|
||||
if vertices is None:
|
||||
vertices = mesh.rest_vertices
|
||||
if vertex_colors is None:
|
||||
vertex_colors = mesh.base_colors
|
||||
|
||||
vis = o3d.visualization.Visualizer()
|
||||
vis.create_window(window_name=window_name, width=1280, height=720)
|
||||
|
||||
tri_mesh = o3d.geometry.TriangleMesh()
|
||||
tri_mesh.vertices = o3d.utility.Vector3dVector(vertices.astype(np.float64))
|
||||
tri_mesh.triangles = o3d.utility.Vector3iVector(mesh.triangles.astype(np.int32))
|
||||
tri_mesh.vertex_colors = o3d.utility.Vector3dVector(vertex_colors.astype(np.float64))
|
||||
tri_mesh.compute_vertex_normals()
|
||||
vis.add_geometry(tri_mesh)
|
||||
|
||||
render_option = vis.get_render_option()
|
||||
render_option.mesh_show_back_face = True
|
||||
if hasattr(render_option, "light_on"):
|
||||
render_option.light_on = False
|
||||
render_option.background_color = np.array([0.05, 0.05, 0.08])
|
||||
vis.poll_events()
|
||||
vis.update_renderer()
|
||||
return vis, tri_mesh, o3d
|
||||
|
||||
|
||||
def update_uv_mesh_viewer(
|
||||
vis,
|
||||
tri_mesh,
|
||||
o3d,
|
||||
vertices: np.ndarray,
|
||||
vertex_colors: np.ndarray,
|
||||
):
|
||||
"""Update mesh vertices/colors."""
|
||||
tri_mesh.vertices = o3d.utility.Vector3dVector(vertices.astype(np.float64))
|
||||
tri_mesh.vertex_colors = o3d.utility.Vector3dVector(vertex_colors.astype(np.float64))
|
||||
tri_mesh.compute_vertex_normals()
|
||||
vis.update_geometry(tri_mesh)
|
||||
|
||||
vis.poll_events()
|
||||
vis.update_renderer()
|
||||
|
||||
|
||||
class UVMeshViewer:
|
||||
"""Stateful UV mesh 3D viewer driven by UV-space tactile fields."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mesh: UVSurfaceMesh,
|
||||
*,
|
||||
max_magnitude: float | None = None,
|
||||
threshold: float = 0.0,
|
||||
color_mode: str = "flow",
|
||||
value_min: float | None = None,
|
||||
value_max: float | None = None,
|
||||
):
|
||||
self.mesh = mesh
|
||||
self.max_magnitude = max_magnitude
|
||||
self.threshold = threshold
|
||||
self.color_mode = color_mode
|
||||
self.value_min = value_min
|
||||
self.value_max = value_max
|
||||
self.vis = None
|
||||
self.tri_mesh = None
|
||||
self._o3d = None
|
||||
|
||||
def update(self, flow=None, scalar_map=None):
|
||||
"""Update viewer from UV-space maps. Returns False if window closed."""
|
||||
if self.color_mode == "depth":
|
||||
colors = scalar_map_vertex_colors(
|
||||
scalar_map,
|
||||
self.mesh,
|
||||
value_min=self.value_min,
|
||||
value_max=self.value_max,
|
||||
threshold=self.threshold,
|
||||
)
|
||||
else:
|
||||
colors = flow_magnitude_vertex_colors(
|
||||
flow,
|
||||
self.mesh,
|
||||
max_magnitude=self.max_magnitude,
|
||||
threshold=self.threshold,
|
||||
)
|
||||
vertices = self.mesh.rest_vertices
|
||||
|
||||
if self.vis is None:
|
||||
self.vis, self.tri_mesh, self._o3d = init_uv_mesh_viewer(
|
||||
self.mesh,
|
||||
vertices=vertices,
|
||||
vertex_colors=colors,
|
||||
)
|
||||
return True
|
||||
|
||||
if not self.vis.poll_events():
|
||||
return False
|
||||
|
||||
update_uv_mesh_viewer(
|
||||
self.vis,
|
||||
self.tri_mesh,
|
||||
self._o3d,
|
||||
vertices,
|
||||
colors,
|
||||
)
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
if self.vis is not None:
|
||||
self.vis.destroy_window()
|
||||
self.vis = None
|
||||
@@ -17,7 +17,10 @@ class MaskedSparseSolver:
|
||||
if rhs.size == 0:
|
||||
return numpy.zeros_like(f, dtype=numpy.float32)
|
||||
|
||||
sol_active = domain.factor(rhs) # (N,) reduced-system potential
|
||||
# MaskedDomain builds a positive diagonal / negative off-diagonal stencil
|
||||
# for (-Laplace). Negate the direct solve result so the masked sparse path
|
||||
# matches the FFT solver convention used elsewhere: Laplace(u) = rhs.
|
||||
sol_active = -domain.factor(rhs) # (N,) reduced-system potential
|
||||
return domain.scatter(sol_active, f)
|
||||
|
||||
def solve_pair(self, f0, f1, domain: MaskedDomain):
|
||||
@@ -28,9 +31,9 @@ class MaskedSparseSolver:
|
||||
return empty, empty.copy()
|
||||
|
||||
try:
|
||||
sol_pair = domain.factor(rhs_pair) # (N, 2) reduced-system potentials
|
||||
sol_pair = -domain.factor(rhs_pair) # (N, 2) reduced-system potentials
|
||||
except Exception:
|
||||
sol_pair = numpy.column_stack(
|
||||
sol_pair = -numpy.column_stack(
|
||||
[domain.factor(rhs_pair[:, 0]), domain.factor(rhs_pair[:, 1])]
|
||||
)
|
||||
return domain.scatter_pair(sol_pair, f0)
|
||||
|
||||
Reference in New Issue
Block a user