524 lines
22 KiB
Python
524 lines
22 KiB
Python
"""
|
||
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
|
||
|
||
|
||
|
||
|