Files
orisys-sdk/src/orisys/sensor.py
T
2025-12-25 16:43:09 +08:00

524 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Stch Calibration OK
"""
import os
import numpy as np
from scipy import ndimage
import cv2
from .Stitcher import Stitcher
from .tracker import Tracker
from .nHHD import *
import time
from scipy.interpolate import griddata
from enum import Enum, auto
from typing import Optional, Tuple, Union, Any
from .getDensity import getFilteredDensity
from . import configs
class Sensor:
"""
Sensor helper that can decompose optical flow via Fourier methods.
Stores the decomposed components and global norms as attributes after compute().
"""
def __init__(self, vid_src, isstitch=False, verbose=True, cuda=True, config_name="prototype"):
"""
Initialize Sensor with video source and configuration.
Args:
vid_src: Video source (int for camera ID, str for file path, None for default camera)
isstitch: Whether to use stitching mode for multi-tile sensor (default: False)
verbose: Whether to print initialization logs (default: True)
cuda: Whether to use CUDA acceleration for optical flow & decomposition (default: True)
"""
try:
import cupy as cp
import cupyx.scipy.signal as cp_signal
self.CUPY_AVAILABLE = True
except ImportError:
self.CUPY_AVAILABLE = False
# Initialize settings
if vid_src is not None:
vid_src = vid_src
else:
print("User must specify a video source.")
self.vid_src, self.isstitch, self.cuda, self.verbose = vid_src, isstitch, cuda, verbose
self.config_name = config_name
# Initialize Sensor Configuration
self.get_config()
# Initialize components
self.__stitcher = Stitcher(isstitch=isstitch, index=self.config_index)
self.tracker = Tracker(adaptive=True, cuda=cuda)
self.hhd = None
if self.verbose:
print("Components initialized")
# Setup video capture
self.cap = cv2.VideoCapture(vid_src)
self.cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter.fourcc('M', 'J', 'P', 'G'))
self.cap.set(cv2.CAP_PROP_FPS, self.config_fps)
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.config_resolution_raw[0])
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.config_resolution_raw[1])
# Wait for first valid frame
if self.verbose:
print("Sensor connecting to video source...")
frame = None
while frame is None:
ret, frame = self.cap.read()
if not ret:
time.sleep(1)
if self.verbose:
print("Video source connected.")
# Initialize and calibrate
self.__find_stitch_boxes(frame, show=False)
self.__get_tiles(frame)
self.stitch_cal()
if self.verbose:
print("Sensor initialized and calibrated.")
# Initialize state variables
self.stitch_valid = False
self.n_boxes = None
self.stitch_calibration = []
self.img = self.flow = self.normal = self.shear = self.force = self.depth = None
self.img_filtered = None # For temporal noise reduction
self.fnormal=self.fshearx=self.fsheary=self.ftor=0.0
self.imgupdate = self.vupdate = False
# Logging state
self.last_log_time = time.time()
self.fps = 0
if self.verbose:
print("Settings:")
print(f" Video source: {vid_src}")
print(f" Stitch mode: {isstitch}")
print(f" CUDA acceleration: {cuda}")
print(f" Cupy available: {self.CUPY_AVAILABLE}")
print(f" OpenCV CUDA devices: {cv2.cuda.getCudaEnabledDeviceCount()}")
class info(Enum):
VRAW = auto()
VNORMAL = auto()
VSHEAR = auto()
FPS = auto()
TIMESTAMP = auto()
FNORMAL = auto()
FSHEARX = auto()
FSHEARY = auto()
DEPTH = auto()
def get_config(self):
"""
Load and return sensor configuration from JSON file.
Returns:
dict: Dictionary containing sensor configuration
"""
self.config = configs.load_config(self.config_name)
self.config_resolution_raw = self.config["camera"]["resolution_raw"]
self.config_fps = self.config["camera"]["fps"]
self.config_units = self.config["camera"]["units"]
self.config_resolution_stitch = self.config["camera"]["resolution_stitch"]
self.config_index = self.config["camera"]["index"]
self.config_pos_default = self.config["camera"]["pos_default"]
def get_img(self):
"""
Capture frame from video source, convert to grayscale, and perform stitching.
Returns:
np.ndarray: Stitched grayscale image (contiguous array) or None if capture fails
"""
ret, frame = self.cap.read()
if not ret:
print("Failed to get_img from video source.")
self.cap.release()
exit(1)
return None
gray_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
self.__stitcher.get_stitch_unit(gray_frame)
self.__get_tiles(gray_frame)
img = np.ascontiguousarray(self.__stitcher.stitch_rt_optimized())
img_resized = cv2.resize(img, (self.config_resolution_stitch[0], self.config_resolution_stitch[1]), interpolation=cv2.INTER_CUBIC)
# Apply temporal noise reduction using exponential moving average
# Blend current frame with previous filtered frame to reduce temporal noise
alpha = 0.7 # Weight for current frame (0.7 = 70% current, 30% previous)
if self.img_filtered is None:
# First frame: no previous frame to blend with
self.img_filtered = img_resized.astype(np.float32)
else:
# Temporal filtering: blend current frame with previous filtered frame
self.img_filtered = alpha * img_resized.astype(np.float32) + (1 - alpha) * self.img_filtered
# Convert back to uint8
self.img = self.img_filtered.astype(np.uint8)
self.imgupdate = True
return self.img
def compute_deformation(self):
"""
Track optical flow and decompose into force field.
Returns:
tuple: (flow, force) where:
- flow: Optical flow field (2D vector field)
- force: Decomposed force components from nHHD, ([normal, shear_x, shear_y, torque]). Note: torque is experimental.
"""
flow = self.tracker.track(self.img)
if hasattr(flow, 'download'):
flow = flow.download()
if self.hhd is None:
self.hhd = nHHD(grid=(flow.shape[0], flow.shape[1]), spacings=(18, 18))
self.hhd.decompose(flow)
self.imgupdate = False
self.vupdate = True
self.flow = flow
self.normal = self.hhd.get_normal()
self.shear = self.hhd.get_shear()
flag, [self.fnormal,self.fshearx,self.fsheary,self.ftor] = self.hhd.get_force(flow)
self.depth = getFilteredDensity(self.flow, use_cuda=self.cuda)
self.fps = 1/(time.time() - self.last_log_time)
self.last_log_time = time.time()
return self.flow
def read_info(self, *args: info) -> Tuple:
"""
Get sensor data according to specified info types.
Args:
*args: Variable number of info enum values specifying desired data
Returns:
Tuple: Data in the order specified by args. Each element corresponds to:
- VRAW: Raw optical flow field (np.ndarray)
- VNORMAL: Normal component of flow (np.ndarray)
- VSHEAR: Shear component of flow (np.ndarray)
- FNORMAL: Normal force magnitude (float)
- FSHEAR: Shear force magnitude (float)
- FPS: Current frames per second (float)
- TIMESTAMP: Current timestamp string (str)
- DEPTH: Depth image (np.ndarray)
Example:
normal, shear, fps = sensor.read_info(sensor.info.VNORMAL, sensor.info.VSHEAR, sensor.info.FPS)
Note:
Must call compute_deformation() before reading flow/force data
"""
if not self.vupdate:
print("Warning: Update field first via compute_deformation() before reading flow/force info.")
results = []
for arg in args:
if not isinstance(arg, self.info):
print(f"Warning: Invalid argument type {type(arg)}, skipping")
continue
if arg == self.info.VRAW:
results.append(self.flow if self.flow is not None else np.array([]))
elif arg == self.info.VNORMAL:
results.append(self.normal if self.normal is not None else np.array([]))
elif arg == self.info.VSHEAR:
results.append(self.shear if self.shear is not None else np.array([]))
elif arg == self.info.FNORMAL:
results.append(self.fnormal)
elif arg == self.info.FSHEARX:
results.append(self.fshearx)
elif arg == self.info.FSHEARY:
results.append(self.fsheary)
elif arg == self.info.FPS:
results.append(self.fps)
elif arg == self.info.TIMESTAMP:
results.append(time.strftime('%H:%M:%S'))
elif arg == self.info.DEPTH:
results.append(self.depth if self.depth is not None else np.array([]))
return tuple(results) if results else ()
def reset(self):
self.tracker.reset()
def disconnect(self):
"""
Release video capture resources.
"""
if self.cap.isOpened():
self.cap.release()
if self.verbose:
print("Sensor disconnected.")
cv2.destroyAllWindows()
def __get_tiles(self, img_src):
"""
Extract individual tiles from source image for stitching.
Args:
img_src: Source image (grayscale) containing multiple vision tiles
Returns:
bool: True (always succeeds)
"""
self.__stitcher.get_stitch_unit(img_src)
self.__stitcher.extract_tiles()
return True
def __find_stitch_boxes(self, img_src, show=False):
"""
Detect vision tile boxes in the frame, waiting for camera stabilization.
Args:
img_src: Initial source image (not actively used, boxes detected from live capture)
show: Whether to display detected boxes visually (default: False)
Returns:
int: Number of boxes detected (should be 15 for valid stitching)
"""
n_boxes, v_mean0, timeout = 0, 0, 10
start_time = cv2.getTickCount()
# print("Waiting for camera stabilization...")
while n_boxes < self.config_units:
ret, frame = self.cap.read()
v_mean = np.sum(cv2.mean(frame))
if abs(v_mean - v_mean0) < 5:
n_boxes = self.__stitcher.find_stitch_boxes(frame, show=show)
if (cv2.getTickCount() - start_time) / cv2.getTickFrequency() > timeout:
print("Timeout for finding valid vision units.")
self.isstitch = False
self.__stitcher.isstitch = False
break
v_mean0 = v_mean
# print("Camera stabilized.")
# print("Checking validabity for stitching...")
self.n_boxes = n_boxes
self.stitch_valid = self.isstitch and n_boxes == self.config_units
if self.isstitch:
print("Sensor is valid for stithcing." if self.stitch_valid else "Sensor not valid to stitching, switching to non-stitch mode")
else:
print("Using non-stitch mode.")
return n_boxes
def stitch_cal(self):
"""
Load or create stitch calibration for aligning multiple tiles.
Returns:
np.ndarray: Stitched image after calibration
Note:
- Loads calibration from ./config/cal.npy if exists
- Creates new calibration and saves to ./config/cal.npy if not found
- Calibration contains transformation parameters for tile alignment
"""
cal_path = "./config/cal.npy"
if os.path.exists(cal_path):
print("Loading existing calibration from", cal_path)
self.stitch_calibration = np.load(cal_path, allow_pickle=True)
self.__stitcher.stitch_calibration = self.stitch_calibration
img_stitched_height = self.__stitcher.stitch_rt_optimized()
else:
print("Calibration file not found, creating new calibration...")
img_stitched_height, self.stitch_calibration = self.__stitcher.stitch_cal()
os.makedirs("./config", exist_ok=True)
np.save(cal_path, self.stitch_calibration)
print("Calibration saved to", cal_path)
# print("Image stitched with shape:", img_stitched_height.shape)
return img_stitched_height
def draw_arrows(self, info_type: info,fps=True, threshold=5, grid_spacing=40, arrow_scale=1.0) -> np.ndarray:
"""
Draw colored optical flow arrows on sensor image based on flow magnitude.
Args:
threshold: Minimum flow magnitude to display arrow (default: 10.0)
grid_spacing: Spacing between arrows in pixels (default: 20)
arrow_scale: Scale factor for arrow length (default: 3.0)
Returns:
np.ndarray: 400x400 BGR image with colored arrows (JET colormap)
"""
# Create working copies to avoid modifying originals
image = self.img.copy()
if info_type is None:
print("Flow field type not specified, defaulting to show raw vector field.")
optical_flow = self.flow.copy()
elif info_type == self.info.VRAW:
optical_flow = self.flow.copy()
elif info_type == self.info.VNORMAL:
optical_flow = self.normal.copy()
elif info_type == self.info.VSHEAR:
optical_flow = self.shear.copy()
# Convert grayscale to BGR to enable colored arrow drawing
if len(image.shape) == 2 or image.shape[2] == 1:
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
# Calculate scaling factors between image and flow dimensions
img_height, img_width = image.shape[:2]
flow_height, flow_width = optical_flow.shape[:2]
scale_x, scale_y = img_width / flow_width, img_height / flow_height
# Create sampling grid for flow field (used for interpolation source points)
sample_step = 19 # Spacing between sample points
flow_start = np.stack(np.meshgrid(range(0, flow_width, sample_step), range(0, flow_height, sample_step)), 2)
flow_end = (optical_flow[flow_start[:, :, 1], flow_start[:, :, 0], :] * 2.0 + flow_start).astype(np.int32)
# Create regular grid for arrow placement in image coordinates
grid_x, grid_y = np.mgrid[grid_spacing//2:flow_height:grid_spacing, grid_spacing//2:flow_width:grid_spacing]
x_axis = (grid_y.reshape(-1) * scale_x).astype(np.int64) # Scale to image width
y_axis = (grid_x.reshape(-1) * scale_y).astype(np.int64) # Scale to image height
# Interpolate flow vectors from sample grid to regular arrow grid using cubic interpolation
grid_vector = griddata(
flow_start.reshape(-1, 2),
(flow_end - flow_start).reshape(-1, 2), # Flow from start to end (original to new position)
(grid_y, grid_x), method='cubic', fill_value=0
)
grid_vector[np.isnan(grid_vector)] = 0 # Replace NaN with zero for stability
grid_flat_scaled = grid_vector.reshape(-1, 2) * np.array([scale_x, scale_y])
# Compute arrow endpoints and ensure they stay within image bounds
end_x = (grid_flat_scaled[:, 0] / arrow_scale + x_axis).astype(np.int64)
end_y = (grid_flat_scaled[:, 1] / arrow_scale + y_axis).astype(np.int64)
valid_mask = (y_axis >= 0) & (y_axis < img_height) & (x_axis >= 0) & (x_axis < img_width)
valid_mask &= (end_x >= 0) & (end_x < img_width) & (end_y >= 0) & (end_y < img_height)
# Filter arrows by magnitude threshold and compute colors using JET colormap
magnitudes = np.linalg.norm(grid_flat_scaled, axis=1)
valid_mask &= (magnitudes >= 0) # Only show arrows above threshold
valid_indices = np.where(valid_mask)[0]
mag_range = magnitudes.max() - magnitudes.min()
# Normalize magnitudes to 0-255 range for colormap application, but set below-threshold values to 0 (blue)
norm_magnitudes = ((magnitudes - magnitudes.min()) / mag_range * 255.0).astype(np.uint8) if mag_range > 0 else np.zeros_like(magnitudes, dtype=np.uint8)
norm_magnitudes[magnitudes < threshold] = 0 # Keep lowest color (blue) for below-threshold magnitudes
colors_array = cv2.applyColorMap(norm_magnitudes.reshape(-1, 1), cv2.COLORMAP_JET) # JET: blue (low) -> red (high)
# Draw arrows at each valid grid point with magnitude-based coloring
for i in valid_indices:
color = tuple(map(int, colors_array[i, 0]))
# Scale arrow length and thickness based on threshold
if magnitudes[i] < threshold:
# Below threshold: draw as small dot (zero-length arrow with larger tip)
cv2.circle(image, (x_axis[i], y_axis[i]), radius=1, color=color, thickness=-1)
else:
# Above threshold: draw full arrow with thickness proportional to magnitude
# Arrow rooted at original position (x_axis, y_axis), head points to new position (end_x, end_y)
thickness = max(1, int(2 * magnitudes[i] / magnitudes.max())) if magnitudes.max() > 0 else 2
cv2.arrowedLine(image, (x_axis[i], y_axis[i]), (end_x[i], end_y[i]), color, thickness=thickness, tipLength=0.3)
out_img = cv2.resize(image, (self.config_resolution_stitch[0], self.config_resolution_stitch[1]), interpolation=cv2.INTER_CUBIC)
# if fps:
# out_img = self.draw_fps(image=out_img)
return out_img
def draw_magnitude_map(self, info_type: info = None, threshold=3.0, colormap=cv2.COLORMAP_JET) -> np.ndarray:
"""
Draw color map of pointwise magnitude of velocity field.
Args:
info_type: Type of flow field to visualize (VRAW, VNORMAL, VSHEAR, or None for total)
threshold: Minimum magnitude to display (below threshold shows as zero/blue)
colormap: OpenCV colormap to use (default: COLORMAP_JET)
Returns:
np.ndarray: 400x400 BGR image with colored magnitude map
"""
# Get the appropriate flow field
if info_type is None or info_type == self.info.VRAW:
flow_field = self.flow.copy() if self.flow is not None else None
elif info_type == self.info.VNORMAL:
flow_field = self.normal.copy() if self.normal is not None else None
elif info_type == self.info.VSHEAR:
flow_field = self.shear.copy() if self.shear is not None else None
else:
# For combined normal + shear magnitude
if self.normal is not None and self.shear is not None:
flow_field = self.normal + self.shear
else:
flow_field = None
if flow_field is None:
# Return blank image if no flow data
return np.zeros((self.config_resolution_stitch[0], self.config_resolution_stitch[1], 3), dtype=np.uint8)
# Compute magnitude at each point
magnitude = np.sqrt(flow_field[:, :, 0]**2 + flow_field[:, :, 1]**2)
# Smooth by downsampling and interpolating back
downsample_factor = 10
h, w = magnitude.shape
magnitude_downsampled = magnitude[::downsample_factor, ::downsample_factor]
magnitude_smoothed = cv2.resize(magnitude_downsampled, (w, h), interpolation=cv2.INTER_CUBIC)
# Apply threshold - set below-threshold values to zero
magnitude_thresholded = magnitude_smoothed.copy()
magnitude_thresholded[magnitude_smoothed < threshold] = 0
# Normalize to 0-255 range
mag_min, mag_max = magnitude_thresholded.min(), magnitude_thresholded.max()
if mag_max > mag_min:
magnitude_norm = ((magnitude_thresholded - mag_min) / (mag_max - mag_min) * 255).astype(np.uint8)
else:
magnitude_norm = np.zeros_like(magnitude_thresholded, dtype=np.uint8)
# Apply colormap
magnitude_colored = cv2.applyColorMap(magnitude_norm, colormap)
# Resize to output size
out_img = cv2.resize(magnitude_colored, (self.config_resolution_stitch[0], self.config_resolution_stitch[1]), interpolation=cv2.INTER_CUBIC)
return out_img
def draw_fps(self, image=None):
"""
Draw FPS counter on the top left corner of the image.
"""
font_scale=0.5
thickness=2
color=(0, 255, 0)
image = image.copy()
fps = self.fps
# Draw FPS text
fps_text = f"FPS: {fps:.1f}"
position = (10, 30) # Top left corner with margin
font = cv2.FONT_HERSHEY_SIMPLEX
# Draw FPS text
cv2.putText(image, fps_text, position, font, font_scale, color, thickness, cv2.LINE_AA)
return image