Merge branch 'stable'
@@ -1,17 +1,37 @@
|
||||
# Python bytecode / caches
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
|
||||
# Packaging / build outputs
|
||||
/build/
|
||||
/dist/
|
||||
/dist_obfuscated/
|
||||
/release/
|
||||
__pycache__/
|
||||
orisys.egg-info/
|
||||
/.cursor/
|
||||
/dist_pyd/
|
||||
/dist_single_pyd/
|
||||
/old_orisys/
|
||||
/dist_nuitka/
|
||||
/build_nuitka
|
||||
/data/
|
||||
/build_nuitka/
|
||||
/release/
|
||||
/SDK/
|
||||
*.egg-info/
|
||||
|
||||
# Local tools / editor state
|
||||
/.cursor/
|
||||
/.vscode/
|
||||
|
||||
# Local data / models
|
||||
/models/
|
||||
/doc/.quarto/
|
||||
*.npy
|
||||
*.png
|
||||
/data/*
|
||||
!/data/sample/
|
||||
!/data/sample/**
|
||||
|
||||
# Generated docs / tooling outputs
|
||||
/docs/.quarto/
|
||||
/docs/exported_data/
|
||||
/tools/3d_patch_generation_blender/exported_data/
|
||||
|
||||
# Local or generated assets kept out of git
|
||||
/config/compound_eye.png
|
||||
|
||||
# Legacy / scratch folders
|
||||
/old_orisys/
|
||||
@@ -1,156 +0,0 @@
|
||||
import os
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
import cv2
|
||||
import torch
|
||||
import torchvision.transforms.functional as F
|
||||
import torchvision.transforms as T
|
||||
from torchvision.models.optical_flow import raft_large, raft_small
|
||||
import numpy as np
|
||||
import time
|
||||
import orisys
|
||||
|
||||
def preprocess(batch, resize=None):
|
||||
"""
|
||||
Preprocess image batch for RAFT model.
|
||||
|
||||
Args:
|
||||
batch: Tensor of shape (N, C, H, W)
|
||||
resize: Optional tuple (height, width) to resize. If None, keeps original size.
|
||||
|
||||
Returns:
|
||||
Preprocessed tensor
|
||||
"""
|
||||
transform_list = [
|
||||
T.ConvertImageDtype(torch.float32),
|
||||
T.Normalize(mean=0.5, std=0.5), # map [0, 1] into [-1, 1]
|
||||
]
|
||||
|
||||
if resize is not None:
|
||||
transform_list.append(T.Resize(size=resize))
|
||||
|
||||
transforms = T.Compose(transform_list)
|
||||
batch = transforms(batch)
|
||||
return batch
|
||||
|
||||
def setup_simple_raft():
|
||||
"""
|
||||
Setup a simpler version using available models
|
||||
"""
|
||||
model = raft_small(pretrained=True, progress=False).to('cuda')
|
||||
model = model.eval()
|
||||
# Alternative: Use LiteFlowNet (lighter alternative)
|
||||
print("RAFT loaded successfully")
|
||||
return model
|
||||
|
||||
def track_raft(prev_frame, frame, model, device):
|
||||
|
||||
gray = frame.copy()
|
||||
start_time = time.time()
|
||||
prev_tensor = torch.from_numpy(prev_frame.transpose(2, 0, 1).astype(np.float32) / 255.0)
|
||||
curr_tensor = torch.from_numpy(gray.transpose(2, 0, 1).astype(np.float32) / 255.0)
|
||||
prev_tensor = preprocess(prev_tensor.unsqueeze(0), resize=None).to(device)
|
||||
curr_tensor = preprocess(curr_tensor.unsqueeze(0), resize=None).to(device)
|
||||
t0 = time.perf_counter()
|
||||
with torch.no_grad():
|
||||
flow = model(prev_tensor, curr_tensor)
|
||||
predicted_flows = flow[-1]
|
||||
t1 = time.perf_counter()
|
||||
print(f"RAFT time: {t1 - t0}")
|
||||
flow_np = predicted_flows[0].cpu().numpy() # or .squeeze(0)
|
||||
|
||||
# Transpose to (H, W, 2) format expected by draw_arrows
|
||||
flow_np = flow_np.transpose(1, 2, 0) # (2, H, W) -> (H, W, 2)
|
||||
|
||||
# torch.onnx.export(
|
||||
# model, # model to export
|
||||
# (prev_tensor, curr_tensor), # inputs of the model,
|
||||
# "my_model.onnx", # filename of the ONNX model
|
||||
# input_names=["prev_tensor", "curr_tensor"], # Rename inputs for the ONNX model
|
||||
# dynamo=True # True or False to select the exporter to use
|
||||
# )
|
||||
# print("ONNX model exported successfully")
|
||||
|
||||
return flow_np
|
||||
|
||||
def main_simple():
|
||||
# Initialize camera
|
||||
# sensor = orisys.Sensor('s1.mp4', config_name="./config/prototype_of3.json", cal_path= "./config/cal.npy", isstitch=True, cuda=True, verbose=True)
|
||||
model = setup_simple_raft()
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
print(f"Using device: {device}")
|
||||
cap = cv2.VideoCapture("s1.mp4")
|
||||
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920)
|
||||
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080)
|
||||
|
||||
prev_gray = None
|
||||
fps_buffer = []
|
||||
|
||||
|
||||
while True:
|
||||
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
dx = 300
|
||||
dy = 0
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
frame1 = frame[420:660, 840:1080, :]
|
||||
# frame2 = frame[420+dy:660+dy, 840+dx:1080+dx, :]
|
||||
# frame3 = frame[420-dy:660-dy, 840-dx:1080-dx, :]
|
||||
|
||||
# frame = np.concatenate([frame3, frame1, frame2], axis=1)
|
||||
frame = frame1
|
||||
# cv2.imshow("frame", frame3)
|
||||
# cv2.waitKey(0)
|
||||
if prev_gray is None:
|
||||
prev_gray = frame.copy()
|
||||
gray = frame
|
||||
# Compute optical flow
|
||||
start_time = time.time()
|
||||
flow_np = track_raft(prev_gray, gray, model, device)
|
||||
fps = 1.0 / (time.time() - start_time)
|
||||
fps_buffer.append(fps)
|
||||
if len(fps_buffer) > 30:
|
||||
fps_buffer.pop(0)
|
||||
|
||||
avg_fps = np.mean(fps_buffer)
|
||||
print(f"avg_fps: {avg_fps}")
|
||||
|
||||
|
||||
# # Remove batch dimension: (1, 2, H, W) -> (2, H, W)
|
||||
# flow_np = predicted_flows[0].cpu().numpy() # or .squeeze(0)
|
||||
|
||||
# # Transpose to (H, W, 2) format expected by draw_arrows
|
||||
# flow_np = flow_np.transpose(1, 2, 0) # (2, H, W) -> (H, W, 2)
|
||||
|
||||
# Flow is already in pixel coordinates, no denormalization needed
|
||||
# Keep as float32 for draw_arrows (it can handle float values)
|
||||
print(f"Flow numpy shape: {flow_np.shape}, dtype: {flow_np.dtype}")
|
||||
print(f"Flow range: x=[{flow_np[:,:,0].min():.2f}, {flow_np[:,:,0].max():.2f}], "
|
||||
f"y=[{flow_np[:,:,1].min():.2f}, {flow_np[:,:,1].max():.2f}]")
|
||||
|
||||
arrows = orisys.util.draw_arrows(
|
||||
gray,
|
||||
flow_np,
|
||||
threshold=2,
|
||||
grid_spacing=40,
|
||||
arrow_scale=1.0
|
||||
)
|
||||
|
||||
arrows = cv2.resize(arrows, (400, 400))
|
||||
cv2.imshow('Optical Flow', arrows)
|
||||
|
||||
# prev_gray = gray.copy()
|
||||
|
||||
#步骤 3.5: 键盘控制
|
||||
key = cv2.waitKey(1) & 0xFF
|
||||
if key == ord("q"):
|
||||
print("\n退出程序...")
|
||||
break
|
||||
if key == ord("r"):
|
||||
# 重置光流追踪器(当追踪漂移时使用)
|
||||
prev_gray = None
|
||||
print("传感器已重置")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main_simple()
|
||||
@@ -0,0 +1,85 @@
|
||||
# Orisys SDK
|
||||
|
||||
Orisys SDK is a Python toolkit for optical tactile sensing. It provides image capture,
|
||||
stitching, dense optical flow, Helmholtz-Hodge decomposition, contact/depth estimation,
|
||||
and several runnable examples for camera and video-based workflows.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
- `src/orisys/`: installable SDK package
|
||||
- `config/`: default and lab/runtime JSON configurations
|
||||
- `examples/`: user-facing example scripts
|
||||
- `scripts/`: maintenance, calibration, packaging, and demo scripts
|
||||
- `tools/`: non-package tooling such as patch-generation utilities
|
||||
- `tests/`: automated pytest suite
|
||||
- `docs/`: usage and API documentation
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create an environment
|
||||
|
||||
```bash
|
||||
conda create -n orisys python=3.10
|
||||
conda activate orisys
|
||||
```
|
||||
|
||||
### 2. Install the SDK in editable mode
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
Optional GPU extras:
|
||||
|
||||
```bash
|
||||
pip install -e .[cuda12]
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
pip install -e .[cuda13]
|
||||
```
|
||||
|
||||
### 3. Run tests
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
### 4. Run an example
|
||||
|
||||
```bash
|
||||
python examples/basic_pipeline.py -v 0 -c ./config/ddjx01.json
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The canonical configuration files now live in the repo-level `config/` folder.
|
||||
The package loader `orisys.configs` resolves configs from:
|
||||
|
||||
1. an explicit JSON file path
|
||||
2. a config file stem under `config/`
|
||||
3. a backward-compatible JSON `"name"` match, such as `"prototype"`
|
||||
|
||||
Examples:
|
||||
|
||||
```python
|
||||
import orisys
|
||||
|
||||
sensor = orisys.Sensor(0, config_name="ddjx01")
|
||||
cfg = orisys.export_config("ddjx01", "./config/exported_config.json")
|
||||
```
|
||||
|
||||
## Examples and Tools
|
||||
|
||||
- Example entry points: `examples/README.md`
|
||||
- User documentation: `docs/get_started.md`
|
||||
- Packaging helper: `scripts/build_nuitka.py`
|
||||
- Specialized utilities: `tools/`
|
||||
|
||||
## Notes for Collaborators
|
||||
|
||||
- Generated build outputs such as `build/`, `dist/`, and `SDK/` are intentionally ignored.
|
||||
- Large or generated tooling outputs should stay under ignored output folders rather than be committed.
|
||||
- If you add new images or arrays that should be versioned, review `.gitignore` first.
|
||||
@@ -1,155 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from importlib.machinery import EXTENSION_SUFFIXES
|
||||
|
||||
|
||||
def install_nuitka():
|
||||
print("Checking Nuitka installation...")
|
||||
try:
|
||||
import nuitka # noqa: F401
|
||||
print("Nuitka is installed.")
|
||||
except ImportError:
|
||||
print("Installing Nuitka...")
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", "nuitka", "ordered-set", "zstandard"]
|
||||
)
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
init_file = Path("src/orisys/__init__.py")
|
||||
if not init_file.exists():
|
||||
return "0.3.2"
|
||||
for line in init_file.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("__version__"):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return "0.3.2"
|
||||
|
||||
|
||||
def find_compiled_extension(output_dir: Path) -> Path:
|
||||
candidates = []
|
||||
for suffix in EXTENSION_SUFFIXES:
|
||||
candidates.extend(output_dir.glob(f"*{suffix}"))
|
||||
if not candidates:
|
||||
all_files = ", ".join(p.name for p in output_dir.iterdir()) if output_dir.exists() else "(none)"
|
||||
raise RuntimeError(f"Nuitka did not produce a compiled extension. Found: {all_files}")
|
||||
# Prefer the shortest filename (usually the package extension itself)
|
||||
return sorted(candidates, key=lambda p: len(p.name))[0]
|
||||
|
||||
|
||||
def build():
|
||||
install_nuitka()
|
||||
|
||||
version = get_version()
|
||||
src_dir = Path("src/orisys")
|
||||
output_dir = Path("build_nuitka")
|
||||
dist_dir = Path("dist_nuitka")
|
||||
|
||||
if output_dir.exists():
|
||||
shutil.rmtree(output_dir)
|
||||
if dist_dir.exists():
|
||||
shutil.rmtree(dist_dir)
|
||||
output_dir.mkdir()
|
||||
dist_dir.mkdir()
|
||||
|
||||
print("\n=== Compiling with Nuitka ===")
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = "src" + os.pathsep + env.get("PYTHONPATH", "")
|
||||
|
||||
cmd = [
|
||||
sys.executable, "-m", "nuitka",
|
||||
"--module",
|
||||
"--include-package=orisys",
|
||||
"--output-dir=" + str(output_dir),
|
||||
"--remove-output",
|
||||
"--no-pyi-file",
|
||||
"src/orisys",
|
||||
]
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
subprocess.check_call(cmd, env=env)
|
||||
|
||||
ext_file = find_compiled_extension(output_dir)
|
||||
ext_suffix = "".join(ext_file.suffixes)
|
||||
print(f"Generated compiled extension: {ext_file}")
|
||||
|
||||
print("\n=== Preparing Wheel Structure ===")
|
||||
wheel_root = dist_dir / "wheel_root"
|
||||
pkg_dir = wheel_root / "orisys"
|
||||
pkg_dir.mkdir(parents=True)
|
||||
|
||||
dest_ext = pkg_dir / f"__init__{ext_suffix}"
|
||||
shutil.copy(ext_file, dest_ext)
|
||||
print(f"Copied compiled package to {dest_ext}")
|
||||
|
||||
configs_src = src_dir / "configs"
|
||||
configs_dst = pkg_dir / "configs"
|
||||
configs_dst.mkdir()
|
||||
json_count = 0
|
||||
for json_file in configs_src.glob("*.json"):
|
||||
shutil.copy(json_file, configs_dst)
|
||||
json_count += 1
|
||||
print(f"Copied {json_count} JSON config files")
|
||||
|
||||
examples_dst = pkg_dir / "examples"
|
||||
examples_dst.mkdir(exist_ok=True)
|
||||
(examples_dst / "__init__.py").write_text("# Examples for orisys package\n", encoding="utf-8")
|
||||
example_files = [Path("example1_basic_pipeline.py"), Path("example2_two_sensor_pipeline.py")]
|
||||
example_count = 0
|
||||
for example_file in example_files:
|
||||
if example_file.exists():
|
||||
shutil.copy(example_file, examples_dst)
|
||||
example_count += 1
|
||||
print(f"Copied example: {example_file.name}")
|
||||
print(f"Copied {example_count} example files to orisys/examples/")
|
||||
|
||||
setup_py_path = dist_dir / "setup.py"
|
||||
setup_content = f"""
|
||||
from setuptools import setup, Distribution
|
||||
|
||||
class BinaryDistribution(Distribution):
|
||||
def has_ext_modules(self):
|
||||
return True
|
||||
|
||||
setup(
|
||||
name="orisys",
|
||||
version="{version}",
|
||||
description="Orisys - Optical Tactile Sensor SDK (Nuitka Compiled)",
|
||||
author="Orisys",
|
||||
packages=['orisys', 'orisys.examples'],
|
||||
package_dir={{'': 'wheel_root'}},
|
||||
package_data={{
|
||||
'orisys': ['*{ext_suffix}', 'configs/*.json'],
|
||||
'orisys.examples': ['*.py'],
|
||||
}},
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
"numpy>=1.24.4",
|
||||
"opencv-python>=4.10.0.84",
|
||||
"scipy>=1.10.1",
|
||||
],
|
||||
extras_require={{
|
||||
"cuda12": ["cupy-cuda12x>=12.3.0"],
|
||||
"cuda13": ["cupy-cuda13x>=13.0.0"],
|
||||
"cuda": ["cupy-cuda12x>=12.3.0"],
|
||||
}},
|
||||
distclass=BinaryDistribution,
|
||||
zip_safe=False,
|
||||
)
|
||||
"""
|
||||
setup_py_path.write_text(setup_content, encoding="utf-8")
|
||||
|
||||
print("\n=== Building Wheel ===")
|
||||
subprocess.check_call([sys.executable, "setup.py", "bdist_wheel"], cwd=dist_dir)
|
||||
|
||||
wheels = list((dist_dir / "dist").glob("*.whl"))
|
||||
if wheels:
|
||||
print(f"\n[SUCCESS] Wheel created: {wheels[0]}")
|
||||
else:
|
||||
print("\n[ERROR] Wheel not found.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build()
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import os
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
import cv2
|
||||
import orisys
|
||||
import numpy as np
|
||||
import argparse
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Calibration Data Collection')
|
||||
parser.add_argument('-i','--vid_source', type=str, default=0, help='Video Source')
|
||||
parser.add_argument('-id','--device_id', type=int, default=0, help='Device ID')
|
||||
|
||||
args = parser.parse_args()
|
||||
device_id = args.device_id
|
||||
vid_source = args.vid_source
|
||||
sample_name = f"dd01a_{device_id}" #dd01 (平面款), dd01a (平面款 手搓), dd02 (手指款)
|
||||
test_type = "normal" # shearX, shearY, normal
|
||||
print("\n")
|
||||
print(f"Collecting data for device ID: {device_id} on test type: {test_type}, video source: {vid_source}")
|
||||
print("\n")
|
||||
sensor = orisys.Sensor(vid_source, config_name="./config/prototype_of3.json", cal_path= f"./config/{sample_name}.npy", cuda=False, verbose=True)
|
||||
|
||||
print(f"\n按 'q' 退出程序,按 'r' 重置光流追踪器,按 's' 开始保存,按 'e' 停止保存,按 'j' 减小力,按 'l' 增大力")
|
||||
force = 0 # kgf
|
||||
force_step = 0.1 # kgf
|
||||
force_min = 0 # kgf
|
||||
force_max = 3 # kgf
|
||||
|
||||
is_save = False
|
||||
save_mode = ["raw", "stitched", "arrows", "contact", "depth"]
|
||||
save_mode_index = 0
|
||||
frame_count = 0
|
||||
writer = None
|
||||
save_path = None
|
||||
|
||||
while True:
|
||||
sensor.get_img()
|
||||
|
||||
if is_save and writer is not None and sensor.frame is not None:
|
||||
frame_count += 1
|
||||
if save_mode_index == 0:
|
||||
save_img = sensor.frame
|
||||
elif save_mode_index == 1:
|
||||
save_img = sensor.img
|
||||
elif save_mode_index == 2:
|
||||
save_img = arrows
|
||||
elif save_mode_index == 3:
|
||||
save_img = image_with_foe
|
||||
elif save_mode_index == 4:
|
||||
save_img = div_normalized
|
||||
|
||||
# Convert grayscale (1 channel) to BGR (3 channels) if needed
|
||||
if len(save_img.shape) == 2 or (len(save_img.shape) == 3 and save_img.shape[2] == 1):
|
||||
if len(save_img.shape) == 3:
|
||||
save_img = save_img.squeeze(2)
|
||||
save_img = cv2.cvtColor(save_img, cv2.COLOR_GRAY2BGR)
|
||||
|
||||
writer.write(save_img)
|
||||
|
||||
sensor.compute_deformation()
|
||||
sensor.compute_contact()
|
||||
|
||||
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = sensor.read_info(
|
||||
sensor.info.FPS,
|
||||
sensor.info.VRAW,
|
||||
sensor.info.VNORMAL,
|
||||
sensor.info.IMG,
|
||||
sensor.info.CONTOUR,
|
||||
sensor.info.CENTROID,
|
||||
sensor.info.DEPTH,
|
||||
sensor.info.FNORMAL,
|
||||
sensor.info.FSHEARX,
|
||||
sensor.info.FSHEARY
|
||||
)
|
||||
|
||||
arrows = orisys.util.draw_arrows(
|
||||
img,
|
||||
flow,
|
||||
threshold=4,
|
||||
grid_spacing=10,
|
||||
arrow_scale=1.0
|
||||
)
|
||||
cv2.putText(arrows, f"FPS: {fps:.0f}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
|
||||
cv2.imshow("Deformation", arrows)
|
||||
|
||||
image_with_foe = orisys.util.draw_contact(img, contour, centroid)
|
||||
cv2.imshow("Contact Centroid", image_with_foe)
|
||||
|
||||
div_abs = depth_map
|
||||
if div_abs.max() > div_abs.min():
|
||||
div_normalized = ((div_abs - div_abs.min()) / (div_abs.max() - div_abs.min()) * 255).astype(np.uint8)
|
||||
else:
|
||||
div_normalized = np.zeros_like(div_abs, dtype=np.uint8)
|
||||
|
||||
# 应用颜色映射(使用JET:蓝色=低散度,红色=高散度)
|
||||
cv2.imshow("Depth Map", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
|
||||
|
||||
key = cv2.waitKey(1) & 0xFF
|
||||
|
||||
if key == ord("s"):
|
||||
save_name = f"{sample_name}_{test_type}_{force:.1f}kgf"
|
||||
save_path = f"./data/{sample_name}/{test_type}/{save_name}.mp4"
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||
# Get frame dimensions from the actual frame
|
||||
save_img = None
|
||||
if save_mode_index == 0:
|
||||
save_img = sensor.frame
|
||||
elif save_mode_index == 1:
|
||||
save_img = sensor.img
|
||||
elif save_mode_index == 2:
|
||||
save_img = arrows
|
||||
elif save_mode_index == 3:
|
||||
save_img = image_with_foe
|
||||
elif save_mode_index == 4:
|
||||
save_img = div_normalized
|
||||
|
||||
if save_img is not None:
|
||||
# Convert grayscale (1 channel) to BGR (3 channels) if needed for dimension check
|
||||
temp_img = save_img.copy()
|
||||
if len(temp_img.shape) == 2 or (len(temp_img.shape) == 3 and temp_img.shape[2] == 1):
|
||||
if len(temp_img.shape) == 3:
|
||||
temp_img = temp_img.squeeze(2)
|
||||
temp_img = cv2.cvtColor(temp_img, cv2.COLOR_GRAY2BGR)
|
||||
|
||||
frame_height, frame_width = temp_img.shape[:2]
|
||||
writer = cv2.VideoWriter(save_path, cv2.VideoWriter_fourcc(*'mp4v'), 30, (frame_width, frame_height))
|
||||
if writer.isOpened():
|
||||
is_save = True
|
||||
print(f"Start writing to {save_path}")
|
||||
else:
|
||||
print(f"Failed to open video writer for {save_path}")
|
||||
writer = None
|
||||
else:
|
||||
print("No frame available to determine video dimensions")
|
||||
|
||||
if key == ord("e"):
|
||||
is_save = False
|
||||
if writer is not None:
|
||||
writer.release()
|
||||
writer = None
|
||||
if save_path is not None:
|
||||
print(f"Stop writing to {save_path}, total frames: {frame_count}")
|
||||
else:
|
||||
print(f"Stop writing, total frames: {frame_count}")
|
||||
frame_count = 0
|
||||
|
||||
if key == ord("p"):
|
||||
print(f"Normal Force: {fnormal:.1f} kgf, Shear ForceX: {fshearx:.1f} kgf, Shear ForceY: {fsheary:.1f} kgf")
|
||||
|
||||
if key == ord("i"):
|
||||
save_mode_index += 1
|
||||
if save_mode_index >= len(save_mode):
|
||||
save_mode_index = 0
|
||||
print(f"Save mode: {save_mode[save_mode_index]}")
|
||||
|
||||
if key == ord("k"):
|
||||
save_mode_index -= 1
|
||||
if save_mode_index < 0:
|
||||
save_mode_index = len(save_mode) - 1
|
||||
print(f"Save mode: {save_mode[save_mode_index]}")
|
||||
if key == ord("j"):
|
||||
force -= force_step
|
||||
if force < force_min:
|
||||
force = force_min
|
||||
print(f"Force: {force:.1f} kgf")
|
||||
if key == ord("l"):
|
||||
force += force_step
|
||||
if force > force_max:
|
||||
force = force_max
|
||||
print(f"Force: {force:.1f} kgf")
|
||||
|
||||
|
||||
if key == ord("q"):
|
||||
print("\n退出程序...")
|
||||
if writer is not None:
|
||||
writer.release()
|
||||
writer = None
|
||||
break
|
||||
if key == ord("r"):
|
||||
# 重置光流追踪器(当追踪漂移时使用)
|
||||
sensor.reset()
|
||||
print("传感器已重置")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 4. 清理资源
|
||||
# =========================================================================
|
||||
if writer is not None:
|
||||
writer.release()
|
||||
sensor.disconnect() # 释放视频源和相关资源
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
Before Width: | Height: | Size: 1014 KiB |
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"stitcher": {
|
||||
"min_area_frac": 0.01,
|
||||
"max_area_frac": 0.04,
|
||||
"max_area_frac": 0.05,
|
||||
"max_aspect_ratio": 50,
|
||||
"index": [1,2,3,6,7,8,11,12,13],
|
||||
"margin": 10,
|
||||
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 429 KiB After Width: | Height: | Size: 429 KiB |
@@ -72,8 +72,8 @@ conda install conda-forge::pyvkfft
|
||||
### 输入参数
|
||||
|
||||
* **vid_src** (`int | str`, 必填): 传感器 ID、相机编号或视频路径。默认为 `0`。
|
||||
* **config_name** (`str`,可选): 传感器配置`json`文件路径,用于加载对应的参数配置,默认为 `./config/ddjx01`。
|
||||
* **cal_path** (`str`,可选): 复眼拼接校准文件路径,默认为 `./config/cal.npy`。有多个传感器同时运行时建议传感器类创建时建议填写,避免校准文件覆写。在首次运行时程序会在找不到文件时自动创建。
|
||||
* **config_name** (`str`,可选): 传感器配置文件名称或 `json` 文件路径,用于加载对应的参数配置;例如 `ddjx01` 或 `./config/ddjx01.json`。
|
||||
* **cal_path** (`str`,可选,已弃用): 该参数仅为兼容旧代码保留,当前版本中不再使用,传入后也会被忽略。
|
||||
* **backend** (`str`,可选): 光流场解耦计算后端,默认为 `auto`,可选`cupy`,`opencl`,`cpu`。
|
||||
* **verbose** (`bool`,可选): 是否输出初始化及运行过程中的详细日志信息,默认为 `False`。
|
||||
|
||||
@@ -99,12 +99,12 @@ sensor = Sensor("/data/data.mp4")
|
||||
|
||||
采集并预处理一帧图像并存到`Sensor` 类别中。
|
||||
|
||||
|
||||
### 返回
|
||||
* `np.ndarray`:处理后的BGR格式图像(`uint8`,尺寸为json文件中`resolution_stitch`对应分辨率)。
|
||||
* `None`:当输入源读取失败(例如视频结束、摄像头断开)时返回。
|
||||
* `bool`:是否成功读取图像。
|
||||
|
||||
### 状态更新
|
||||
* 成功时更新`sensor`类里面的图像。
|
||||
* 成功时更新`sensor`类里面的`sensor.img`。处理后的BGR格式图像(`uint8`,尺寸为json文件中`resolution_stitch`对应分辨率)。
|
||||
### 示例
|
||||
|
||||
```python
|
||||
@@ -123,6 +123,8 @@ sensor.get_img()
|
||||
### 输入参数
|
||||
* **check_motion**(`bool`,可选): 为 `True` 时先根据光流判断是否有运动,有运动才做 HHD 分解;为 `False` 时每帧都做 HHD。默认 `True`。
|
||||
* **threshold**(`float`,可选): **check_motion** 功能的阈值,默认为`6`。
|
||||
* **tail_frames** (`int`,可选): 光流变化低于阈值后继续解算多少帧,确保传感器回复,,默认为 `10`.
|
||||
|
||||
### 返回
|
||||
|
||||
* `bool`:是否接触到物体,如果**check_motion**为`False`时返回`None`。
|
||||
@@ -1,6 +1,6 @@
|
||||
叠动科技软件使用手册
|
||||
版本: 0.3.2
|
||||
更新日期: 2026年3月6日
|
||||
更新日期: 2026年4月16日
|
||||
|
||||
# 简介
|
||||
本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
|
||||
@@ -120,7 +120,7 @@ print(orisys.__version__)
|
||||
3. 如果第一步没有输出,传感器的ID就是“0”,如果第一步有一个设备,传感器的ID就是“1”,如此类推。
|
||||
4. 在确定了传感器的ID(下方例子假设为“0”)后,打开Anaconda Prompt, 切换到工作路径,然后在命令行中输入:
|
||||
``` bash
|
||||
python example1_basic_pipeline.py -v 0
|
||||
python examples/basic_pipeline.py -v 0
|
||||
```
|
||||
5. 程序会运行,并自动打开可视化窗口和打印资料,在窗口上按键盘”q”键可以停止运行,按”r”键可以重设数据
|
||||
|
||||
@@ -130,7 +130,7 @@ print(orisys.__version__)
|
||||
3. 根据新增的输出,例如”/dev/video0”,确定传感器的ID为“0”, 如果是”/dev/video3”的话ID为“3” ,如此类推。
|
||||
4. 在确定了传感器的ID(下方例子假设为“0”)后,打开终端, 激活相应的python环境,切换到工作路径,然后在命令行中输入:
|
||||
``` bash
|
||||
python example1_basic_pipeline.py -v 0
|
||||
python examples/basic_pipeline.py -v 0
|
||||
```
|
||||
5. 程序会运行,并自动打开可视化窗口和打印资料,在窗口上按键盘”q”键可以停止运行,按”r”键可以重设数据
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
@@ -1,113 +0,0 @@
|
||||
"""
|
||||
示例 1: 基础用法 - 单传感器同步处理
|
||||
|
||||
本示例演示 orisys 的基本使用流程:
|
||||
1. 初始化传感器(视频文件或摄像头)
|
||||
2. 循环获取图像并计算形变和触碰点
|
||||
3. 读取各类传感器数据
|
||||
4. 可视化显示
|
||||
|
||||
|
||||
"""
|
||||
import os
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
import cv2
|
||||
import orisys
|
||||
import numpy as np
|
||||
import argparse
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--video","-v", type=str, default="0", help="Camera ID / Video file path")
|
||||
parser.add_argument("--config","-c", type=str, default="./config/ddjx01.json", help="Configuration file path")
|
||||
parser.add_argument("--cal","-cal", type=str, default="./config/ddjx01.npy", help="Calibration file path")
|
||||
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="Print detailed information")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 步骤1:创建传感器对象 (必要步骤),
|
||||
# config_name为配置文件名(内置配置文件名)或路径(自定义配置文件路径)
|
||||
# cal_path为校准文件路径, 每一个设备需要创建/使用一个校准文件
|
||||
|
||||
# 方式1: 从视频文件读取
|
||||
try:
|
||||
input = int(args.video)
|
||||
is_camera = True
|
||||
sensor = orisys.Sensor(input, config_name=args.config, cal_path= args.cal, verbose=args.verbose)
|
||||
except:
|
||||
is_camera = False
|
||||
sensor = orisys.Sensor(args.video, config_name=args.config, cal_path= args.cal, verbose=args.verbose)
|
||||
|
||||
print("\n按 'q' 退出程序,按 'r' 重置传感器追踪器")
|
||||
|
||||
while True:
|
||||
# 步骤2:信息提取
|
||||
# 步骤 2.1: 获取并拼接图像 (必要步骤)
|
||||
sensor.get_img()
|
||||
# 步骤 2.2: 计算形变场 (必要步骤)
|
||||
is_touched = sensor.compute_deformation(check_motion=True, threshold=6)
|
||||
# 步骤 2.3: 计算触碰点
|
||||
if is_touched:
|
||||
sensor.compute_contact()
|
||||
|
||||
|
||||
#步骤 2.4: 读取信息
|
||||
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = sensor.read_info(
|
||||
sensor.info.FPS,
|
||||
sensor.info.VRAW,
|
||||
sensor.info.VNORMAL,
|
||||
sensor.info.IMG,
|
||||
sensor.info.CONTOUR,
|
||||
sensor.info.CENTROID,
|
||||
sensor.info.DEPTH,
|
||||
sensor.info.FNORMAL,
|
||||
sensor.info.FSHEARX,
|
||||
sensor.info.FSHEARY
|
||||
)
|
||||
|
||||
#步骤 3: 可视化显示
|
||||
# 步骤 3.1: 绘制形变场
|
||||
arrows = orisys.util.draw_arrows(
|
||||
img,
|
||||
flow,
|
||||
threshold=2,
|
||||
grid_spacing=20,
|
||||
arrow_scale=1.0
|
||||
)
|
||||
cv2.imshow("Deformation", arrows)
|
||||
|
||||
# 步骤 3.2: 在图像上触碰点和区域
|
||||
image_with_foe = orisys.util.draw_contact(img, contour, centroid)
|
||||
cv2.imshow("Contact Centroid", image_with_foe)
|
||||
|
||||
# 步骤 3.3: 绘制深度图
|
||||
# 归一化散度值到0-255范围(使用绝对值,然后归一化)
|
||||
div_abs = depth_map
|
||||
if div_abs.max() > div_abs.min():
|
||||
div_normalized = ((div_abs - div_abs.min()) / (div_abs.max() - div_abs.min()) * 255).astype(np.uint8)
|
||||
else:
|
||||
div_normalized = np.zeros_like(div_abs, dtype=np.uint8)
|
||||
|
||||
# 应用颜色映射(使用JET:蓝色=低散度,红色=高散度)
|
||||
cv2.imshow("Depth Map", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
|
||||
|
||||
#步骤 3.4: 打印资料
|
||||
print(f"fps: {fps}, fnormal: {fnormal}, fshearx: {fshearx}, fsheary: {fsheary}, depth_shape: {depth_map.shape}, sensor.resolution_raw: {sensor.img_size_raw}")
|
||||
|
||||
#步骤 3.5: 键盘控制
|
||||
key = cv2.waitKey(1) & 0xFF
|
||||
if key == ord("q"):
|
||||
print("\n退出程序...")
|
||||
break
|
||||
if key == ord("r"):
|
||||
# 重置光流追踪器(当追踪漂移时使用)
|
||||
sensor.reset()
|
||||
print("传感器已重置")
|
||||
|
||||
# =========================================================================
|
||||
# 4. 清理资源
|
||||
# =========================================================================
|
||||
sensor.disconnect() # 释放视频源和相关资源
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,50 @@
|
||||
# Examples
|
||||
|
||||
This folder contains user-facing example scripts for the Orisys SDK.
|
||||
|
||||
## Files
|
||||
|
||||
- `basic_pipeline.py`: single-sensor basic processing and visualization flow
|
||||
- `two_sensor_pipeline.py`: dual-sensor multiprocessing example
|
||||
- `plot_data.py`: real-time plotting example with PyQtGraph
|
||||
|
||||
## Typical usage
|
||||
|
||||
Run examples from the repository root so relative paths such as `./config/ddjx01.json`
|
||||
resolve correctly.
|
||||
|
||||
### Basic pipeline
|
||||
|
||||
```bash
|
||||
python examples/basic_pipeline.py -v 0 -c ./config/ddjx01.json
|
||||
```
|
||||
|
||||
### Two-sensor pipeline
|
||||
|
||||
```bash
|
||||
python examples/two_sensor_pipeline.py
|
||||
```
|
||||
|
||||
### Real-time plotting
|
||||
|
||||
```bash
|
||||
python examples/plot_data.py -v 0 -c ./config/ddjx01.json
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python environment with the SDK dependencies installed
|
||||
- a supported camera or test video
|
||||
- valid JSON config in `./config/`
|
||||
|
||||
Some examples also require:
|
||||
|
||||
- `pyqtgraph`
|
||||
- `PyQt5`
|
||||
- multiple cameras for multi-sensor demos
|
||||
|
||||
## Notes
|
||||
|
||||
- Most examples assume a tactile sensor camera is connected.
|
||||
- Press `q` in the display window to quit where applicable.
|
||||
- If your camera index is not `0`, change `-v` accordingly.
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
示例 1:单传感器基础流程
|
||||
|
||||
本示例演示 Orisys SDK 的标准处理流程:
|
||||
1. 初始化传感器(摄像头或视频文件)
|
||||
2. 获取图像并计算形变
|
||||
3. 按需计算接触区域
|
||||
4. 读取并可视化结果
|
||||
|
||||
SDK version: 0.3.1
|
||||
"""
|
||||
import os
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
import cv2
|
||||
import orisys
|
||||
import numpy as np
|
||||
import argparse
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
|
||||
parser.add_argument("--config","-c", type=str, default="./config/ddjx01.json", help="配置文件名称或路径")
|
||||
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 步骤 1:创建传感器对象(必需)
|
||||
# config_name 可以是内置配置名称,也可以是自定义配置文件路径
|
||||
# cal_path 为标定文件路径;每台设备建议使用独立的标定文件
|
||||
|
||||
# 输入既可以是摄像头编号,也可以是视频文件路径
|
||||
try:
|
||||
input = int(args.video)
|
||||
is_camera = True
|
||||
sensor = orisys.Sensor(input, config_name=args.config, verbose=args.verbose)
|
||||
except:
|
||||
is_camera = False
|
||||
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose)
|
||||
|
||||
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
|
||||
|
||||
while True:
|
||||
# 步骤 2:数据处理
|
||||
# 步骤 2.1:获取并拼接图像(必需)
|
||||
sensor.get_img()
|
||||
# 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动
|
||||
is_contact_now = sensor.compute_deformation(check_motion=True, threshold=0)
|
||||
# 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域
|
||||
# if is_contact_now:
|
||||
sensor.compute_contact()
|
||||
|
||||
|
||||
# 步骤 2.4:读取结果
|
||||
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = sensor.read_info(
|
||||
sensor.info.FPS,
|
||||
sensor.info.VRAW,
|
||||
sensor.info.VNORMAL,
|
||||
sensor.info.IMG,
|
||||
sensor.info.CONTOUR,
|
||||
sensor.info.CENTROID,
|
||||
sensor.info.DEPTH,
|
||||
sensor.info.FNORMAL,
|
||||
sensor.info.FSHEARX,
|
||||
sensor.info.FSHEARY
|
||||
)
|
||||
|
||||
# 步骤 3:可视化显示
|
||||
# 步骤 3.1:显示形变矢量场
|
||||
arrows = orisys.util.draw_arrows(
|
||||
img,
|
||||
flow,
|
||||
threshold=2,
|
||||
grid_spacing=20,
|
||||
arrow_scale=1.0
|
||||
)
|
||||
cv2.imshow("形变矢量场", arrows)
|
||||
|
||||
# 步骤 3.2:显示接触区域和质心
|
||||
image_with_foe = orisys.util.draw_contact(img, contour, centroid)
|
||||
cv2.imshow("接触区域与质心", image_with_foe)
|
||||
|
||||
# 步骤 3.3:显示深度图
|
||||
# 将深度值归一化到 0-255 范围
|
||||
div_abs = depth_map
|
||||
if div_abs.max() > div_abs.min():
|
||||
div_normalized = ((div_abs - div_abs.min()) / (div_abs.max() - div_abs.min()) * 255).astype(np.uint8)
|
||||
else:
|
||||
div_normalized = np.zeros_like(div_abs, dtype=np.uint8)
|
||||
|
||||
# 应用伪彩色映射
|
||||
cv2.imshow("深度图", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
|
||||
|
||||
# 步骤 3.4:打印关键结果
|
||||
print(
|
||||
f"FPS={fps:.2f}, 法向力={fnormal:.4f}, 切向力X={fshearx:.4f}, "
|
||||
f"切向力Y={fsheary:.4f}, 深度图尺寸={depth_map.shape}, 原始分辨率={sensor.img_size_raw}"
|
||||
)
|
||||
|
||||
# 步骤 3.5:键盘控制
|
||||
key = cv2.waitKey(1) & 0xFF
|
||||
if key == ord("q"):
|
||||
print("\n正在退出示例程序...")
|
||||
break
|
||||
if key == ord("r"):
|
||||
# 当光流追踪出现漂移时,可手动重置追踪器
|
||||
sensor.reset()
|
||||
print("追踪器已重置。")
|
||||
|
||||
# =========================================================================
|
||||
# 4. 释放资源
|
||||
# =========================================================================
|
||||
sensor.disconnect() # 释放视频源及相关资源
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,3 +1,15 @@
|
||||
"""
|
||||
示例 3:实时曲线与幅值图显示
|
||||
|
||||
本示例演示如何在读取传感器结果的同时:
|
||||
1. 显示形变矢量场
|
||||
2. 显示流场幅值热力图
|
||||
3. 使用 PyQtGraph 实时绘制法向力与切向力曲线
|
||||
4. 按键控制录像开始与结束
|
||||
|
||||
SDK version: 0.3.1
|
||||
"""
|
||||
|
||||
import os
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
import cv2
|
||||
@@ -5,10 +17,45 @@ import orisys
|
||||
import numpy as np
|
||||
import time
|
||||
import argparse
|
||||
try:
|
||||
import pyqtgraph as pg
|
||||
except:
|
||||
print("PyQtGraph is not installed. Please install it using 'pip install pyqt5 pyqtgraph'.")
|
||||
exit()
|
||||
|
||||
def lp_filter(series, alpha=0.5):
|
||||
series = np.asarray(series)
|
||||
if series.ndim == 0:
|
||||
return float(series)
|
||||
if series.shape[0] < 2:
|
||||
return float(series[-1])
|
||||
return float(series[-1] * alpha + series[-2] * (1 - alpha))
|
||||
|
||||
def median_filter(series, window_size=5):
|
||||
series = np.asarray(series)
|
||||
if series.ndim == 0:
|
||||
return float(series)
|
||||
if series.shape[0] == 0:
|
||||
return 0.0
|
||||
try:
|
||||
window_n = max(1, int(window_size))
|
||||
except (TypeError, ValueError):
|
||||
window_n = 1
|
||||
return float(np.median(series[-window_n:]))
|
||||
def moving_average(series, window_size=5):
|
||||
series = np.asarray(series)
|
||||
if series.ndim == 0:
|
||||
return float(series)
|
||||
if series.shape[0] == 0:
|
||||
return 0.0
|
||||
try:
|
||||
window_n = max(1, int(window_size))
|
||||
except (TypeError, ValueError):
|
||||
window_n = 1
|
||||
return float(np.mean(series[-window_n:]))
|
||||
|
||||
def create_plot():
|
||||
p1 = win.addPlot(title="法相力")
|
||||
p1 = win.addPlot(title="法向力")
|
||||
p1.setLabel('bottom', '时间', units='s')
|
||||
win.nextRow()
|
||||
p2 = win.addPlot(title="切向力 X")
|
||||
@@ -33,75 +80,75 @@ def update(plot_af, plot_tfx, plot_tfy, data, p1, p2, p3):
|
||||
# 深度图
|
||||
def draw_magnitude_map(vfield, threshold=3.0, colormap=cv2.COLORMAP_JET) -> np.ndarray:
|
||||
"""
|
||||
Draw color map of pointwise magnitude of velocity field.
|
||||
绘制流场幅值伪彩色图。
|
||||
|
||||
Args:
|
||||
vfield: Velocity field to visualize (2D vector field)
|
||||
threshold: Minimum magnitude to display (below threshold shows as zero/blue)
|
||||
colormap: OpenCV colormap to use (default: COLORMAP_JET)
|
||||
vfield: 需要可视化的二维矢量场
|
||||
threshold: 最小显示阈值;低于阈值的区域显示为低值颜色
|
||||
colormap: OpenCV 颜色映射类型(默认 COLORMAP_JET)
|
||||
|
||||
Returns:
|
||||
np.ndarray: 400x400 BGR image with colored magnitude map
|
||||
np.ndarray: 400x400 的 BGR 伪彩图像
|
||||
"""
|
||||
flow_field = vfield
|
||||
|
||||
# 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
|
||||
# 归一化到 0-255
|
||||
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, (400, 400), interpolation=cv2.INTER_CUBIC)
|
||||
|
||||
return out_img
|
||||
|
||||
# 实时图表
|
||||
npoints = 100 # 图表数据长度
|
||||
npoints = 100 # 曲线缓冲区长度
|
||||
pg.setConfigOption('background', 'w')
|
||||
pg.setConfigOption('foreground', 'k')
|
||||
app = pg.mkQApp("Plotting Example")
|
||||
win = pg.GraphicsLayoutWidget(show=True, title="Basic plotting examples")
|
||||
win.setWindowTitle('pyqtgraph example: Plotting')
|
||||
app = pg.mkQApp("Orisys Plot Example")
|
||||
win = pg.GraphicsLayoutWidget(show=True, title="Orisys 实时力曲线")
|
||||
win.setWindowTitle('Orisys 实时力曲线')
|
||||
p1, p2, p3, plot_af, plot_tfx, plot_tfy = create_plot()
|
||||
data = np.zeros([npoints,2,3])
|
||||
start_time = None # Will be set when sensor starts
|
||||
for i in range(3):
|
||||
data[:,0,i] = np.linspace(-npoints,0,npoints)
|
||||
data[:,0,i] = np.linspace(-npoints,0,npoints)/30
|
||||
|
||||
|
||||
|
||||
def main():
|
||||
global start_time
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--video","-v", type=str, default="0", help="Camera ID / Video file path")
|
||||
parser.add_argument("--config","-c", type=str, default="./config/ddjx01.json", help="Configuration file path")
|
||||
parser.add_argument("--cal","-cal", type=str, default="./config/ddjx01.npy", help="Calibration file path")
|
||||
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="Print detailed information")
|
||||
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
|
||||
parser.add_argument("--config","-c", type=str, default="./config/ddjx01.json", help="配置文件名称或路径")
|
||||
parser.add_argument("--cal","-cal", type=str, default="./config/ddjx01.npy", help="标定文件路径")
|
||||
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 步骤1:创建传感器对象 (必要步骤),
|
||||
# config_name为配置文件名(内置配置文件名)或路径(自定义配置文件路径)
|
||||
# cal_path为校准文件路径, 每一个设备需要创建/使用一个校准文件
|
||||
# 步骤 1:创建传感器对象(必需)
|
||||
# config_name 可以是内置配置名称,也可以是自定义配置文件路径
|
||||
# cal_path 为标定文件路径;每台设备建议使用独立的标定文件
|
||||
|
||||
# 方式1: 从视频文件读取
|
||||
# 输入既可以是摄像头编号,也可以是视频文件路径
|
||||
try:
|
||||
input = int(args.video)
|
||||
is_camera = True
|
||||
@@ -109,23 +156,23 @@ def main():
|
||||
except:
|
||||
is_camera = False
|
||||
sensor = orisys.Sensor(args.video, config_name=args.config, cal_path= args.cal, verbose=args.verbose)
|
||||
# 打开视频
|
||||
# sensor1 = Sensor("./s1.mp4",isstitch=True,cuda = True,verbose = True)
|
||||
# 方式1: 从视频文件读取
|
||||
# 下面保留了历史示例写法,便于参考
|
||||
# sensor1 = orisys.Sensor("./s1.mp4", config_name="./config/ddjx01.json", cal_path= "./config/ddjx01.npy", cuda=False, verbose=True)
|
||||
|
||||
start_time = time.time()
|
||||
is_save = False
|
||||
frame_count = 0
|
||||
writer = None
|
||||
save_path = None
|
||||
print("\n按键说明:'q' 退出程序,'s' 开始录像,'e' 结束录像。")
|
||||
while True:
|
||||
sensor.get_img() # 提取并拼接复眼图像
|
||||
sensor.get_img() # 获取并拼接复眼图像
|
||||
|
||||
if is_save and writer is not None and sensor.frame is not None:
|
||||
frame_count += 1
|
||||
save_img = sensor.frame
|
||||
|
||||
# Convert grayscale (1 channel) to BGR (3 channels) if needed
|
||||
# 若为灰度图,则先转换为 BGR 再写入视频
|
||||
if len(save_img.shape) == 2 or (len(save_img.shape) == 3 and save_img.shape[2] == 1):
|
||||
if len(save_img.shape) == 3:
|
||||
save_img = save_img.squeeze(2)
|
||||
@@ -133,44 +180,48 @@ def main():
|
||||
|
||||
writer.write(save_img)
|
||||
|
||||
sensor.compute_deformation(check_motion=False) # 计算形变和分解
|
||||
# 提取信息:
|
||||
# 向量场: VRAW, VNORMAL, VSHEAR: 原始,法向,切向
|
||||
# 帧率,时间戳: FPS, TIMESTAMP
|
||||
# 变形量总和: FNORMAL , FSHEARX, FSHEARY : 法向,切向(X),切向(Y)
|
||||
sensor.compute_deformation(check_motion=True, threshold=0) # 计算形变与分解结果
|
||||
# 读取信息:
|
||||
# 向量场:VRAW、VNORMAL、VSHEAR -> 原始 / 法向 / 切向
|
||||
# 标量:FNORMAL、FSHEARX、FSHEARY -> 法向力 / X 向切向力 / Y 向切向力
|
||||
fps, fn, fx, fy, flow, vnormal, vshear = sensor.read_info(sensor.info.FPS, sensor.info.FNORMAL, sensor.info.FSHEARX, sensor.info.FSHEARY, sensor.info.VRAW, sensor.info.VNORMAL,sensor.info.VSHEAR)
|
||||
print(fps,fn,fx,fy)
|
||||
# 显示向量场
|
||||
print(f"FPS={fps:.2f}, 法向力={fn:.4f}, 切向力X={fx:.4f}, 切向力Y={fy:.4f}")
|
||||
# 显示形变矢量场
|
||||
arrows = orisys.util.draw_arrows(sensor.img, flow, threshold=5, grid_spacing=10, arrow_scale=1.0)
|
||||
cv2.imshow("Deformation", arrows)
|
||||
# 显示深度图
|
||||
cv2.imshow("形变矢量场", arrows)
|
||||
# 显示流场幅值图
|
||||
mag = draw_magnitude_map(flow)
|
||||
cv2.imshow("Magnitude", mag)
|
||||
cv2.imshow("流场幅值图", mag)
|
||||
|
||||
# 显示图表 - 更新时间轴
|
||||
# 更新实时曲线
|
||||
current_time = time.time() - start_time
|
||||
data_filtered = data.copy()
|
||||
for i in range(3):
|
||||
data[:-1, 0, i] = data[1:, 0, i] # shift time data left
|
||||
data[:-1, 1, i] = data[1:, 1, i] # shift force data left
|
||||
data[-1, 0, :] = current_time # set current time for all plots
|
||||
data[:-1, 0, i] = data[1:, 0, i] # 时间轴左移
|
||||
data[:-1, 1, i] = data[1:, 1, i] # 力数据左移
|
||||
data[-1, 0, :] = current_time # 写入当前时间
|
||||
data[-1, 1, 0] = fn
|
||||
data[-1, 1, 1] = fx
|
||||
data[-1, 1, 2] = fy
|
||||
update(plot_af, plot_tfx, plot_tfy, data, p1, p2, p3) # Update plot
|
||||
# 对最新采样点做一阶低通,滤波对象是历史曲线而不是单个标量。
|
||||
data_filtered[-1, 1, 0] = moving_average(data[:, 1, 0])
|
||||
data_filtered[-1, 1, 1] = moving_average(data[:, 1, 1])
|
||||
data_filtered[-1, 1, 2] = moving_average(data[:, 1, 2])
|
||||
update(plot_af, plot_tfx, plot_tfy, data_filtered, p1, p2, p3) # 更新曲线
|
||||
|
||||
key = cv2.waitKey(1)
|
||||
if key & 0xFF == ord("q"):
|
||||
print("\n 停止程序")
|
||||
print("\n正在退出示例程序...")
|
||||
break
|
||||
if key == ord("s"):
|
||||
save_path = f"./data/pulse_test.mp4"
|
||||
# Ensure directory exists
|
||||
# 确保输出目录存在
|
||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||
# Get frame dimensions from the actual frame
|
||||
# 从当前帧获取视频尺寸
|
||||
save_img = sensor.frame
|
||||
|
||||
if save_img is not None:
|
||||
# Convert grayscale (1 channel) to BGR (3 channels) if needed for dimension check
|
||||
# 如为灰度图,先转换为 BGR 以获取视频尺寸
|
||||
temp_img = save_img.copy()
|
||||
if len(temp_img.shape) == 2 or (len(temp_img.shape) == 3 and temp_img.shape[2] == 1):
|
||||
if len(temp_img.shape) == 3:
|
||||
@@ -181,24 +232,24 @@ def main():
|
||||
writer = cv2.VideoWriter(save_path, cv2.VideoWriter_fourcc(*'mp4v'), 30, (frame_width, frame_height))
|
||||
if writer.isOpened():
|
||||
is_save = True
|
||||
print(f"Start writing to {save_path}")
|
||||
print(f"开始录像:{save_path}")
|
||||
else:
|
||||
print(f"Failed to open video writer for {save_path}")
|
||||
print(f"无法创建录像文件:{save_path}")
|
||||
writer = None
|
||||
else:
|
||||
print("No frame available to determine video dimensions")
|
||||
print("当前没有可用帧,无法确定录像尺寸。")
|
||||
if key == ord("e"):
|
||||
is_save = False
|
||||
if writer is not None:
|
||||
writer.release()
|
||||
writer = None
|
||||
if save_path is not None:
|
||||
print(f"Stop writing to {save_path}, total frames: {frame_count}")
|
||||
print(f"录像结束:{save_path},总帧数:{frame_count}")
|
||||
else:
|
||||
print(f"Stop writing, total frames: {frame_count}")
|
||||
print(f"录像结束,总帧数:{frame_count}")
|
||||
frame_count = 0
|
||||
|
||||
sensor.disconnect() # 断开传感器连接
|
||||
sensor.disconnect() # 断开传感器连接并释放资源
|
||||
|
||||
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
"""
|
||||
示例 4: 多传感器并行处理(多进程模式 - 不使用 MultiSensor)
|
||||
示例 2:双传感器并行处理(多进程)
|
||||
|
||||
本示例演示如何使用 multiprocessing 同时运行两个传感器:
|
||||
- 每个传感器运行在独立的进程中
|
||||
- 每个传感器运行在独立进程中
|
||||
- 每个进程拥有独立的 CUDA 上下文
|
||||
- 使用 multiprocessing.Process 和 Queue 进行进程间通信
|
||||
- 通过 Process 和 Queue 在主进程中汇总结果并显示
|
||||
|
||||
架构说明:
|
||||
进程 1 (传感器 0): 采集 → 光流 → HHD → 接触检测 → 队列
|
||||
进程 2 (传感器 1): 采集 → 光流 → HHD → 接触检测 → 队列
|
||||
进程 1(传感器 0):采集 → 光流 → HHD → 接触检测 → 队列
|
||||
进程 2(传感器 1):采集 → 光流 → HHD → 接触检测 → 队列
|
||||
↓
|
||||
主进程: 显示 ← ───────────────┘
|
||||
主进程: 显示 ← ───────────────┘
|
||||
|
||||
适用场景:多指触觉传感器、多视角采集、需要 GPU 加速的多路处理
|
||||
适用场景:多指触觉传感器、多视角采集,以及需要 GPU 加速的多路处理。
|
||||
|
||||
注意事项:
|
||||
- Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support()
|
||||
- 每个传感器进程会占用独立的 GPU 显存
|
||||
- 每个传感器进程都会占用独立的 GPU 显存
|
||||
|
||||
SDK version: 0.3.1
|
||||
"""
|
||||
import os
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
@@ -26,20 +28,20 @@ import numpy as np
|
||||
import multiprocessing as mp
|
||||
from multiprocessing import Queue, Process
|
||||
import time
|
||||
import argparse
|
||||
|
||||
|
||||
def sensor_worker(sensor_id, vid_src, config_name, cal_path, result_queue, stop_event, target_fps=None):
|
||||
def sensor_worker(sensor_id, vid_src, config_name, result_queue, stop_event, target_fps=None):
|
||||
"""
|
||||
传感器工作进程函数
|
||||
传感器工作进程。
|
||||
|
||||
Args:
|
||||
sensor_id: 传感器编号
|
||||
vid_src: 视频源(文件路径或摄像头索引)
|
||||
config_name: 配置名称
|
||||
cal_path: 标定路径
|
||||
result_queue: 结果队列,用于向主进程发送数据
|
||||
stop_event: 停止事件,用于通知工作进程退出
|
||||
target_fps: 目标帧率;若设置则用定时器限速,使多传感器 FPS 接近一致
|
||||
target_fps: 目标帧率;设置后将对处理循环限速,使多路传感器 FPS 更接近
|
||||
"""
|
||||
try:
|
||||
# 在每个进程中创建独立的传感器实例
|
||||
@@ -48,27 +50,26 @@ def sensor_worker(sensor_id, vid_src, config_name, cal_path, result_queue, stop_
|
||||
isstitch=True,
|
||||
cuda=True,
|
||||
verbose=False, # 减少子进程输出
|
||||
config_name=config_name,
|
||||
cal_path=cal_path
|
||||
config_name=config_name
|
||||
)
|
||||
|
||||
print(f"传感器 {sensor_id} 已启动" + (f" (target_fps={target_fps})" if target_fps else ""))
|
||||
print(f"传感器 {sensor_id} 已启动" + (f"(目标帧率:{target_fps} FPS)" if target_fps else ""))
|
||||
|
||||
frame_interval = 1.0 / target_fps if target_fps and target_fps > 0 else 0.0
|
||||
|
||||
while not stop_event.is_set():
|
||||
t_start = time.perf_counter()
|
||||
try:
|
||||
# 步骤1: 获取图像
|
||||
# 步骤 1:获取图像
|
||||
sensor.get_img()
|
||||
|
||||
# 步骤2: 计算形变场
|
||||
# 步骤 2:计算形变场
|
||||
sensor.compute_deformation()
|
||||
|
||||
# 步骤3: 计算触碰点
|
||||
# 步骤 3:计算接触区域(如需要可取消注释)
|
||||
# sensor.compute_contact()
|
||||
|
||||
# 步骤4: 读取信息
|
||||
# 步骤 4:读取结果
|
||||
fps, flow, vnormal, img, contour, centroid, depth_map = sensor.read_info(
|
||||
sensor.info.FPS,
|
||||
sensor.info.VRAW,
|
||||
@@ -79,7 +80,7 @@ def sensor_worker(sensor_id, vid_src, config_name, cal_path, result_queue, stop_
|
||||
sensor.info.DEPTH
|
||||
)
|
||||
|
||||
# 步骤5: 准备数据字典(确保所有数据都是可序列化的)
|
||||
# 步骤 5:整理结果字典(确保数据可序列化)
|
||||
data = {
|
||||
'sensor_id': sensor_id,
|
||||
'fps': fps,
|
||||
@@ -92,100 +93,104 @@ def sensor_worker(sensor_id, vid_src, config_name, cal_path, result_queue, stop_
|
||||
'timestamp': time.time()
|
||||
}
|
||||
|
||||
# 步骤6: 发送数据到主进程(非阻塞)
|
||||
# 步骤 6:将结果发送到主进程(非阻塞)
|
||||
try:
|
||||
result_queue.put_nowait(data)
|
||||
except Exception:
|
||||
# 如果队列已满,跳过这一帧
|
||||
# 如果队列已满,则丢弃当前帧,避免阻塞工作进程
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
print(f"传感器 {sensor_id} 处理错误: {e}")
|
||||
print(f"传感器 {sensor_id} 处理失败:{e}")
|
||||
time.sleep(0.1)
|
||||
|
||||
# 步骤7: 按 target_fps 限速,使多传感器 FPS 接近
|
||||
# 步骤 7:按 target_fps 限速,使多路传感器 FPS 更接近
|
||||
if frame_interval > 0:
|
||||
elapsed = time.perf_counter() - t_start
|
||||
sleep_time = frame_interval - elapsed
|
||||
if sleep_time > 0.001: # 至少 1ms 才 sleep,避免无意义调度
|
||||
time.sleep(sleep_time)
|
||||
|
||||
# 清理资源
|
||||
# 释放资源
|
||||
sensor.disconnect()
|
||||
print(f"传感器 {sensor_id} 已停止")
|
||||
print(f"传感器 {sensor_id} 已停止。")
|
||||
|
||||
except Exception as e:
|
||||
print(f"传感器 {sensor_id} 初始化失败: {e}")
|
||||
print(f"传感器 {sensor_id} 初始化失败:{e}")
|
||||
result_queue.put({'sensor_id': sensor_id, 'error': str(e)})
|
||||
|
||||
|
||||
def main():
|
||||
# =========================================================================
|
||||
# 1. 配置多个传感器
|
||||
# 1. 配置多路传感器
|
||||
# =========================================================================
|
||||
# 目标帧率:设为正数时,两路传感器会按此帧率限速,使 FPS 接近一致;设为 None 则不限速
|
||||
# 目标帧率:设为正数时,两路传感器会按该帧率限速;设为 None 则不限速
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--video1","-v1", type=str, default="0", help="视频文件路径")
|
||||
parser.add_argument("--video2","-v2", type=str, default="0", help="视频文件路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
target_fps = 30
|
||||
|
||||
num_sensors = 2
|
||||
sensor_configs = [
|
||||
{
|
||||
'sensor_id': 0,
|
||||
'vid_src': 2,
|
||||
'config_name': './config/prototype_of3_720p.json',
|
||||
'cal_path': './config/ddjx01.npy'
|
||||
'vid_src': args.video1,
|
||||
'config_name': './config/ddjx01.json'
|
||||
},
|
||||
{
|
||||
'sensor_id': 1,
|
||||
'vid_src': 4, # 可以改为不同的视频源或摄像头索引
|
||||
'config_name': './config/prototype_of3_720p.json',
|
||||
'cal_path': './config/ddjx01.npy'
|
||||
'vid_src': args.video2, # 可替换为其他视频源或摄像头索引
|
||||
'config_name': './config/ddjx01.json'
|
||||
},
|
||||
]
|
||||
|
||||
# =========================================================================
|
||||
# 2. 创建进程间通信对象
|
||||
# =========================================================================
|
||||
# 增大队列大小以缓冲更多帧,避免因主进程处理延迟导致的丢帧
|
||||
# 适当增大队列容量,以缓冲更多帧并减轻主进程偶发延迟带来的丢帧
|
||||
result_queues = [Queue(maxsize=30) for _ in sensor_configs]
|
||||
stop_events = [mp.Event() for _ in sensor_configs]
|
||||
|
||||
# =========================================================================
|
||||
# 3. 启动传感器工作进程
|
||||
# 3. 启动工作进程
|
||||
# =========================================================================
|
||||
processes = []
|
||||
for i, config in enumerate(sensor_configs):
|
||||
p = Process(
|
||||
target=sensor_worker,
|
||||
args=(i, config['vid_src'], config['config_name'], config['cal_path'],
|
||||
args=(i, config['vid_src'], config['config_name'],
|
||||
result_queues[i], stop_events[i], target_fps)
|
||||
)
|
||||
p.start()
|
||||
processes.append(p)
|
||||
|
||||
print(f"\n已启动 {len(processes)} 个传感器进程")
|
||||
print("按 'q' 退出程序")
|
||||
print(f"\n已启动 {len(processes)} 个传感器进程。")
|
||||
print("按键说明:'q' 退出程序。")
|
||||
|
||||
# 用于存储每个传感器的最新数据
|
||||
# 保存每个传感器的最新一帧数据
|
||||
latest_data = [None] * len(sensor_configs)
|
||||
|
||||
# 用于控制打印频率,减少I/O开销
|
||||
# 控制打印频率,减少 I/O 开销
|
||||
print_counter = 0
|
||||
print_interval = 30 # 每30帧打印一次,进一步减少I/O
|
||||
print_interval = 30 # 每 30 帧打印一次
|
||||
|
||||
# 用于控制按键检查频率
|
||||
# 控制按键检查频率
|
||||
key_check_counter = 0
|
||||
key_check_interval = 5 # 每 5 帧检查一次按键
|
||||
|
||||
try:
|
||||
# =====================================================================
|
||||
# 4. 主循环:从队列获取数据并显示
|
||||
# 4. 主循环:从队列获取结果并显示
|
||||
# =====================================================================
|
||||
while True:
|
||||
# 从所有队列中快速获取最新数据(高效清空队列,只保留最新帧)
|
||||
# 从所有队列中快速获取最新数据,只保留最后一帧
|
||||
for i, queue in enumerate(result_queues):
|
||||
# 快速清空队列,只保留最后一帧
|
||||
# 清空队列,仅保留最后一帧
|
||||
data = None
|
||||
# 一次性获取所有可用数据,只保留最后一个
|
||||
# 读取所有可用数据,只保留最新结果
|
||||
while True:
|
||||
try:
|
||||
data = queue.get_nowait()
|
||||
@@ -195,14 +200,14 @@ def main():
|
||||
if data is not None:
|
||||
latest_data[i] = data
|
||||
|
||||
# 显示所有传感器的数据
|
||||
# 显示所有传感器的最新结果
|
||||
for data in latest_data:
|
||||
if data is None or 'error' in data:
|
||||
continue
|
||||
|
||||
sid = data['sensor_id']
|
||||
|
||||
# 显示形变场
|
||||
# 显示形变矢量场
|
||||
if data['img'] is not None and data['flow'] is not None:
|
||||
arrows = orisys.util.draw_arrows(
|
||||
data['img'],
|
||||
@@ -211,7 +216,7 @@ def main():
|
||||
grid_spacing=10,
|
||||
arrow_scale=1.0
|
||||
)
|
||||
cv2.imshow(f"Deformation {sid}", arrows)
|
||||
cv2.imshow(f"传感器 {sid} - 形变矢量场", arrows)
|
||||
|
||||
# # 显示触碰点和区域
|
||||
# if data['img'] is not None:
|
||||
@@ -233,33 +238,33 @@ def main():
|
||||
# cv2.imshow(f"Depth Map {sid}",
|
||||
# cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
|
||||
|
||||
# 降低打印频率以减少I/O开销(在主循环级别控制)
|
||||
# 降低打印频率,减少主循环中的 I/O 开销
|
||||
print_counter += 1
|
||||
if print_counter >= print_interval:
|
||||
for data in latest_data:
|
||||
if data is not None and 'error' not in data:
|
||||
sid = data['sensor_id']
|
||||
print(f"传感器 {sid}: FPS={data['fps']:5.1f}")
|
||||
print(f"传感器 {sid}:FPS={data['fps']:5.1f}")
|
||||
print_counter = 0
|
||||
|
||||
# 降低按键检查频率以减少开销
|
||||
# 降低按键检查频率,减少额外开销
|
||||
key_check_counter += 1
|
||||
if key_check_counter >= key_check_interval:
|
||||
key = cv2.waitKey(1) & 0xFF
|
||||
if key == ord('q'):
|
||||
print("\n退出程序...")
|
||||
print("\n正在退出示例程序...")
|
||||
break
|
||||
key_check_counter = 0
|
||||
|
||||
finally:
|
||||
# =====================================================================
|
||||
# 5. 清理资源
|
||||
# 5. 释放资源
|
||||
# =====================================================================
|
||||
# 通知所有工作进程停止
|
||||
for event in stop_events:
|
||||
event.set()
|
||||
|
||||
# 等待所有进程结束
|
||||
# 等待所有工作进程结束
|
||||
for p in processes:
|
||||
p.join(timeout=2)
|
||||
if p.is_alive():
|
||||
@@ -267,10 +272,10 @@ def main():
|
||||
p.join()
|
||||
|
||||
cv2.destroyAllWindows()
|
||||
print("所有传感器进程已停止")
|
||||
print("所有传感器进程已停止。")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Windows 下多进程必需的初始化
|
||||
# Windows 下运行多进程示例所需的初始化
|
||||
mp.freeze_support()
|
||||
main()
|
||||
@@ -1,296 +0,0 @@
|
||||
"""
|
||||
示例 4: 多传感器并行处理(多进程模式 - 不使用 MultiSensor)
|
||||
|
||||
本示例演示如何使用 multiprocessing 同时运行两个传感器:
|
||||
- 每个传感器运行在独立的进程中
|
||||
- 每个进程拥有独立的 CUDA 上下文
|
||||
- 使用 multiprocessing.Process 和 Queue 进行进程间通信
|
||||
|
||||
架构说明:
|
||||
进程 1 (传感器 0): 采集 → 光流 → HHD → 接触检测 → 队列
|
||||
进程 2 (传感器 1): 采集 → 光流 → HHD → 接触检测 → 队列
|
||||
↓
|
||||
主进程: 显示 ← ───────────────┘
|
||||
|
||||
适用场景:多指触觉传感器、多视角采集、需要 GPU 加速的多路处理
|
||||
|
||||
注意事项:
|
||||
- Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support()
|
||||
- 每个传感器进程会占用独立的 GPU 显存
|
||||
"""
|
||||
import os
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
import cv2
|
||||
import orisys
|
||||
import numpy as np
|
||||
import multiprocessing as mp
|
||||
from multiprocessing import Queue, Process
|
||||
import time
|
||||
|
||||
|
||||
def sensor_worker(sensor_id, vid_src, config_name, cal_path, result_queue, stop_event, target_fps=None):
|
||||
"""
|
||||
传感器工作进程函数
|
||||
|
||||
Args:
|
||||
sensor_id: 传感器编号
|
||||
vid_src: 视频源(文件路径或摄像头索引)
|
||||
config_name: 配置名称
|
||||
cal_path: 标定路径
|
||||
result_queue: 结果队列,用于向主进程发送数据
|
||||
stop_event: 停止事件,用于通知工作进程退出
|
||||
target_fps: 目标帧率;若设置则用定时器限速,使多传感器 FPS 接近一致
|
||||
"""
|
||||
try:
|
||||
# 在每个进程中创建独立的传感器实例
|
||||
sensor = orisys.Sensor(
|
||||
vid_src,
|
||||
isstitch=True,
|
||||
cuda=True,
|
||||
verbose=False, # 减少子进程输出
|
||||
config_name=config_name,
|
||||
cal_path=cal_path
|
||||
)
|
||||
|
||||
print(f"传感器 {sensor_id} 已启动" + (f" (target_fps={target_fps})" if target_fps else ""))
|
||||
|
||||
frame_interval = 1.0 / target_fps if target_fps and target_fps > 0 else 0.0
|
||||
|
||||
while not stop_event.is_set():
|
||||
t_start = time.perf_counter()
|
||||
try:
|
||||
# 步骤1: 获取图像
|
||||
sensor.get_img()
|
||||
|
||||
# 步骤2: 计算形变场
|
||||
sensor.compute_deformation()
|
||||
|
||||
# 步骤3: 计算触碰点
|
||||
sensor.compute_contact()
|
||||
|
||||
# 步骤4: 读取信息
|
||||
fps, flow, vnormal, img, contour, centroid, depth_map = sensor.read_info(
|
||||
sensor.info.FPS,
|
||||
sensor.info.VRAW,
|
||||
sensor.info.VNORMAL,
|
||||
sensor.info.IMG,
|
||||
sensor.info.CONTOUR,
|
||||
sensor.info.CENTROID,
|
||||
sensor.info.DEPTH
|
||||
)
|
||||
|
||||
# 步骤5: 准备数据字典(确保所有数据都是可序列化的)
|
||||
data = {
|
||||
'sensor_id': sensor_id,
|
||||
'fps': fps,
|
||||
'flow': flow.copy() if flow is not None else None,
|
||||
'vnormal': vnormal.copy() if vnormal is not None else None,
|
||||
'img': img.copy() if img is not None else None,
|
||||
'contour': contour,
|
||||
'centroid': centroid,
|
||||
'depth_map': depth_map.copy() if depth_map is not None else None,
|
||||
'timestamp': time.time()
|
||||
}
|
||||
|
||||
# 步骤6: 发送数据到主进程(非阻塞)
|
||||
try:
|
||||
result_queue.put_nowait(data)
|
||||
except Exception:
|
||||
# 如果队列已满,跳过这一帧
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
print(f"传感器 {sensor_id} 处理错误: {e}")
|
||||
time.sleep(0.1)
|
||||
|
||||
# 步骤7: 精确限速(time.sleep 在 Windows 上精度仅 ~15.6ms,需留足余量后 spin-wait)
|
||||
if frame_interval > 0:
|
||||
deadline = t_start + frame_interval
|
||||
remaining = deadline - time.perf_counter()
|
||||
if remaining > 0.018:
|
||||
time.sleep(remaining - 0.018) # coarse sleep; leave 18ms margin for Windows granularity
|
||||
while time.perf_counter() < deadline:
|
||||
pass # spin-wait for the last few ms
|
||||
|
||||
# 清理资源
|
||||
sensor.disconnect()
|
||||
print(f"传感器 {sensor_id} 已停止")
|
||||
|
||||
except Exception as e:
|
||||
print(f"传感器 {sensor_id} 初始化失败: {e}")
|
||||
result_queue.put({'sensor_id': sensor_id, 'error': str(e)})
|
||||
|
||||
|
||||
def main():
|
||||
# =========================================================================
|
||||
# 1. 配置多个传感器
|
||||
# =========================================================================
|
||||
# 目标帧率:设为 None 不限速(可跑满 100+ FPS);设为正数则 sleep 限速,使 FPS ≤ target_fps
|
||||
target_fps = 30 # 不限速;设为 30 等可 cap 帧率
|
||||
|
||||
num_sensors = 5
|
||||
sensor_configs = [
|
||||
{
|
||||
'sensor_id': 0,
|
||||
'vid_src': './s1_480p.mp4',
|
||||
'config_name': './config/prototype_of3_480p.json',
|
||||
'cal_path': './config/dd01a_0_480p.npy'
|
||||
},
|
||||
{
|
||||
'sensor_id': 1,
|
||||
'vid_src': './s1_480p.mp4', # 可以改为不同的视频源或摄像头索引
|
||||
'config_name': './config/prototype_of3_480p.json',
|
||||
'cal_path': './config/dd01a_0_480p.npy'
|
||||
},
|
||||
{
|
||||
'sensor_id': 2,
|
||||
'vid_src': './s1_480p.mp4', # 可以改为不同的视频源或摄像头索引
|
||||
'config_name': './config/prototype_of3_480p.json',
|
||||
'cal_path': './config/dd01a_0_480p.npy'
|
||||
},
|
||||
{
|
||||
'sensor_id': 3,
|
||||
'vid_src': './s1_480p.mp4', # 可以改为不同的视频源或摄像头索引
|
||||
'config_name': './config/prototype_of3_480p.json',
|
||||
'cal_path': './config/dd01a_0_480p.npy'
|
||||
},
|
||||
{
|
||||
'sensor_id': 4,
|
||||
'vid_src': './s1_480p.mp4', # 可以改为不同的视频源或摄像头索引
|
||||
'config_name': './config/prototype_of3_480p.json',
|
||||
'cal_path': './config/dd01a_0_480p.npy'
|
||||
},
|
||||
]
|
||||
|
||||
# =========================================================================
|
||||
# 2. 创建进程间通信对象
|
||||
# =========================================================================
|
||||
# 增大队列大小以缓冲更多帧,避免因主进程处理延迟导致的丢帧
|
||||
result_queues = [Queue(maxsize=30) for _ in sensor_configs]
|
||||
stop_events = [mp.Event() for _ in sensor_configs]
|
||||
|
||||
# =========================================================================
|
||||
# 3. 启动传感器工作进程
|
||||
# =========================================================================
|
||||
processes = []
|
||||
for i, config in enumerate(sensor_configs):
|
||||
p = Process(
|
||||
target=sensor_worker,
|
||||
args=(i, config['vid_src'], config['config_name'], config['cal_path'],
|
||||
result_queues[i], stop_events[i], target_fps)
|
||||
)
|
||||
p.start()
|
||||
processes.append(p)
|
||||
|
||||
print(f"\n已启动 {len(processes)} 个传感器进程")
|
||||
print("按 'q' 退出程序")
|
||||
|
||||
# 用于存储每个传感器的最新数据
|
||||
latest_data = [None] * len(sensor_configs)
|
||||
|
||||
# 用于控制打印频率,减少I/O开销
|
||||
print_counter = 0
|
||||
print_interval = 30 # 每30帧打印一次,进一步减少I/O
|
||||
|
||||
# 用于控制按键检查频率
|
||||
key_check_counter = 0
|
||||
key_check_interval = 5 # 每5帧检查一次按键
|
||||
|
||||
try:
|
||||
# =====================================================================
|
||||
# 4. 主循环:从队列获取数据并显示
|
||||
# =====================================================================
|
||||
while True:
|
||||
# 从所有队列中快速获取最新数据(高效清空队列,只保留最新帧)
|
||||
for i, queue in enumerate(result_queues):
|
||||
# 快速清空队列,只保留最后一帧
|
||||
data = None
|
||||
# 一次性获取所有可用数据,只保留最后一个
|
||||
while True:
|
||||
try:
|
||||
data = queue.get_nowait()
|
||||
except:
|
||||
break
|
||||
|
||||
if data is not None:
|
||||
latest_data[i] = data
|
||||
|
||||
# 显示所有传感器的数据
|
||||
for data in latest_data:
|
||||
if data is None or 'error' in data:
|
||||
continue
|
||||
|
||||
sid = data['sensor_id']
|
||||
|
||||
# 显示形变场
|
||||
if data['img'] is not None and data['flow'] is not None:
|
||||
arrows = orisys.util.draw_arrows(
|
||||
data['img'],
|
||||
data['flow'],
|
||||
threshold=5,
|
||||
grid_spacing=10,
|
||||
arrow_scale=1.0
|
||||
)
|
||||
cv2.imshow(f"Deformation {sid}", arrows)
|
||||
|
||||
# # 显示触碰点和区域
|
||||
# if data['img'] is not None:
|
||||
# image_with_foe = orisys.util.draw_contact(
|
||||
# data['img'],
|
||||
# data['contour'],
|
||||
# data['centroid']
|
||||
# )
|
||||
# cv2.imshow(f"Contact Centroid {sid}", image_with_foe)
|
||||
|
||||
# # 显示深度图
|
||||
# if data['depth_map'] is not None and data['depth_map'].size > 0:
|
||||
# div_abs = data['depth_map']
|
||||
# if div_abs.max() > div_abs.min():
|
||||
# div_normalized = ((div_abs - div_abs.min()) /
|
||||
# (div_abs.max() - div_abs.min()) * 255).astype(np.uint8)
|
||||
# else:
|
||||
# div_normalized = np.zeros_like(div_abs, dtype=np.uint8)
|
||||
# cv2.imshow(f"Depth Map {sid}",
|
||||
# cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
|
||||
|
||||
# 降低打印频率以减少I/O开销(在主循环级别控制)
|
||||
print_counter += 1
|
||||
if print_counter >= print_interval:
|
||||
for data in latest_data:
|
||||
if data is not None and 'error' not in data:
|
||||
sid = data['sensor_id']
|
||||
print(f"传感器 {sid}: FPS={data['fps']:5.1f}")
|
||||
print_counter = 0
|
||||
|
||||
# 降低按键检查频率以减少开销
|
||||
key_check_counter += 1
|
||||
if key_check_counter >= key_check_interval:
|
||||
key = cv2.waitKey(1) & 0xFF
|
||||
if key == ord('q'):
|
||||
print("\n退出程序...")
|
||||
break
|
||||
key_check_counter = 0
|
||||
|
||||
finally:
|
||||
# =====================================================================
|
||||
# 5. 清理资源
|
||||
# =====================================================================
|
||||
# 通知所有工作进程停止
|
||||
for event in stop_events:
|
||||
event.set()
|
||||
|
||||
# 等待所有进程结束
|
||||
for p in processes:
|
||||
p.join(timeout=2)
|
||||
if p.is_alive():
|
||||
p.terminate()
|
||||
p.join()
|
||||
|
||||
cv2.destroyAllWindows()
|
||||
print("所有传感器进程已停止")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Windows 下多进程必需的初始化
|
||||
mp.freeze_support()
|
||||
main()
|
||||
@@ -1,354 +0,0 @@
|
||||
"""
|
||||
示例 4: 多传感器并行处理(多进程模式 - 不使用 MultiSensor)
|
||||
|
||||
本示例演示如何使用 multiprocessing 同时运行两个传感器:
|
||||
- 每个传感器运行在独立的进程中
|
||||
- 每个进程拥有独立的 CUDA 上下文
|
||||
- 使用 multiprocessing.Process 和 Queue 进行进程间通信
|
||||
|
||||
架构说明:
|
||||
进程 1 (传感器 0): 采集 → 光流 → HHD → 接触检测 → 队列
|
||||
进程 2 (传感器 1): 采集 → 光流 → HHD → 接触检测 → 队列
|
||||
↓
|
||||
主进程: 显示 ← ───────────────┘
|
||||
|
||||
适用场景:多指触觉传感器、多视角采集、需要 GPU 加速的多路处理
|
||||
|
||||
注意事项:
|
||||
- Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support()
|
||||
- 每个传感器进程会占用独立的 GPU 显存
|
||||
"""
|
||||
import os
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
import cv2
|
||||
import orisys
|
||||
import numpy as np
|
||||
import multiprocessing as mp
|
||||
from multiprocessing import Queue, Process
|
||||
import time
|
||||
import ctypes
|
||||
import platform
|
||||
import ctypes
|
||||
|
||||
# Multi-sensor FPS stabilization options
|
||||
STABILITY_MODE = True # True: prioritize similar FPS across sensors
|
||||
TARGET_FPS = 30.0 # Used only when STABILITY_MODE=True
|
||||
SEND_VISUALIZATION_DATA = False # True sends img/flow/depth to main process (higher IPC cost)
|
||||
|
||||
def _enable_high_res_sleep():
|
||||
"""
|
||||
Enable high-resolution sleep for accurate FPS pacing.
|
||||
Note: time.perf_counter() is already high-resolution on all platforms (Windows, macOS, Linux).
|
||||
This function only improves time.sleep() accuracy on Windows.
|
||||
"""
|
||||
if os.name == "nt": # Windows (x64 and ARM64)
|
||||
try:
|
||||
# timeBeginPeriod improves time.sleep() accuracy (default ~15ms -> 1ms)
|
||||
# time.perf_counter() is already high-res on Windows (uses QueryPerformanceCounter)
|
||||
winmm = ctypes.WinDLL("winmm")
|
||||
winmm.timeBeginPeriod(1) # Request 1ms timer resolution for sleep()
|
||||
return winmm
|
||||
except Exception:
|
||||
return None
|
||||
# macOS and Linux: time.perf_counter() and time.sleep() are already high-resolution
|
||||
# No special setup needed
|
||||
return None
|
||||
|
||||
|
||||
def _disable_high_res_sleep(handle):
|
||||
"""Restore default scheduler timer resolution (Windows only)."""
|
||||
if handle is None:
|
||||
return
|
||||
if os.name == "nt" and isinstance(handle, ctypes.WinDLL):
|
||||
try:
|
||||
handle.timeEndPeriod(1)
|
||||
except Exception:
|
||||
pass
|
||||
# macOS and Linux don't need cleanup
|
||||
|
||||
|
||||
def sensor_worker(sensor_id, vid_src, config_name, cal_path, result_queue, stop_event):
|
||||
"""
|
||||
传感器工作进程函数
|
||||
|
||||
Args:
|
||||
sensor_id: 传感器编号
|
||||
vid_src: 视频源(文件路径或摄像头索引)
|
||||
config_name: 配置名称
|
||||
result_queue: 结果队列,用于向主进程发送数据
|
||||
stop_event: 停止事件,用于通知工作进程退出
|
||||
"""
|
||||
timer_handle = None
|
||||
try:
|
||||
# Limit per-process CPU thread fan-out to reduce contention jitter.
|
||||
os.environ["OMP_NUM_THREADS"] = "1"
|
||||
os.environ["OPENBLAS_NUM_THREADS"] = "1"
|
||||
os.environ["MKL_NUM_THREADS"] = "1"
|
||||
cv2.setNumThreads(1)
|
||||
if STABILITY_MODE and TARGET_FPS > 0:
|
||||
timer_handle = _enable_high_res_sleep()
|
||||
|
||||
# 在每个进程中创建独立的传感器实例
|
||||
sensor = orisys.Sensor(
|
||||
vid_src,
|
||||
isstitch=True,
|
||||
cuda=True,
|
||||
verbose=False, # 减少子进程输出
|
||||
config_name=config_name,
|
||||
cal_path=cal_path
|
||||
)
|
||||
|
||||
print(f"传感器 {sensor_id} 已启动")
|
||||
|
||||
target_period = (1.0 / TARGET_FPS) if (STABILITY_MODE and TARGET_FPS > 0) else 0.0
|
||||
next_tick = time.perf_counter()
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
# 步骤1: 获取图像
|
||||
sensor.get_img()
|
||||
|
||||
# 步骤2: 计算形变场
|
||||
sensor.compute_deformation()
|
||||
|
||||
# 步骤3: 计算触碰点
|
||||
sensor.compute_contact()
|
||||
|
||||
# 步骤4: 读取信息
|
||||
if SEND_VISUALIZATION_DATA:
|
||||
fps, flow, img, contour, centroid, depth_map = sensor.read_info(
|
||||
sensor.info.FPS,
|
||||
sensor.info.VRAW,
|
||||
sensor.info.IMG,
|
||||
sensor.info.CONTOUR,
|
||||
sensor.info.CENTROID,
|
||||
sensor.info.DEPTH
|
||||
)
|
||||
# Full payload (higher IPC cost)
|
||||
data = {
|
||||
'sensor_id': sensor_id,
|
||||
'fps': fps,
|
||||
'flow': flow.copy() if flow is not None else None,
|
||||
'img': img.copy() if img is not None else None,
|
||||
'contour': contour,
|
||||
'centroid': centroid,
|
||||
'depth_map': depth_map.copy() if depth_map is not None else None,
|
||||
'timestamp': time.time()
|
||||
}
|
||||
else:
|
||||
# Lightweight payload for stable multi-process throughput
|
||||
fps, = sensor.read_info(sensor.info.FPS)
|
||||
data = {
|
||||
'sensor_id': sensor_id,
|
||||
'fps': fps,
|
||||
'timestamp': time.time()
|
||||
}
|
||||
|
||||
# 步骤6: 发送数据到主进程(非阻塞)
|
||||
try:
|
||||
result_queue.put_nowait(data)
|
||||
except:
|
||||
# Queue full: keep latest data (drop oldest)
|
||||
try:
|
||||
result_queue.get_nowait()
|
||||
result_queue.put_nowait(data)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Pace workers to a common target so sensor FPS stays similar.
|
||||
# Use drift-free scheduler: next_tick increments by fixed period.
|
||||
if target_period > 0:
|
||||
next_tick += target_period
|
||||
now = time.perf_counter()
|
||||
sleep_time = next_tick - now
|
||||
if sleep_time > 0:
|
||||
time.sleep(sleep_time)
|
||||
else:
|
||||
# If we are behind, reset schedule to current time.
|
||||
next_tick = now
|
||||
|
||||
except Exception as e:
|
||||
print(f"传感器 {sensor_id} 处理错误: {e}")
|
||||
time.sleep(0.1)
|
||||
|
||||
# 清理资源
|
||||
sensor.disconnect()
|
||||
if timer_handle is not None:
|
||||
_disable_high_res_sleep(timer_handle)
|
||||
print(f"传感器 {sensor_id} 已停止")
|
||||
|
||||
except Exception as e:
|
||||
if timer_handle is not None:
|
||||
_disable_high_res_sleep(timer_handle)
|
||||
print(f"传感器 {sensor_id} 初始化失败: {e}")
|
||||
result_queue.put({'sensor_id': sensor_id, 'error': str(e)})
|
||||
|
||||
|
||||
def main():
|
||||
# =========================================================================
|
||||
# 1. 配置多个传感器
|
||||
# =========================================================================
|
||||
num_sensors = 5
|
||||
sensor_configs = [
|
||||
{
|
||||
'sensor_id': 0,
|
||||
'vid_src': './s1_720p.mp4',
|
||||
'config_name': './config/prototype_of3_720p.json',
|
||||
'cal_path': './config/dd01a_0_720p.npy'
|
||||
},
|
||||
{
|
||||
'sensor_id': 1,
|
||||
'vid_src': './s1_720p.mp4',
|
||||
'config_name': './config/prototype_of3_720p.json',
|
||||
'cal_path': './config/dd01a_0_720p.npy'
|
||||
},
|
||||
{
|
||||
'sensor_id': 2,
|
||||
'vid_src': './s1_720p.mp4',
|
||||
'config_name': './config/prototype_of3_720p.json',
|
||||
'cal_path': './config/dd01a_0_720p.npy'
|
||||
},
|
||||
{
|
||||
'sensor_id': 3,
|
||||
'vid_src': './s1_720p.mp4',
|
||||
'config_name': './config/prototype_of3_720p.json',
|
||||
'cal_path': './config/dd01a_0_720p.npy'
|
||||
},
|
||||
{
|
||||
'sensor_id': 4,
|
||||
'vid_src': './s1_720p.mp4',
|
||||
'config_name': './config/prototype_of3_720p.json',
|
||||
'cal_path': './config/dd01a_0_720p.npy'
|
||||
},
|
||||
]
|
||||
|
||||
# =========================================================================
|
||||
# 2. 创建进程间通信对象
|
||||
# =========================================================================
|
||||
# Keep queue short to prevent backlog-induced jitter (latest-data policy)
|
||||
result_queues = [Queue(maxsize=2) for _ in sensor_configs]
|
||||
stop_events = [mp.Event() for _ in sensor_configs] # 停止事件
|
||||
|
||||
# =========================================================================
|
||||
# 3. 启动传感器工作进程
|
||||
# =========================================================================
|
||||
processes = []
|
||||
for i, config in enumerate(sensor_configs):
|
||||
p = Process(
|
||||
target=sensor_worker,
|
||||
args=(i, config['vid_src'], config['config_name'], config['cal_path'], result_queues[i], stop_events[i])
|
||||
)
|
||||
p.start()
|
||||
processes.append(p)
|
||||
|
||||
print(f"\n已启动 {len(processes)} 个传感器进程")
|
||||
print("按 'q' 退出程序")
|
||||
|
||||
# 用于存储每个传感器的最新数据
|
||||
latest_data = [None] * len(sensor_configs)
|
||||
|
||||
# 用于控制打印频率,减少I/O开销
|
||||
print_counter = 0
|
||||
print_interval = 30 # 每30帧打印一次,进一步减少I/O
|
||||
|
||||
# 用于控制按键检查频率
|
||||
key_check_counter = 0
|
||||
key_check_interval = 5 # 每5帧检查一次按键
|
||||
|
||||
try:
|
||||
# =====================================================================
|
||||
# 4. 主循环:从队列获取数据并显示
|
||||
# =====================================================================
|
||||
while True:
|
||||
# 从所有队列中快速获取最新数据(高效清空队列,只保留最新帧)
|
||||
for i, queue in enumerate(result_queues):
|
||||
# 快速清空队列,只保留最后一帧
|
||||
data = None
|
||||
# 一次性获取所有可用数据,只保留最后一个
|
||||
while True:
|
||||
try:
|
||||
data = queue.get_nowait()
|
||||
except:
|
||||
break
|
||||
|
||||
if data is not None:
|
||||
latest_data[i] = data
|
||||
|
||||
# 显示所有传感器的数据
|
||||
for data in latest_data:
|
||||
if data is None or 'error' in data:
|
||||
continue
|
||||
|
||||
sid = data['sensor_id']
|
||||
|
||||
# 显示形变场
|
||||
if data.get('img') is not None and data.get('flow') is not None:
|
||||
arrows = orisys.util.draw_arrows(
|
||||
data['img'],
|
||||
data['flow'],
|
||||
threshold=5,
|
||||
grid_spacing=10,
|
||||
arrow_scale=1.0
|
||||
)
|
||||
cv2.imshow(f"Deformation {sid}", arrows)
|
||||
|
||||
# # 显示触碰点和区域
|
||||
# if data['img'] is not None:
|
||||
# image_with_foe = orisys.util.draw_contact(
|
||||
# data['img'],
|
||||
# data['contour'],
|
||||
# data['centroid']
|
||||
# )
|
||||
# cv2.imshow(f"Contact Centroid {sid}", image_with_foe)
|
||||
|
||||
# # 显示深度图
|
||||
# if data['depth_map'] is not None and data['depth_map'].size > 0:
|
||||
# div_abs = data['depth_map']
|
||||
# if div_abs.max() > div_abs.min():
|
||||
# div_normalized = ((div_abs - div_abs.min()) /
|
||||
# (div_abs.max() - div_abs.min()) * 255).astype(np.uint8)
|
||||
# else:
|
||||
# div_normalized = np.zeros_like(div_abs, dtype=np.uint8)
|
||||
# cv2.imshow(f"Depth Map {sid}",
|
||||
# cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
|
||||
|
||||
# 降低打印频率以减少I/O开销(在主循环级别控制)
|
||||
print_counter += 1
|
||||
if print_counter >= print_interval:
|
||||
for data in latest_data:
|
||||
if data is not None and 'error' not in data:
|
||||
sid = data['sensor_id']
|
||||
print(f"传感器 {sid}: FPS={data['fps']:5.1f}")
|
||||
print_counter = 0
|
||||
|
||||
# 降低按键检查频率以减少开销
|
||||
key_check_counter += 1
|
||||
if key_check_counter >= key_check_interval:
|
||||
key = cv2.waitKey(1) & 0xFF
|
||||
if key == ord('q'):
|
||||
print("\n退出程序...")
|
||||
break
|
||||
key_check_counter = 0
|
||||
|
||||
finally:
|
||||
# =====================================================================
|
||||
# 5. 清理资源
|
||||
# =====================================================================
|
||||
# 通知所有工作进程停止
|
||||
for event in stop_events:
|
||||
event.set()
|
||||
|
||||
# 等待所有进程结束
|
||||
for p in processes:
|
||||
p.join(timeout=2)
|
||||
if p.is_alive():
|
||||
p.terminate()
|
||||
p.join()
|
||||
|
||||
cv2.destroyAllWindows()
|
||||
print("所有传感器进程已停止")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Windows 下多进程必需的初始化
|
||||
mp.freeze_support()
|
||||
main()
|
||||
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 344 KiB |
|
Before Width: | Height: | Size: 912 KiB |
@@ -1,224 +0,0 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
import matplotlib.pyplot as plt
|
||||
def calculate_local_contrast(image_input, window_size=5):
|
||||
"""
|
||||
Calculate Global Average Local Contrast (for fine texture detail evaluation in optical flow).
|
||||
Args:
|
||||
image_input: str (image file path) or numpy array (grayscale/RGB image).
|
||||
window_size: int (odd number, default=5) — sliding window size for fine texture analysis.
|
||||
Returns:
|
||||
float: Global average local contrast (higher = better fine texture detail; target ≥0.2 for optical flow).
|
||||
Raises:
|
||||
ValueError: If image is invalid, window size is even/too small, or processing fails.
|
||||
"""
|
||||
# ---------------------- Step 1: Input Validation & Preprocessing ----------------------
|
||||
# Validate window size (must be odd, ≥3 to capture meaningful texture)
|
||||
if window_size % 2 == 0 or window_size < 3:
|
||||
raise ValueError(f"Window size must be an odd integer ≥3 (got {window_size}). Use 3/5/7.")
|
||||
|
||||
# Load/convert image to grayscale (consistent with optical flow workflow)
|
||||
if isinstance(image_input, str):
|
||||
img = cv2.imread(image_input)
|
||||
if img is None:
|
||||
raise ValueError(f"Failed to load image from path: {image_input}")
|
||||
elif isinstance(image_input, np.ndarray):
|
||||
img = image_input.copy()
|
||||
else:
|
||||
raise ValueError("Input must be an image file path (str) or numpy array (image).")
|
||||
|
||||
# Convert to grayscale (handle RGB/BGR)
|
||||
if len(img.shape) == 3:
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||
else:
|
||||
gray = img # Already grayscale
|
||||
|
||||
# Normalize to 0-255 (avoid non-standard pixel ranges, e.g., float32)
|
||||
gray = cv2.normalize(gray, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
|
||||
|
||||
# ---------------------- Step 2: Sliding Window Local Contrast Calculation ----------------------
|
||||
# Define padding (replicate edge pixels to avoid boundary distortion)
|
||||
pad_size = window_size // 2
|
||||
gray_padded = cv2.copyMakeBorder(
|
||||
gray,
|
||||
top=pad_size, bottom=pad_size, left=pad_size, right=pad_size,
|
||||
borderType=cv2.BORDER_REPLICATE
|
||||
)
|
||||
|
||||
# Initialize array to store local contrast for each window
|
||||
local_contrast_vals = []
|
||||
|
||||
# Iterate over every pixel (center of the sliding window)
|
||||
for y in range(pad_size, gray_padded.shape[0] - pad_size):
|
||||
for x in range(pad_size, gray_padded.shape[1] - pad_size):
|
||||
# Extract 5x5 (or custom) window around (x,y)
|
||||
window = gray_padded[y-pad_size : y+pad_size+1, x-pad_size : x+pad_size+1]
|
||||
|
||||
# Compute Michelson contrast for the window (avoid division by zero)
|
||||
window_max = np.max(window)
|
||||
window_min = np.min(window)
|
||||
if window_max + window_min == 0: # All pixels are 0 (black window)
|
||||
lc = 0.0
|
||||
else:
|
||||
lc = (window_max - window_min) / (window_max + window_min)
|
||||
|
||||
local_contrast_vals.append(lc)
|
||||
|
||||
# ---------------------- Step 3: Global Average Local Contrast ----------------------
|
||||
global_local_contrast = np.mean(local_contrast_vals) if local_contrast_vals else 0.0
|
||||
|
||||
# Round for readability (4 decimal places)
|
||||
return global_local_contrast
|
||||
|
||||
def calculate_edge_strength_density(image_input):
|
||||
"""
|
||||
Calculate Edge Strength and Edge Density for optical flow evaluation.
|
||||
Args:
|
||||
image_input: Can be (1) str (image file path) or (2) numpy array (grayscale/RGB image).
|
||||
Returns:
|
||||
dict: Contains two metrics:
|
||||
- edge_strength: Average gradient magnitude of valid edges (higher = sharper edges).
|
||||
- edge_density: Percentage of valid edge pixels (higher = more texture/edges).
|
||||
Raises:
|
||||
ValueError: If image cannot be loaded/processed.
|
||||
"""
|
||||
# ---------------------- Step 1: Load/Preprocess Image ----------------------
|
||||
|
||||
img = image_input
|
||||
# Convert to grayscale (handle RGB/BGR)
|
||||
if len(img.shape) == 3:
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||
else:
|
||||
gray = img # Already grayscale
|
||||
|
||||
# Normalize to 0-255 (avoid float/non-standard pixel ranges)
|
||||
gray = cv2.normalize(gray, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
|
||||
|
||||
# ---------------------- Step 2: Compute Gradient Magnitude (Sobel) ----------------------
|
||||
# Sobel kernel size = 3 (balance between noise reduction and edge sharpness for optical flow)
|
||||
sobel_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3) # Horizontal gradient
|
||||
sobel_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3) # Vertical gradient
|
||||
|
||||
# Compute gradient magnitude (edge strength raw values)
|
||||
gradient_magnitude = np.sqrt(sobel_x**2 + sobel_y**2)
|
||||
|
||||
# ---------------------- Step 3: Adaptive Threshold for Valid Edges ----------------------
|
||||
# Use median gradient to filter noise (robust to outliers)
|
||||
median_gradient = np.median(gradient_magnitude)
|
||||
# Threshold = 1.5× median (filters weak noise, keeps meaningful edges for optical flow)
|
||||
edge_threshold = 1.5 * median_gradient
|
||||
# Create binary mask for valid edges (1 = valid edge, 0 = non-edge)
|
||||
valid_edge_mask = (gradient_magnitude > edge_threshold).astype(np.uint8)
|
||||
|
||||
# ---------------------- Step 4: Calculate Metrics ----------------------
|
||||
# Edge Strength: Average gradient magnitude of valid edges
|
||||
valid_gradient_values = gradient_magnitude[valid_edge_mask == 1]
|
||||
edge_strength = np.mean(valid_gradient_values) if len(valid_gradient_values) > 0 else 0.0
|
||||
|
||||
# Edge Density: Percentage of valid edge pixels (total pixels = height × width)
|
||||
total_pixels = gray.shape[0] * gray.shape[1]
|
||||
valid_edge_pixels = np.sum(valid_edge_mask)
|
||||
edge_density = (valid_edge_pixels / total_pixels) * 100 if total_pixels > 0 else 0.0
|
||||
# print(f"edge_strength: {edge_strength}, edge_density: {edge_density}")
|
||||
|
||||
# Round for readability (3 decimal places)
|
||||
return edge_strength, edge_density
|
||||
|
||||
def custom_gray_conversion(img, r_weight=0.299, g_weight=0.587, b_weight=0.114,
|
||||
red_boost=1.2, green_reduce=0.8):
|
||||
"""
|
||||
Custom grayscale calculation with tunable RGB weights + red/green contrast enhancement
|
||||
Args:
|
||||
image_path: Path to input image
|
||||
r_weight: Weight for Red channel (default: 0.299)
|
||||
g_weight: Weight for Green channel (default: 0.587)
|
||||
b_weight: Weight for Blue channel (default: 0.114)
|
||||
red_boost: Multiplier to boost red region grayscale (>1 = brighter)
|
||||
green_reduce: Multiplier to reduce green region grayscale (<1 = darker)
|
||||
Returns:
|
||||
default_gray: Grayscale with standard weights
|
||||
custom_gray: Grayscale with tunable weights + red/green enhancement
|
||||
"""
|
||||
# 1. Read image and validate
|
||||
# img_bgr = cv2.imread(image_path)
|
||||
img_bgr = img
|
||||
|
||||
if img_bgr is None:
|
||||
raise ValueError("Failed to read image - check path!")
|
||||
|
||||
# 2. Convert BGR (OpenCV default) to RGB and split channels
|
||||
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
||||
# Convert to float32 to avoid integer overflow/precision loss
|
||||
r = img_rgb[:, :, 0].astype(np.float32)
|
||||
g = img_rgb[:, :, 1].astype(np.float32)
|
||||
b = img_rgb[:, :, 2].astype(np.float32)
|
||||
|
||||
# 3. Calculate DEFAULT grayscale (standard weights)
|
||||
default_gray = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
|
||||
# 4. Calculate CUSTOM grayscale (tunable weights)
|
||||
custom_gray_base = r_weight * r + g_weight * g + b_weight * b
|
||||
|
||||
# 5. Enhance red/green contrast (optional but effective)
|
||||
# Define red/green regions using RGB value ratios (no HSV needed)
|
||||
# red_mask = (r > g * 1.2) & (r > b * 1.2) & (r >= 40) # Red-dominant pixels
|
||||
# green_mask = (g > r * 1.2) & (g > b * 1.2) & (g >= 40) # Green-dominant pixels
|
||||
|
||||
# Apply targeted adjustments to red/green regions
|
||||
custom_gray = custom_gray_base.copy()
|
||||
# custom_gray[red_mask] *= red_boost # Boost red region grayscale
|
||||
# custom_gray[green_mask] *= green_reduce # Reduce green region grayscale
|
||||
|
||||
# 6. Normalize to 0-255 range and convert back to uint8 (OpenCV standard)
|
||||
# Clip values first to avoid overflow, then scale if needed
|
||||
default_gray = np.clip(default_gray, 0, 255).astype(np.uint8)
|
||||
custom_gray = np.clip(custom_gray, 0, 255).astype(np.uint8)
|
||||
|
||||
return default_gray, custom_gray
|
||||
|
||||
# -------------------------- Test the Code --------------------------
|
||||
if __name__ == "__main__":
|
||||
# Replace with your image path
|
||||
IMAGE_PATH = "./raw_1.jpg"
|
||||
img = cv2.imread(IMAGE_PATH)
|
||||
dx = 50
|
||||
dy = 0
|
||||
tile = img[410+dy:650+dy,940+dx:1180+dx,:]
|
||||
|
||||
# Example 1: Use standard weights (verify correctness)
|
||||
default_gray, red_gray = custom_gray_conversion(
|
||||
tile,
|
||||
r_weight=1, g_weight=0, b_weight=0
|
||||
)
|
||||
|
||||
default_gray, green_gray = custom_gray_conversion(
|
||||
tile,
|
||||
r_weight=0, g_weight=1, b_weight=0
|
||||
)
|
||||
default_gray, blue_gray = custom_gray_conversion(
|
||||
tile,
|
||||
r_weight=0, g_weight=0, b_weight=1
|
||||
)
|
||||
|
||||
edge_strength, edge_density = calculate_edge_strength_density(default_gray)
|
||||
local_contrast = calculate_local_contrast(default_gray)
|
||||
print(f"标准灰度: 边缘强度: {edge_strength}, 边缘密度: {edge_density}, 局部对比度: {local_contrast}")
|
||||
edge_strength, edge_density = calculate_edge_strength_density(red_gray)
|
||||
local_contrast = calculate_local_contrast(red_gray)
|
||||
print(f"红色灰度: 边缘强度: {edge_strength}, 边缘密度: {edge_density}, 局部对比度: {local_contrast}")
|
||||
edge_strength, edge_density = calculate_edge_strength_density(green_gray)
|
||||
local_contrast = calculate_local_contrast(green_gray)
|
||||
print(f"绿色灰度: 边缘强度: {edge_strength}, 边缘密度: {edge_density}, 局部对比度: {local_contrast}")
|
||||
edge_strength, edge_density = calculate_edge_strength_density(blue_gray)
|
||||
local_contrast = calculate_local_contrast(blue_gray)
|
||||
print(f"蓝色灰度: 边缘强度: {edge_strength}, 边缘密度: {edge_density}, 局部对比度: {local_contrast}")
|
||||
|
||||
plt.subplot(2, 2, 1)
|
||||
plt.imshow(default_gray, cmap='gray')
|
||||
plt.subplot(2, 2, 2)
|
||||
plt.imshow(red_gray, cmap='gray')
|
||||
plt.subplot(2, 2, 3)
|
||||
plt.imshow(green_gray, cmap='gray')
|
||||
plt.subplot(2, 2, 4)
|
||||
plt.imshow(blue_gray, cmap='gray')
|
||||
plt.show()
|
||||
|
Before Width: | Height: | Size: 736 KiB |
@@ -0,0 +1,228 @@
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from importlib.machinery import EXTENSION_SUFFIXES
|
||||
|
||||
INCLUDE_LAYOUT = {
|
||||
"configs": {
|
||||
"source_dir": Path("config"),
|
||||
"target_subdir": Path("configs"),
|
||||
"patterns": ["*.json"],
|
||||
},
|
||||
"examples": {
|
||||
"source_dir": Path("examples"),
|
||||
"target_subdir": Path("examples"),
|
||||
"files": [
|
||||
Path("examples/basic_pipeline.py"),
|
||||
Path("examples/two_sensor_pipeline.py"),
|
||||
Path("examples/plot_data.py"),
|
||||
],
|
||||
},
|
||||
"docs": {
|
||||
"source_dir": Path("docs"),
|
||||
"target_subdir": Path("docs"),
|
||||
"patterns": ["*.md", "*.pdf"],
|
||||
},
|
||||
}
|
||||
|
||||
def install_nuitka():
|
||||
print("Checking Nuitka installation...")
|
||||
try:
|
||||
import nuitka # noqa: F401
|
||||
print("Nuitka is installed.")
|
||||
except ImportError:
|
||||
print("Installing Nuitka...")
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", "nuitka", "ordered-set", "zstandard"]
|
||||
)
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
init_file = Path("src/orisys/__init__.py")
|
||||
if not init_file.exists():
|
||||
return "0.3.1"
|
||||
for line in init_file.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("__version__"):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return "0.3.1"
|
||||
|
||||
|
||||
def find_compiled_extension(output_dir: Path, module_name: str) -> Path:
|
||||
candidates = []
|
||||
for suffix in EXTENSION_SUFFIXES:
|
||||
candidates.extend(output_dir.glob(f"{module_name}*{suffix}"))
|
||||
if not candidates:
|
||||
all_files = ", ".join(p.name for p in output_dir.iterdir()) if output_dir.exists() else "(none)"
|
||||
raise RuntimeError(
|
||||
f"Nuitka did not produce a compiled extension for {module_name}. Found: {all_files}"
|
||||
)
|
||||
return sorted(candidates, key=lambda p: len(p.name))[0]
|
||||
|
||||
|
||||
def move_wheel_to_sdk(wheel_path: Path, version: str) -> Path:
|
||||
sdk_version_dir = Path("SDK") / version
|
||||
sdk_version_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest_wheel = sdk_version_dir / wheel_path.name
|
||||
if dest_wheel.exists():
|
||||
dest_wheel.unlink()
|
||||
shutil.move(str(wheel_path), str(dest_wheel))
|
||||
return dest_wheel
|
||||
|
||||
|
||||
def compile_orisys(build_root: Path, env: dict[str, str]) -> Path:
|
||||
module_output_dir = build_root / "orisys"
|
||||
if module_output_dir.exists():
|
||||
shutil.rmtree(module_output_dir)
|
||||
module_output_dir.mkdir(parents=True)
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"nuitka",
|
||||
"--module",
|
||||
"--include-package=orisys",
|
||||
"--output-dir=" + str(module_output_dir),
|
||||
"--remove-output",
|
||||
"--no-pyi-file",
|
||||
"src/orisys",
|
||||
]
|
||||
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
subprocess.check_call(cmd, env=env)
|
||||
|
||||
ext_file = find_compiled_extension(module_output_dir, "orisys")
|
||||
print(f"Generated compiled extension for orisys: {ext_file}")
|
||||
return ext_file
|
||||
|
||||
|
||||
def copy_compiled_orisys(pkg_dir: Path, ext_file: Path) -> str:
|
||||
ext_suffix = "".join(ext_file.suffixes)
|
||||
dest_ext = pkg_dir / f"__init__{ext_suffix}"
|
||||
shutil.copy(ext_file, dest_ext)
|
||||
print(f"Copied compiled module orisys to {dest_ext}")
|
||||
return ext_suffix
|
||||
|
||||
|
||||
def copy_included_files(pkg_dir: Path) -> dict[str, int]:
|
||||
copied_counts = {}
|
||||
for group_name, group_cfg in INCLUDE_LAYOUT.items():
|
||||
src_dir = group_cfg["source_dir"]
|
||||
dst_dir = pkg_dir / group_cfg["target_subdir"]
|
||||
dst_dir.mkdir(exist_ok=True)
|
||||
|
||||
copied = 0
|
||||
if "files" in group_cfg:
|
||||
for file_path in group_cfg["files"]:
|
||||
if file_path.exists():
|
||||
shutil.copy(file_path, dst_dir)
|
||||
copied += 1
|
||||
print(f"Copied {group_name}: {file_path.name}")
|
||||
else:
|
||||
for pattern in group_cfg.get("patterns", []):
|
||||
for src_file in src_dir.glob(pattern):
|
||||
shutil.copy(src_file, dst_dir)
|
||||
copied += 1
|
||||
print(f"Copied {group_name}: {src_file.name}")
|
||||
|
||||
if group_name == "examples":
|
||||
(dst_dir / "__init__.py").write_text("# Examples for orisys package\n", encoding="utf-8")
|
||||
|
||||
copied_counts[group_name] = copied
|
||||
return copied_counts
|
||||
|
||||
|
||||
def get_package_data_entries(ext_suffix: str) -> dict[str, list[str]]:
|
||||
package_data = {
|
||||
"orisys": [f"*{ext_suffix}"],
|
||||
"orisys.examples": ["*.py"],
|
||||
}
|
||||
for group_name, group_cfg in INCLUDE_LAYOUT.items():
|
||||
if group_name == "examples":
|
||||
continue
|
||||
target_subdir = str(group_cfg["target_subdir"]).replace("\\", "/")
|
||||
for pattern in group_cfg.get("patterns", []):
|
||||
package_data["orisys"].append(f"{target_subdir}/{pattern}")
|
||||
return package_data
|
||||
|
||||
|
||||
def build():
|
||||
install_nuitka()
|
||||
|
||||
version = get_version()
|
||||
output_dir = Path("build_nuitka")
|
||||
dist_dir = Path("dist_nuitka")
|
||||
|
||||
if output_dir.exists():
|
||||
shutil.rmtree(output_dir)
|
||||
if dist_dir.exists():
|
||||
shutil.rmtree(dist_dir)
|
||||
output_dir.mkdir()
|
||||
dist_dir.mkdir()
|
||||
|
||||
print("\n=== Compiling with Nuitka ===")
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = "src" + os.pathsep + env.get("PYTHONPATH", "")
|
||||
ext_file = compile_orisys(output_dir, env)
|
||||
|
||||
print("\n=== Preparing Wheel Structure ===")
|
||||
wheel_root = dist_dir / "wheel_root"
|
||||
pkg_dir = wheel_root / "orisys"
|
||||
pkg_dir.mkdir(parents=True)
|
||||
|
||||
ext_suffix = copy_compiled_orisys(pkg_dir, ext_file)
|
||||
|
||||
copied_counts = copy_included_files(pkg_dir)
|
||||
print(f"Copied {copied_counts['configs']} JSON config files")
|
||||
print(f"Copied {copied_counts['examples']} example files to orisys/examples/")
|
||||
print(f"Copied {copied_counts['docs']} doc files to orisys/docs/")
|
||||
package_data = get_package_data_entries(ext_suffix)
|
||||
|
||||
setup_py_path = dist_dir / "setup.py"
|
||||
setup_content = f"""
|
||||
from setuptools import setup, Distribution
|
||||
|
||||
class BinaryDistribution(Distribution):
|
||||
def has_ext_modules(self):
|
||||
return True
|
||||
|
||||
setup(
|
||||
name="orisys",
|
||||
version="{version}",
|
||||
description="Orisys - Optical Tactile Sensor SDK (Nuitka Compiled)",
|
||||
author="Orisys",
|
||||
packages=['orisys', 'orisys.examples'],
|
||||
package_dir={{'': 'wheel_root'}},
|
||||
package_data={package_data},
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
"numpy>=1.24.4",
|
||||
"opencv-python>=4.10.0.84",
|
||||
"scipy>=1.10.1",
|
||||
],
|
||||
extras_require={{
|
||||
"cuda12": ["cupy-cuda12x>=12.3.0"],
|
||||
"cuda13": ["cupy-cuda13x>=13.0.0"],
|
||||
"cuda": ["cupy-cuda12x>=12.3.0"],
|
||||
}},
|
||||
distclass=BinaryDistribution,
|
||||
zip_safe=False,
|
||||
)
|
||||
"""
|
||||
setup_py_path.write_text(setup_content, encoding="utf-8")
|
||||
|
||||
print("\n=== Building Wheel ===")
|
||||
subprocess.check_call([sys.executable, "setup.py", "bdist_wheel"], cwd=dist_dir)
|
||||
|
||||
wheels = sorted((dist_dir / "dist").glob("*.whl"))
|
||||
if wheels:
|
||||
final_wheel = move_wheel_to_sdk(wheels[0], version)
|
||||
print(f"\n[SUCCESS] Wheel created and moved to: {final_wheel}")
|
||||
else:
|
||||
print("\n[ERROR] Wheel not found.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build()
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
import os
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
# Force pyqtgraph to bind to PyQt5 when multiple Qt bindings are installed.
|
||||
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PyQt5")
|
||||
import cv2
|
||||
import orisys
|
||||
import numpy as np
|
||||
import time
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtCore import Qt, QSize, QEvent
|
||||
from PyQt5.QtGui import QImage, QFont, QPixmap, QKeySequence, QCursor
|
||||
from PyQt5.QtWidgets import QLabel, QHBoxLayout, QVBoxLayout, QWidget, QSizePolicy, QShortcut, QApplication
|
||||
from pyqtgraph.Qt import QtGui
|
||||
|
||||
|
||||
# Final desired appearance on the mirrored 1872x1560 (6:5) display.
|
||||
# The desktop is first shown on a 1920x1080 (16:9) screen, then stretched by
|
||||
# the OS onto the 6:5 mirror. We therefore pre-distort the source layout so
|
||||
# the mirrored output looks correct.
|
||||
TARGET_SINGLE_PANEL_W = 936
|
||||
TARGET_ASPECT_W = TARGET_SINGLE_PANEL_W * 2
|
||||
TARGET_VIDEO_FRAME_H = 1053
|
||||
TARGET_BOTTOM_PANEL_H = 507
|
||||
TARGET_ASPECT_H_TOTAL = TARGET_VIDEO_FRAME_H + TARGET_BOTTOM_PANEL_H
|
||||
TARGET_MIRROR_W = 1872
|
||||
TARGET_MIRROR_H = 1560
|
||||
|
||||
SINGLE_PANEL_W = TARGET_SINGLE_PANEL_W
|
||||
ASPECT_W = TARGET_ASPECT_W
|
||||
BOTTOM_PANEL_H = TARGET_BOTTOM_PANEL_H
|
||||
VIDEO_FRAME_W = TARGET_ASPECT_W
|
||||
VIDEO_FRAME_H = TARGET_VIDEO_FRAME_H
|
||||
ASPECT_H_TOTAL = TARGET_ASPECT_H_TOTAL
|
||||
UI_SCALE = 1.0
|
||||
ARROW_STRETCH = 507
|
||||
PLOT_STRETCH = 429
|
||||
VIDEO_LOOP_PATH = "./叠动机械爪_成片.m4v"
|
||||
npoints = 120 # 图表数据长度
|
||||
dispaly_angle = -90 # 箭头图显示角度
|
||||
touch_threshold = 5 # 触碰反应阈值
|
||||
frame_to_recover = 10 # 触碰后等待回复帧数
|
||||
|
||||
# Opneing Hour for keep reading image
|
||||
open_hour = 9
|
||||
close_hour = 17
|
||||
manual_open_enable = True # 控制是否可以手动切换营业状态 (按键G)
|
||||
is_open = True
|
||||
|
||||
# Match arrow panel / Qt style (not pure black)
|
||||
PLOT_THEME_BG = "#0B1016"
|
||||
PLOT_THEME_FG = "#A0A0A0"
|
||||
|
||||
# OS title bar cannot be tinted to #0B1016; frameless removes it (use Alt+drag to move).
|
||||
USE_FRAMELESS_WINDOW = False
|
||||
|
||||
|
||||
def scale_ui_px(px, floor=1):
|
||||
return max(floor, int(round(px * UI_SCALE)))
|
||||
|
||||
|
||||
def compute_source_layout(screen_w, screen_h):
|
||||
"""Pre-distort the app on the source screen so the mirrored monitor is correct."""
|
||||
target_video_ratio = TARGET_VIDEO_FRAME_H / float(TARGET_ASPECT_H_TOTAL)
|
||||
target_bottom_ratio = TARGET_BOTTOM_PANEL_H / float(TARGET_ASPECT_H_TOTAL)
|
||||
|
||||
return {
|
||||
"window_w": max(320, int(screen_w)),
|
||||
"window_h": max(240, int(screen_h)),
|
||||
"video_w": max(320, int(screen_w)),
|
||||
"video_h": max(1, int(round(screen_h * target_video_ratio))),
|
||||
"bottom_h": max(1, int(round(screen_h * target_bottom_ratio))),
|
||||
"single_panel_w": max(1, int(round(screen_w / 2.0))),
|
||||
}
|
||||
|
||||
|
||||
class CombinedTactileWindow(QWidget):
|
||||
"""
|
||||
Resizable window that keeps width:height = ASPECT_W : ASPECT_H_TOTAL.
|
||||
Dragging the width or height edge updates the other dimension accordingly.
|
||||
"""
|
||||
|
||||
RESIZE_MARGIN = 8
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._aspect_hw = ASPECT_H_TOTAL / float(ASPECT_W)
|
||||
self._resize_guard = False
|
||||
self.user_closed = False
|
||||
self._drag_anchor = None
|
||||
self._frameless_resize_edge = None
|
||||
self._frameless_resize_start_global = None
|
||||
self._frameless_resize_start_geom = None
|
||||
|
||||
def _min_client_size(self):
|
||||
w = max(320, self.minimumSizeHint().width())
|
||||
h = int(round(w * self._aspect_hw))
|
||||
return w, h
|
||||
|
||||
def _hit_test_resize(self, global_pos):
|
||||
if not USE_FRAMELESS_WINDOW:
|
||||
return None
|
||||
p = self.mapFromGlobal(global_pos)
|
||||
w, h = self.width(), self.height()
|
||||
m = self.RESIZE_MARGIN
|
||||
left = p.x() <= m
|
||||
right = p.x() >= w - m
|
||||
top = p.y() <= m
|
||||
bottom = p.y() >= h - m
|
||||
if top and left:
|
||||
return "tl"
|
||||
if top and right:
|
||||
return "tr"
|
||||
if bottom and left:
|
||||
return "bl"
|
||||
if bottom and right:
|
||||
return "br"
|
||||
if top:
|
||||
return "t"
|
||||
if bottom:
|
||||
return "b"
|
||||
if left:
|
||||
return "l"
|
||||
if right:
|
||||
return "r"
|
||||
return None
|
||||
|
||||
def _cursor_for_edge(self, edge):
|
||||
return {
|
||||
"tl": Qt.SizeFDiagCursor,
|
||||
"br": Qt.SizeFDiagCursor,
|
||||
"tr": Qt.SizeBDiagCursor,
|
||||
"bl": Qt.SizeBDiagCursor,
|
||||
"l": Qt.SizeHorCursor,
|
||||
"r": Qt.SizeHorCursor,
|
||||
"t": Qt.SizeVerCursor,
|
||||
"b": Qt.SizeVerCursor,
|
||||
}.get(edge, Qt.ArrowCursor)
|
||||
|
||||
def _apply_frameless_resize(self, dg):
|
||||
edge = self._frameless_resize_edge
|
||||
g0 = self._frameless_resize_start_geom
|
||||
min_w, min_h = self._min_client_size()
|
||||
dx, dy = dg.x(), dg.y()
|
||||
x0, y0, w0, h0 = g0.x(), g0.y(), g0.width(), g0.height()
|
||||
|
||||
if edge == "br":
|
||||
nw = max(min_w, w0 + dx)
|
||||
nh = int(round(nw * self._aspect_hw))
|
||||
self.setGeometry(x0, y0, nw, nh)
|
||||
elif edge == "r":
|
||||
nw = max(min_w, w0 + dx)
|
||||
nh = int(round(nw * self._aspect_hw))
|
||||
self.setGeometry(x0, y0, nw, nh)
|
||||
elif edge == "b":
|
||||
nh = max(min_h, h0 + dy)
|
||||
nw = int(round(nh / self._aspect_hw))
|
||||
self.setGeometry(x0, y0, nw, nh)
|
||||
elif edge == "l":
|
||||
nw = max(min_w, w0 - dx)
|
||||
nh = int(round(nw * self._aspect_hw))
|
||||
nx = g0.right() - nw
|
||||
self.setGeometry(nx, y0, nw, nh)
|
||||
elif edge == "t":
|
||||
nh = max(min_h, h0 - dy)
|
||||
nw = int(round(nh / self._aspect_hw))
|
||||
ny = g0.bottom() - nh
|
||||
self.setGeometry(x0, ny, nw, nh)
|
||||
elif edge == "tr":
|
||||
nw = max(min_w, w0 + dx)
|
||||
nh = int(round(nw * self._aspect_hw))
|
||||
ny = g0.bottom() - nh
|
||||
self.setGeometry(x0, ny, nw, nh)
|
||||
elif edge == "bl":
|
||||
nw = max(min_w, w0 - dx)
|
||||
nh = int(round(nw * self._aspect_hw))
|
||||
nx = g0.right() - nw
|
||||
self.setGeometry(nx, y0, nw, nh)
|
||||
elif edge == "tl":
|
||||
nw = max(min_w, w0 - dx)
|
||||
nh = int(round(nw * self._aspect_hw))
|
||||
nx = g0.right() - nw
|
||||
ny = g0.bottom() - nh
|
||||
self.setGeometry(nx, ny, nw, nh)
|
||||
|
||||
def install_drag_filters(self):
|
||||
"""Alt+left-drag to move when frameless; grabs go to this widget."""
|
||||
for w in self.findChildren(QWidget):
|
||||
if w is not self:
|
||||
w.installEventFilter(self)
|
||||
|
||||
def eventFilter(self, watched, event):
|
||||
if not USE_FRAMELESS_WINDOW:
|
||||
return super().eventFilter(watched, event)
|
||||
et = event.type()
|
||||
if et == QEvent.MouseButtonPress and event.button() == Qt.LeftButton:
|
||||
edge = self._hit_test_resize(event.globalPos())
|
||||
if edge is not None:
|
||||
self._frameless_resize_edge = edge
|
||||
self._frameless_resize_start_global = event.globalPos()
|
||||
self._frameless_resize_start_geom = self.geometry()
|
||||
self.grabMouse()
|
||||
return True
|
||||
if QApplication.keyboardModifiers() & Qt.AltModifier:
|
||||
self._drag_anchor = event.globalPos() - self.frameGeometry().topLeft()
|
||||
self.grabMouse()
|
||||
return True
|
||||
if et == QEvent.MouseMove and not event.buttons():
|
||||
edge = self._hit_test_resize(event.globalPos())
|
||||
watched.setCursor(QCursor(self._cursor_for_edge(edge)))
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if USE_FRAMELESS_WINDOW and self._frameless_resize_edge and event.buttons() & Qt.LeftButton:
|
||||
dg = event.globalPos() - self._frameless_resize_start_global
|
||||
self._apply_frameless_resize(dg)
|
||||
event.accept()
|
||||
return
|
||||
if (
|
||||
USE_FRAMELESS_WINDOW
|
||||
and self._drag_anchor is not None
|
||||
and event.buttons() & Qt.LeftButton
|
||||
):
|
||||
self.move(event.globalPos() - self._drag_anchor)
|
||||
event.accept()
|
||||
return
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if USE_FRAMELESS_WINDOW and self._frameless_resize_edge and event.button() == Qt.LeftButton:
|
||||
self.releaseMouse()
|
||||
self._frameless_resize_edge = None
|
||||
self._frameless_resize_start_global = None
|
||||
self._frameless_resize_start_geom = None
|
||||
event.accept()
|
||||
return
|
||||
if USE_FRAMELESS_WINDOW and self._drag_anchor is not None and event.button() == Qt.LeftButton:
|
||||
self.releaseMouse()
|
||||
self._drag_anchor = None
|
||||
event.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.user_closed = True
|
||||
event.accept()
|
||||
|
||||
def minimumSizeHint(self):
|
||||
w = 320
|
||||
return QSize(w, int(round(w * self._aspect_hw)))
|
||||
|
||||
def resizeEvent(self, event):
|
||||
if self._resize_guard:
|
||||
super().resizeEvent(event)
|
||||
return
|
||||
super().resizeEvent(event)
|
||||
w, h = self.width(), self.height()
|
||||
if w < 8 or h < 8:
|
||||
return
|
||||
old = event.oldSize()
|
||||
self._resize_guard = True
|
||||
try:
|
||||
if old.isValid() and old.width() > 0 and old.height() > 0:
|
||||
dw = abs(w - old.width())
|
||||
dh = abs(h - old.height())
|
||||
if dw >= dh:
|
||||
new_w, new_h = w, int(round(w * self._aspect_hw))
|
||||
else:
|
||||
new_w, new_h = int(round(h / self._aspect_hw)), h
|
||||
else:
|
||||
new_w, new_h = w, int(round(w * self._aspect_hw))
|
||||
if new_w != w or new_h != h:
|
||||
self.resize(new_w, new_h)
|
||||
finally:
|
||||
self._resize_guard = False
|
||||
|
||||
|
||||
def compute_arrow_canvas_side(panel_widget):
|
||||
"""
|
||||
Pixel size of the square arrow canvas from the window client area and the
|
||||
fixed horizontal split ARROW_STRETCH : PLOT_STRETCH (matches QHBoxLayout stretch).
|
||||
"""
|
||||
w = panel_widget.width()
|
||||
h = panel_widget.height()
|
||||
if w < 16 or h < 16:
|
||||
return max(32, ASPECT_H_TOTAL)
|
||||
w_arrow = int(round(w * ARROW_STRETCH / float(ARROW_STRETCH + PLOT_STRETCH)))
|
||||
# Keep the rendered arrow image square inside the left panel.
|
||||
return max(32, min(h, w_arrow))
|
||||
|
||||
|
||||
def draw_arrows_wuxi(
|
||||
frame,
|
||||
optical_flow,
|
||||
fps=True,
|
||||
threshold=5,
|
||||
grid_spacing=40,
|
||||
arrow_scale=1.0,
|
||||
margin=0.8,
|
||||
colormap=cv2.COLORMAP_PARULA,
|
||||
below_threshold_color=(0, 0, 255),
|
||||
edge_margin=15,
|
||||
target_size=936,
|
||||
):
|
||||
"""
|
||||
Draw colored optical flow arrows on the image by magnitude (JET colormap).
|
||||
Resizes frame and flow to target_size x target_size; scales flow vectors and
|
||||
grid_spacing / margins / threshold so arrow density matches the original resolution.
|
||||
"""
|
||||
image = frame.copy()
|
||||
if len(image.shape) == 2 or image.shape[2] == 1:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
||||
|
||||
h0, w0 = image.shape[:2]
|
||||
if h0 != target_size or w0 != target_size:
|
||||
sx = target_size / float(w0)
|
||||
sy = target_size / float(h0)
|
||||
image = cv2.resize(image, (target_size, target_size), interpolation=cv2.INTER_LINEAR)
|
||||
u = cv2.resize(optical_flow[:, :, 0], (target_size, target_size), interpolation=cv2.INTER_LINEAR) * sx
|
||||
v = cv2.resize(optical_flow[:, :, 1], (target_size, target_size), interpolation=cv2.INTER_LINEAR) * sy
|
||||
optical_flow = np.stack([u, v], axis=2)
|
||||
scale = max(sx, sy)
|
||||
grid_spacing = max(4, int(round(grid_spacing * scale)))
|
||||
edge_margin = max(1, int(round(edge_margin * scale)))
|
||||
threshold = float(threshold) * scale
|
||||
|
||||
h, w = image.shape[:2]
|
||||
# Arrow grid in pixel coordinates (assume frame and flow same size)
|
||||
rr = np.arange(grid_spacing // 2, h, grid_spacing)
|
||||
cc = np.arange(grid_spacing // 2, w, grid_spacing)
|
||||
rr, cc = np.meshgrid(rr, cc, indexing="ij")
|
||||
rr, cc = rr.reshape(-1), cc.reshape(-1)
|
||||
|
||||
# Displacement at each grid point (flow stored as half-displacement)
|
||||
disp = 2.0 * optical_flow[rr, cc, :]
|
||||
mag = np.linalg.norm(disp, axis=1)
|
||||
dx = disp[:, 0] / arrow_scale
|
||||
dy = disp[:, 1] / arrow_scale
|
||||
length = np.sqrt(dx * dx + dy * dy)
|
||||
# Cap arrow length at grid_spacing
|
||||
scale = np.where(length > grid_spacing*margin, grid_spacing / np.maximum(length, 1e-9), 1.0)*margin
|
||||
dx, dy = dx * scale, dy * scale
|
||||
end_x = (cc + dx).astype(np.int32)
|
||||
end_y = (rr + dy).astype(np.int32)
|
||||
|
||||
valid = (
|
||||
(cc >= edge_margin) & (cc < w - edge_margin) & (rr >= edge_margin) & (rr < h - edge_margin)
|
||||
& (end_x >= 0) & (end_x < w) & (end_y >= 0) & (end_y < h)
|
||||
)
|
||||
mag_range = mag.max() - mag.min()
|
||||
norm_mag = ((mag - mag.min()) / mag_range * 255).astype(np.uint8) if mag_range > 0 else np.zeros_like(mag, dtype=np.uint8)
|
||||
norm_mag[mag < threshold] = 0
|
||||
colors = cv2.applyColorMap(norm_mag.reshape(-1, 1), colormap)
|
||||
|
||||
for i in np.where(valid)[0]:
|
||||
pt1, pt2 = (int(cc[i]), int(rr[i])), (int(end_x[i]), int(end_y[i]))
|
||||
color = tuple(int(c) for c in colors[i, 0])
|
||||
if mag[i] < threshold:
|
||||
cv2.circle(image, pt1, radius=1, color=below_threshold_color, thickness=-1)
|
||||
else:
|
||||
# thickness = max(1, int(2 * mag[i] / mag.max())) if mag.max() > 0 else 2
|
||||
thickness = 1
|
||||
cv2.arrowedLine(image, pt1, pt2, color, thickness=thickness, tipLength=0.2)
|
||||
return image
|
||||
|
||||
|
||||
def set_arrow_label_bgr(label, bgr_image):
|
||||
"""Show a BGR OpenCV image in a QLabel using source-screen pre-distortion."""
|
||||
rgb = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2RGB)
|
||||
rgb = np.ascontiguousarray(rgb)
|
||||
h, w, ch = rgb.shape
|
||||
qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy()
|
||||
pm = QPixmap.fromImage(qimg)
|
||||
lw, lh = label.width(), label.height()
|
||||
if lw > 0 and lh > 0 and (pm.width() != lw or pm.height() != lh):
|
||||
pm = pm.scaled(lw, lh, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
|
||||
label.setPixmap(pm)
|
||||
|
||||
def set_video_label_bgr(label, bgr_image):
|
||||
"""Show loop video in the top frame using the fitted display size."""
|
||||
rgb = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2RGB)
|
||||
rgb = np.ascontiguousarray(rgb)
|
||||
h, w, ch = rgb.shape
|
||||
qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy()
|
||||
pm = QPixmap.fromImage(qimg)
|
||||
lw, lh = label.width(), label.height()
|
||||
if lw > 0 and lh > 0 and (pm.width() != lw or pm.height() != lh):
|
||||
pm = pm.scaled(lw, lh, Qt.IgnoreAspectRatio, Qt.FastTransformation)
|
||||
label.setPixmap(pm)
|
||||
|
||||
|
||||
def read_loop_video_frame_realtime(video_cap, video_clock):
|
||||
"""Read loop-video frame paced to wall clock; skip frames when main loop is slow."""
|
||||
if video_cap is None or not video_cap.isOpened():
|
||||
return False, None
|
||||
|
||||
now = time.perf_counter()
|
||||
fps = max(1.0, float(video_clock.get("fps", 30.0)))
|
||||
frame_dt = 1.0 / fps
|
||||
next_ts = video_clock.get("next_ts")
|
||||
if next_ts is None:
|
||||
next_ts = now
|
||||
|
||||
frames_due = 1
|
||||
if now > next_ts + frame_dt:
|
||||
frames_due += int((now - next_ts) / frame_dt)
|
||||
# Cap skip burst to avoid long catch-up loops after pauses.
|
||||
frames_due = min(frames_due, int(max(2.0, fps)))
|
||||
|
||||
for _ in range(frames_due - 1):
|
||||
if not video_cap.grab():
|
||||
video_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
if not video_cap.grab():
|
||||
return False, None
|
||||
|
||||
vret, vframe = video_cap.read()
|
||||
if not vret:
|
||||
video_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
vret, vframe = video_cap.read()
|
||||
if not vret:
|
||||
return False, None
|
||||
|
||||
next_ts = next_ts + frames_due * frame_dt
|
||||
if next_ts < now - 2.0 * frame_dt:
|
||||
next_ts = now
|
||||
video_clock["next_ts"] = next_ts
|
||||
return True, vframe
|
||||
|
||||
|
||||
def make_gradient_brush(color_hex, max_alpha=150):
|
||||
"""
|
||||
创建一个从上到下渐变的笔刷 (兼容 PyQt5 和 PyQt6)
|
||||
"""
|
||||
c_top = QtGui.QColor(color_hex)
|
||||
c_top.setAlpha(max_alpha) # 顶部半透明
|
||||
|
||||
c_bot = QtGui.QColor(color_hex)
|
||||
c_bot.setAlpha(0) # 底部全透明
|
||||
|
||||
# 创建线性渐变:(0,0)到(0,1) 代表从控件顶部到底部
|
||||
grad = QtGui.QLinearGradient(0, 0, 0, 1)
|
||||
|
||||
if hasattr(QtGui.QGradient, 'CoordinateMode'):
|
||||
grad.setCoordinateMode(QtGui.QGradient.CoordinateMode.ObjectBoundingMode)
|
||||
else:
|
||||
grad.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)
|
||||
# ==============================================
|
||||
|
||||
grad.setColorAt(0, c_top) # 0.0 (顶端) 颜色
|
||||
grad.setColorAt(1, c_bot) # 1.0 (底端) 颜色
|
||||
|
||||
return QtGui.QBrush(grad)
|
||||
|
||||
def create_plot(plot_container):
|
||||
pg.setConfigOption("background", PLOT_THEME_BG)
|
||||
pg.setConfigOption("foreground", PLOT_THEME_FG)
|
||||
pg.setConfigOptions(antialias=True) # 抗锯齿
|
||||
theme_color = '#00E5FF' # '#00E5FF'
|
||||
line_pen = pg.mkPen(color=theme_color, width=2)
|
||||
grad_brush = make_gradient_brush(theme_color, max_alpha=120)
|
||||
|
||||
axis_font = QFont("Segoe UI", scale_ui_px(11, floor=8))
|
||||
p1 = plot_container.addPlot()
|
||||
p1.setLabel('left', 'N')
|
||||
p1.getAxis('left').label.setFont(axis_font)
|
||||
p1.getAxis('left').label.setRotation(dispaly_angle)
|
||||
p1.showGrid(x=True, y=True, alpha=0.3)
|
||||
plot_container.nextRow()
|
||||
p2 = plot_container.addPlot()
|
||||
p2.setLabel('left', 'X')
|
||||
p2.getAxis('left').label.setFont(axis_font)
|
||||
p2.getAxis('left').label.setRotation(dispaly_angle)
|
||||
p2.showGrid(x=True, y=True, alpha=0.3)
|
||||
plot_container.nextRow()
|
||||
p3 = plot_container.addPlot()
|
||||
p3.setLabel('left', 'Y')
|
||||
|
||||
# p3.setLabel('bottom', 'Time', units='s')
|
||||
p3.getAxis('left').label.setFont(axis_font)
|
||||
p3.getAxis('left').label.setRotation(dispaly_angle)
|
||||
p3.getAxis('bottom').label.setFont(axis_font)
|
||||
p3.showGrid(x=True, y=True, alpha=0.3)
|
||||
_plot_bg = pg.mkColor(PLOT_THEME_BG)
|
||||
for p in (p1, p2, p3):
|
||||
p.getAxis("bottom").setStyle(showValues=False)
|
||||
p.getAxis("left").setStyle(showValues=False)
|
||||
# Keep title as "N" / "X" / "Y" — no PyQtGraph "(x0.001)" suffix on small ranges.
|
||||
p.getAxis("left").enableAutoSIPrefix(False)
|
||||
# PlotItem.setBackground exists only in newer pyqtgraph; ViewBox works on older installs.
|
||||
p.vb.setBackgroundColor(_plot_bg)
|
||||
plot_af = p1.plot(pen=line_pen, fillLevel=0, fillBrush=grad_brush)
|
||||
plot_tfx = p2.plot(pen=line_pen, fillLevel=0, fillBrush=grad_brush)
|
||||
plot_tfy = p3.plot(pen=line_pen, fillLevel=0, fillBrush=grad_brush)
|
||||
return p1, p2, p3, plot_af, plot_tfx, plot_tfy
|
||||
|
||||
def _plot_y_range(y):
|
||||
"""Avoid degenerate Y span; explicit x/y in setData so older PyQtGraph never plots time as y."""
|
||||
mn = float(np.min(y))
|
||||
mx = float(np.max(y))
|
||||
if not (np.isfinite(mn) and np.isfinite(mx)):
|
||||
return 0.0, 1.0
|
||||
if mx - mn <= 1e-15:
|
||||
mid = 0.5 * (mn + mx)
|
||||
return mid - 0.5, mid + 0.5
|
||||
pad = (mx - mn) * 0.05
|
||||
return mn - pad, mx + pad
|
||||
|
||||
|
||||
def update(plot_af, plot_tfx, plot_tfy, data, p1, p2, p3):
|
||||
# global plot_af, plot_tfx, plot_tfy, data, p1, p2, p3
|
||||
plot_af.setData(data[:, 0, 0], data[:, 1, 0])
|
||||
p1.setYRange(*_plot_y_range(data[:, 1, 0]))
|
||||
plot_tfx.setData(data[:, 0, 1], data[:, 1, 1])
|
||||
p2.setYRange(*_plot_y_range(data[:, 1, 1]))
|
||||
plot_tfy.setData(data[:, 0, 2], data[:, 1, 2])
|
||||
p3.setYRange(*_plot_y_range(data[:, 1, 2]))
|
||||
|
||||
|
||||
def build_sensor_panel(parent_layout):
|
||||
panel = QWidget()
|
||||
panel_layout = QHBoxLayout(panel)
|
||||
panel_layout.setContentsMargins(0, 0, 0, 0)
|
||||
panel_layout.setSpacing(0)
|
||||
|
||||
# Ignored: do not let pixmap/content drive QLabel size — layout stretch sets arrow vs plot widths.
|
||||
_policy_layout = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
|
||||
|
||||
arrow_label = QLabel()
|
||||
arrow_label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
arrow_label.setStyleSheet(
|
||||
"background-color: #0B1016; margin: 0; padding: 0; border: none;"
|
||||
)
|
||||
arrow_label.setSizePolicy(_policy_layout)
|
||||
arrow_label.setMinimumSize(0, 0)
|
||||
arrow_label.setScaledContents(False)
|
||||
panel_layout.addWidget(arrow_label, stretch=ARROW_STRETCH)
|
||||
|
||||
plot_widget = pg.GraphicsLayoutWidget()
|
||||
plot_widget.setBackground(PLOT_THEME_BG)
|
||||
plot_widget.setSizePolicy(_policy_layout)
|
||||
plot_widget.setMinimumSize(0, 0)
|
||||
panel_layout.addWidget(plot_widget, stretch=PLOT_STRETCH)
|
||||
|
||||
disconnected_label = QLabel("已断开")
|
||||
disconnected_label.setAlignment(Qt.AlignCenter)
|
||||
disconnected_label.setStyleSheet(
|
||||
f"background-color: #0B1016; color: #FF4D4D; font-size: {scale_ui_px(54, floor=24)}px; "
|
||||
"font-weight: bold; letter-spacing: 2px; margin: 0; padding: 0; border: none;"
|
||||
)
|
||||
disconnected_label.hide()
|
||||
panel_layout.addWidget(disconnected_label, stretch=SINGLE_PANEL_W)
|
||||
|
||||
parent_layout.addWidget(panel, stretch=1)
|
||||
|
||||
p1, p2, p3, plot_af, plot_tfx, plot_tfy = create_plot(plot_widget)
|
||||
data = np.zeros([npoints, 2, 3])
|
||||
for i in range(3):
|
||||
data[:, 0, i] = np.linspace(0.0, npoints / 30.0, npoints)
|
||||
|
||||
return {
|
||||
"panel": panel,
|
||||
"arrow_label": arrow_label,
|
||||
"plot_widget": plot_widget,
|
||||
"disconnected_label": disconnected_label,
|
||||
"p1": p1,
|
||||
"p2": p2,
|
||||
"p3": p3,
|
||||
"plot_af": plot_af,
|
||||
"plot_tfx": plot_tfx,
|
||||
"plot_tfy": plot_tfy,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
|
||||
# 实时图表:单窗口,两套并排(左 sensor1,右 sensor2)
|
||||
pg.setConfigOption("background", PLOT_THEME_BG)
|
||||
pg.setConfigOption("foreground", PLOT_THEME_FG)
|
||||
app = pg.mkQApp("Wuxi Demo")
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry() if screen is not None else None
|
||||
screen_w = screen_geometry.width() if screen_geometry is not None else 1920
|
||||
screen_h = screen_geometry.height() if screen_geometry is not None else 1080
|
||||
source_layout = compute_source_layout(screen_w, screen_h)
|
||||
ASPECT_W = source_layout["window_w"]
|
||||
ASPECT_H_TOTAL = source_layout["window_h"]
|
||||
VIDEO_FRAME_W = source_layout["video_w"]
|
||||
VIDEO_FRAME_H = source_layout["video_h"]
|
||||
BOTTOM_PANEL_H = source_layout["bottom_h"]
|
||||
SINGLE_PANEL_W = source_layout["single_panel_w"]
|
||||
UI_SCALE = max(0.1, screen_h / float(TARGET_ASPECT_H_TOTAL))
|
||||
|
||||
combined_win = CombinedTactileWindow()
|
||||
combined_win.setWindowTitle("Wuxi tactile — dual flow + forces")
|
||||
main_layout = QVBoxLayout(combined_win)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
|
||||
video_label = QLabel()
|
||||
video_label.setAlignment(Qt.AlignCenter)
|
||||
video_label.setStyleSheet("background-color: #000000; margin: 0; padding: 0; border: none;")
|
||||
video_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
video_label.setMinimumSize(0, 0)
|
||||
main_layout.addWidget(video_label, stretch=VIDEO_FRAME_H)
|
||||
|
||||
bottom_container = QWidget()
|
||||
bottom_layout = QHBoxLayout(bottom_container)
|
||||
bottom_layout.setContentsMargins(0, 0, 0, 0)
|
||||
bottom_layout.setSpacing(0)
|
||||
main_layout.addWidget(bottom_container, stretch=BOTTOM_PANEL_H)
|
||||
|
||||
panel1_ui = build_sensor_panel(bottom_layout)
|
||||
panel2_ui = build_sensor_panel(bottom_layout)
|
||||
|
||||
if USE_FRAMELESS_WINDOW:
|
||||
combined_win.setWindowFlags(Qt.Window | Qt.FramelessWindowHint)
|
||||
# Client area matches theme (no bright border flash on frameless)
|
||||
combined_win.setStyleSheet(f"background-color: {PLOT_THEME_BG};")
|
||||
combined_win.resize(ASPECT_W, ASPECT_H_TOTAL)
|
||||
combined_win.install_drag_filters()
|
||||
combined_win.showFullScreen()
|
||||
closed_hour_overlay = QLabel(combined_win)
|
||||
closed_hour_overlay.setAlignment(Qt.AlignCenter)
|
||||
closed_hour_overlay.setStyleSheet(
|
||||
f"background-color: {PLOT_THEME_BG}; color: #A0A0A0; "
|
||||
f"font-size: {scale_ui_px(72, floor=32)}px; font-weight: bold; "
|
||||
"margin: 0; padding: 0; border: none;"
|
||||
)
|
||||
closed_hour_overlay.setText("Closed hour\n非营业时间")
|
||||
closed_hour_overlay.hide()
|
||||
start_time = None # Will be set when sensor starts
|
||||
fps = 30.0
|
||||
|
||||
|
||||
def outside_opening_hours(now):
|
||||
return now.hour < open_hour or now.hour >= close_hour
|
||||
|
||||
|
||||
|
||||
def main():
|
||||
global start_time
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--video1","-v1", type=str, default="0", help="视频文件路径")
|
||||
parser.add_argument("--video2","-v2", type=str, default="0", help="视频文件路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
session_start = datetime.now()
|
||||
print(
|
||||
f"[session] START {session_start:%Y-%m-%d %H:%M:%S} (long-run / crash log marker)",
|
||||
flush=True,
|
||||
)
|
||||
if USE_FRAMELESS_WINDOW:
|
||||
print(
|
||||
"[window] Frameless: resize = drag window edges/corners; move = Alt+left-drag; quit = Q.",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
sensor1 = None
|
||||
sensor2 = None
|
||||
video_cap = None
|
||||
try:
|
||||
input1 = int(args.video1)
|
||||
input2 = int(args.video2)
|
||||
is_camera = True
|
||||
except:
|
||||
is_camera = False
|
||||
try:
|
||||
if is_camera:
|
||||
sensor1 = orisys.Sensor(
|
||||
input1,
|
||||
config_name="./config/ddjx01_wuxi.json",
|
||||
cal_path="./config/dd01a_0306.npy",
|
||||
isstitch=True,
|
||||
cuda=False,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
sensor2 = orisys.Sensor(
|
||||
input2,
|
||||
config_name="./config/ddjx01_wuxi.json",
|
||||
cal_path="./config/dd01a_0306.npy",
|
||||
isstitch=True,
|
||||
cuda=False,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
else:
|
||||
sensor1 = orisys.Sensor(
|
||||
args.video1,
|
||||
config_name="./config/ddjx01_wuxi.json",
|
||||
cal_path="./config/dd01a_0306.npy",
|
||||
isstitch=True,
|
||||
cuda=False,
|
||||
verbose=False,
|
||||
)
|
||||
sensor2 = orisys.Sensor(
|
||||
args.video2,
|
||||
config_name="./config/ddjx01_wuxi.json",
|
||||
cal_path="./config/dd01a_0306.npy",
|
||||
isstitch=True,
|
||||
cuda=False,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
def _shortcut_reset_tracker():
|
||||
sensor1.reset()
|
||||
sensor2.reset()
|
||||
print("传感器已重置 (快捷键 R)", flush=True)
|
||||
|
||||
def _shortcut_quit():
|
||||
combined_win.close()
|
||||
def _shortcut_toggle_open():
|
||||
global is_open
|
||||
is_open = not is_open
|
||||
print(f"营业状态: {'营业' if is_open else '非营业'}", flush=True)
|
||||
|
||||
_sc_r = QShortcut(QKeySequence(Qt.Key_R), combined_win)
|
||||
_sc_r.activated.connect(_shortcut_reset_tracker)
|
||||
if manual_open_enable:
|
||||
_sc_g = QShortcut(QKeySequence(Qt.Key_G), combined_win)
|
||||
_sc_g.activated.connect(_shortcut_toggle_open)
|
||||
_sc_q = QShortcut(QKeySequence(Qt.Key_Q), combined_win)
|
||||
_sc_q.activated.connect(_shortcut_quit)
|
||||
|
||||
video_cap = cv2.VideoCapture(VIDEO_LOOP_PATH)
|
||||
video_clock = {"fps": 30.0, "next_ts": None}
|
||||
if not video_cap.isOpened():
|
||||
print(f"[video] Failed to open loop video: {VIDEO_LOOP_PATH}", flush=True)
|
||||
else:
|
||||
src_fps = float(video_cap.get(cv2.CAP_PROP_FPS) or 0.0)
|
||||
if np.isfinite(src_fps) and src_fps >= 1.0:
|
||||
video_clock["fps"] = src_fps
|
||||
|
||||
start_time = time.time()
|
||||
is_save = False
|
||||
frame_count = 0
|
||||
writer = None
|
||||
sensor1_active = True
|
||||
sensor2_active = True
|
||||
# Local to main(): assignments inside the loop make this local; must exist
|
||||
# before the first iteration that skips the closed branch.
|
||||
start_open = False
|
||||
while True:
|
||||
now = datetime.now()
|
||||
if outside_opening_hours(now) or not is_open:
|
||||
start_open = False
|
||||
closed_hour_overlay.setGeometry(0, 0, combined_win.width(), combined_win.height())
|
||||
closed_hour_overlay.raise_()
|
||||
closed_hour_overlay.show()
|
||||
app.processEvents()
|
||||
if combined_win.user_closed or not combined_win.isVisible():
|
||||
print("\n窗口已关闭,退出程序")
|
||||
if writer is not None:
|
||||
writer.release()
|
||||
writer = None
|
||||
break
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
if not start_open:
|
||||
start_open = True
|
||||
sensor1.reset()
|
||||
sensor2.reset()
|
||||
print(f"传感器已重置: {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
|
||||
closed_hour_overlay.hide()
|
||||
|
||||
if video_cap is not None and video_cap.isOpened():
|
||||
vret, vframe = read_loop_video_frame_realtime(video_cap, video_clock)
|
||||
if vret:
|
||||
if vframe.shape[1] != VIDEO_FRAME_W or vframe.shape[0] != VIDEO_FRAME_H:
|
||||
vframe = cv2.resize(vframe, (VIDEO_FRAME_W, VIDEO_FRAME_H), interpolation=cv2.INTER_LINEAR)
|
||||
set_video_label_bgr(video_label, vframe)
|
||||
|
||||
if sensor1_active:
|
||||
img1_valid = sensor1.get_img()
|
||||
if img1_valid is None:
|
||||
panel1_ui["arrow_label"].hide()
|
||||
panel1_ui["plot_widget"].hide()
|
||||
panel1_ui["disconnected_label"].show()
|
||||
sensor1_active = False
|
||||
print(f"Sensor1 disconnected. Showing DISCONNECTED screen. {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
|
||||
else:
|
||||
sensor1.compute_deformation(check_motion=True, tail_frames=frame_to_recover, threshold=touch_threshold)
|
||||
_, fn, fx, fy, flow = sensor1.read_info(
|
||||
sensor1.info.FPS,
|
||||
sensor1.info.FNORMAL,
|
||||
sensor1.info.FSHEARX,
|
||||
sensor1.info.FSHEARY,
|
||||
sensor1.info.VRAW,
|
||||
)
|
||||
_bg_bgr = (22, 16, 11)
|
||||
_img = sensor1.img
|
||||
_h, _w = _img.shape[:2]
|
||||
black_img = np.empty((_h, _w, 3), dtype=_img.dtype)
|
||||
black_img[:, :, 0] = _bg_bgr[0]
|
||||
black_img[:, :, 1] = _bg_bgr[1]
|
||||
black_img[:, :, 2] = _bg_bgr[2]
|
||||
lw, lh = panel1_ui["arrow_label"].width(), panel1_ui["arrow_label"].height()
|
||||
if lw > 16 and lh > 16:
|
||||
side = max(32, min(lw, lh))
|
||||
else:
|
||||
side = compute_arrow_canvas_side(panel1_ui["panel"])
|
||||
arrows = draw_arrows_wuxi(
|
||||
black_img,
|
||||
flow,
|
||||
threshold=2,
|
||||
grid_spacing=20,
|
||||
arrow_scale=0.5,
|
||||
below_threshold_color=(160, 160, 160),
|
||||
target_size=int(side),
|
||||
)
|
||||
set_arrow_label_bgr(panel1_ui["arrow_label"], arrows)
|
||||
data1 = panel1_ui["data"]
|
||||
for i in range(3):
|
||||
data1[:-1, 0, i] = data1[1:, 0, i]
|
||||
data1[:-1, 1, i] = data1[1:, 1, i]
|
||||
data1[-1, 0, :] = frame_count / 15.0
|
||||
data1[-1, 1, 0] = fn
|
||||
data1[-1, 1, 1] = abs(fx)
|
||||
data1[-1, 1, 2] = abs(fy)
|
||||
update(
|
||||
panel1_ui["plot_af"],
|
||||
panel1_ui["plot_tfx"],
|
||||
panel1_ui["plot_tfy"],
|
||||
data1,
|
||||
panel1_ui["p1"],
|
||||
panel1_ui["p2"],
|
||||
panel1_ui["p3"],
|
||||
)
|
||||
|
||||
if sensor2_active:
|
||||
img2_valid = sensor2.get_img()
|
||||
if img2_valid is None:
|
||||
panel2_ui["arrow_label"].hide()
|
||||
panel2_ui["plot_widget"].hide()
|
||||
panel2_ui["disconnected_label"].show()
|
||||
sensor2_active = False
|
||||
print(f"Sensor2 disconnected. Showing DISCONNECTED screen. {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
|
||||
else:
|
||||
sensor2.compute_deformation(check_motion=True, tail_frames=frame_to_recover, threshold=touch_threshold)
|
||||
_, fn2, fx2, fy2, flow2 = sensor2.read_info(
|
||||
sensor2.info.FPS,
|
||||
sensor2.info.FNORMAL,
|
||||
sensor2.info.FSHEARX,
|
||||
sensor2.info.FSHEARY,
|
||||
sensor2.info.VRAW,
|
||||
)
|
||||
_bg_bgr2 = (22, 16, 11)
|
||||
_img2 = sensor2.img
|
||||
_h2, _w2 = _img2.shape[:2]
|
||||
black_img2 = np.empty((_h2, _w2, 3), dtype=_img2.dtype)
|
||||
black_img2[:, :, 0] = _bg_bgr2[0]
|
||||
black_img2[:, :, 1] = _bg_bgr2[1]
|
||||
black_img2[:, :, 2] = _bg_bgr2[2]
|
||||
lw2, lh2 = panel2_ui["arrow_label"].width(), panel2_ui["arrow_label"].height()
|
||||
if lw2 > 16 and lh2 > 16:
|
||||
side2 = max(32, min(lw2, lh2))
|
||||
else:
|
||||
side2 = compute_arrow_canvas_side(panel2_ui["panel"])
|
||||
arrows2 = draw_arrows_wuxi(
|
||||
black_img2,
|
||||
flow2,
|
||||
threshold=2,
|
||||
grid_spacing=20,
|
||||
arrow_scale=0.5,
|
||||
below_threshold_color=(160, 160, 160),
|
||||
target_size=int(side2),
|
||||
)
|
||||
set_arrow_label_bgr(panel2_ui["arrow_label"], arrows2)
|
||||
data2 = panel2_ui["data"]
|
||||
for i in range(3):
|
||||
data2[:-1, 0, i] = data2[1:, 0, i]
|
||||
data2[:-1, 1, i] = data2[1:, 1, i]
|
||||
data2[-1, 0, :] = frame_count / 15.0
|
||||
data2[-1, 1, 0] = fn2
|
||||
data2[-1, 1, 1] = abs(fx2)
|
||||
data2[-1, 1, 2] = abs(fy2)
|
||||
update(
|
||||
panel2_ui["plot_af"],
|
||||
panel2_ui["plot_tfx"],
|
||||
panel2_ui["plot_tfy"],
|
||||
data2,
|
||||
panel2_ui["p1"],
|
||||
panel2_ui["p2"],
|
||||
panel2_ui["p3"],
|
||||
)
|
||||
|
||||
app.processEvents()
|
||||
now = datetime.now()
|
||||
if now.hour == 9 and now.minute == 0 and now.second == 0:
|
||||
if sensor1_active:
|
||||
sensor1.reset()
|
||||
if sensor2_active:
|
||||
sensor2.reset()
|
||||
print(
|
||||
f"[session] 传感器已重置: {now:%Y-%m-%d %H:%M:%S} ",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if not sensor1_active and not sensor2_active:
|
||||
while not combined_win.user_closed and combined_win.isVisible():
|
||||
app.processEvents()
|
||||
time.sleep(0.05)
|
||||
break
|
||||
|
||||
frame_count += 1
|
||||
app.processEvents()
|
||||
|
||||
if combined_win.user_closed or not combined_win.isVisible():
|
||||
print("\n窗口已关闭,退出程序")
|
||||
if writer is not None:
|
||||
writer.release()
|
||||
writer = None
|
||||
break
|
||||
|
||||
# Convert combined window to image (arrows + curves)
|
||||
pixmap = combined_win.grab()
|
||||
# Convert QPixmap to QImage
|
||||
qimg = pixmap.toImage()
|
||||
# Convert QImage to numpy array (RGB format)
|
||||
qimg = qimg.convertToFormat(QImage.Format_RGB888)
|
||||
width = qimg.width()
|
||||
height = qimg.height()
|
||||
# Convert QImage to numpy array
|
||||
ptr = qimg.bits()
|
||||
ptr.setsize(height * width * 3)
|
||||
arr = np.array(ptr, dtype=np.uint8).reshape((height, width, 3))
|
||||
|
||||
except BaseException as exc:
|
||||
session_abnormal = datetime.now()
|
||||
print(
|
||||
f"[session] ABNORMAL at {session_abnormal:%Y-%m-%d %H:%M:%S}: "
|
||||
f"{type(exc).__name__}: {exc}",
|
||||
flush=True,
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
session_end = datetime.now()
|
||||
elapsed = session_end - session_start
|
||||
print(
|
||||
f"[session] END {session_end:%Y-%m-%d %H:%M:%S} "
|
||||
f"(elapsed {elapsed})",
|
||||
flush=True,
|
||||
)
|
||||
if sensor1 is not None:
|
||||
try:
|
||||
sensor1.disconnect()
|
||||
except Exception as disconnect_err:
|
||||
print(f"[session] disconnect error: {disconnect_err} {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
|
||||
if 'sensor2' in locals() and sensor2 is not None:
|
||||
try:
|
||||
sensor2.disconnect()
|
||||
except Exception as disconnect_err:
|
||||
print(f"[session] disconnect error (sensor2): {disconnect_err} {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
|
||||
if video_cap is not None:
|
||||
video_cap.release()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -153,7 +153,9 @@ def main():
|
||||
frame_count = 0
|
||||
writer = None
|
||||
while True:
|
||||
sensor1.get_img() # 提取并拼接复眼图像
|
||||
isimg = sensor1.get_img() # 提取并拼接复眼图像
|
||||
if isimg is None:
|
||||
break
|
||||
|
||||
|
||||
sensor1.compute_deformation() # 计算形变和分解
|
||||
@@ -165,8 +167,13 @@ def main():
|
||||
fps, fn, fx, fy, flow, depth_map = sensor1.read_info(sensor1.info.FPS, sensor1.info.FNORMAL, sensor1.info.FSHEARX, sensor1.info.FSHEARY, sensor1.info.VRAW, sensor1.info.DEPTH)
|
||||
print(fps,fn,fx,fy)
|
||||
# 显示向量场
|
||||
black_img = np.zeros_like(sensor1.img)
|
||||
arrows = orisys.util.draw_arrows(sensor1.img, flow, threshold=2, grid_spacing=20, arrow_scale=0.5)
|
||||
_bg_bgr = (22, 16, 11)
|
||||
black_img = np.empty_like(sensor1.img)
|
||||
black_img[:, :, 0] = _bg_bgr[0]
|
||||
black_img[:, :, 1] = _bg_bgr[1]
|
||||
black_img[:, :, 2] = _bg_bgr[2]
|
||||
|
||||
arrows = orisys.util.draw_arrows(black_img, flow, threshold=2, grid_spacing=20, arrow_scale=0.5,below_threshold_color=(160, 160, 160),edge_margin=15,colormap=cv2.COLORMAP_PARULA)
|
||||
cv2.imshow("Deformation", arrows)
|
||||
|
||||
# 显示深度图
|
||||
@@ -0,0 +1,159 @@
|
||||
import argparse
|
||||
import av
|
||||
import cv2
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
TARGET_FPS = 30
|
||||
WIDTH = 1920
|
||||
HEIGHT = 1080
|
||||
|
||||
|
||||
def list_windows_dshow_devices():
|
||||
"""
|
||||
Print DirectShow devices using ffmpeg, if available.
|
||||
"""
|
||||
cmd = ["ffmpeg", "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"]
|
||||
print("Listing Windows DirectShow devices via ffmpeg...")
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError("ffmpeg was not found in PATH; cannot list DirectShow devices.") from exc
|
||||
|
||||
output = (result.stderr or "") + (result.stdout or "")
|
||||
print(output.strip())
|
||||
|
||||
|
||||
def open_pyav_capture(system_name, device, pixel_format):
|
||||
"""
|
||||
Open a camera device via PyAV on Linux or Windows.
|
||||
"""
|
||||
if system_name == "linux":
|
||||
options = {
|
||||
"video_size": f"{WIDTH}x{HEIGHT}",
|
||||
"framerate": str(TARGET_FPS),
|
||||
"input_format": pixel_format or "mjpeg",
|
||||
}
|
||||
container = av.open(device or "/dev/video4", format="v4l2", options=options)
|
||||
elif system_name == "windows":
|
||||
if not device:
|
||||
raise RuntimeError(
|
||||
"On Windows, PyAV/dshow requires a camera name, for example "
|
||||
'--device "USB Camera". Use --list-devices first.'
|
||||
)
|
||||
options = {
|
||||
"video_size": f"{WIDTH}x{HEIGHT}",
|
||||
"framerate": str(TARGET_FPS),
|
||||
}
|
||||
if pixel_format:
|
||||
options["pixel_format"] = pixel_format
|
||||
container = av.open(f"video={device}", format="dshow", options=options)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Unsupported OS: {platform.system()}. Only Linux and Windows are supported."
|
||||
)
|
||||
|
||||
stream = container.streams.video[0]
|
||||
return container, stream
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Capture from camera via PyAV on Linux or Windows, optionally save to MP4."
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--device",
|
||||
type=str,
|
||||
default="",
|
||||
help=(
|
||||
"Linux: V4L2 device path like /dev/video4. "
|
||||
"Windows: DirectShow camera name like 'USB Camera'. "
|
||||
"If empty, Linux uses /dev/video4; Windows requires --device."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pixel-format",
|
||||
type=str,
|
||||
default="",
|
||||
help=(
|
||||
"Optional input pixel format / format hint. "
|
||||
"Examples: Linux 'mjpeg'; Windows may accept values such as 'mjpeg' if supported."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list-devices",
|
||||
action="store_true",
|
||||
help="On Windows, list DirectShow device names via ffmpeg and exit.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
type=str,
|
||||
default="",
|
||||
help="Output MP4 path (e.g. capture.mp4). If empty, only preview, no file.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
system_name = platform.system().lower()
|
||||
if args.list_devices:
|
||||
if system_name != "windows":
|
||||
raise RuntimeError("--list-devices is currently only implemented for Windows.")
|
||||
list_windows_dshow_devices()
|
||||
return
|
||||
|
||||
container, stream = open_pyav_capture(system_name, args.device, args.pixel_format)
|
||||
print(
|
||||
f"Using PyAV backend on {platform.system()} "
|
||||
f"with device={args.device or '/dev/video4'}"
|
||||
)
|
||||
|
||||
frame_count = 0
|
||||
t_start = time.perf_counter()
|
||||
|
||||
writer = None
|
||||
if args.output:
|
||||
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||
writer = cv2.VideoWriter(
|
||||
args.output, fourcc, float(TARGET_FPS), (WIDTH, HEIGHT)
|
||||
)
|
||||
if not writer.isOpened():
|
||||
container.close()
|
||||
raise RuntimeError(f"Could not open VideoWriter for {args.output}")
|
||||
print(f"Recording to {args.output} @ {TARGET_FPS} fps, {WIDTH}x{HEIGHT}")
|
||||
|
||||
try:
|
||||
for frame in container.decode(stream):
|
||||
t0 = time.perf_counter()
|
||||
img = frame.to_ndarray(format="bgr24") # OpenCV-compatible
|
||||
t1 = time.perf_counter()
|
||||
|
||||
if writer is not None:
|
||||
writer.write(img)
|
||||
|
||||
cv2.imshow("pyav_cam", img)
|
||||
key = cv2.waitKey(1) & 0xFF
|
||||
t2 = time.perf_counter()
|
||||
|
||||
frame_count += 1
|
||||
if frame_count % 30 == 0:
|
||||
elapsed = t2 - t_start
|
||||
print(
|
||||
f"Frame {frame_count}: convert={1000*(t1-t0):.1f}ms, "
|
||||
f"imshow+waitKey={1000*(t2-t1):.1f}ms, avg_fps={frame_count/elapsed:.1f}"
|
||||
)
|
||||
|
||||
if key == ord("q"):
|
||||
break
|
||||
finally:
|
||||
container.close()
|
||||
if writer is not None:
|
||||
writer.release()
|
||||
print(f"Saved {frame_count} frames to {args.output}")
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,11 +1,12 @@
|
||||
from setuptools import setup, find_packages
|
||||
from pathlib import Path
|
||||
|
||||
# Find all JSON files in the configs directory
|
||||
configs_dir = Path("src/orisys/configs")
|
||||
json_files = []
|
||||
if configs_dir.exists():
|
||||
json_files = [str(p.relative_to("src")) for p in configs_dir.glob("*.json")]
|
||||
config_dir = Path("config")
|
||||
data_files = []
|
||||
if config_dir.exists():
|
||||
json_files = [str(p) for p in sorted(config_dir.glob("*.json"))]
|
||||
if json_files:
|
||||
data_files.append(("orisys/configs", json_files))
|
||||
|
||||
setup(
|
||||
name="orisys",
|
||||
@@ -14,10 +15,8 @@ setup(
|
||||
author="Your Name",
|
||||
package_dir={"": "src"},
|
||||
packages=find_packages(where="src"),
|
||||
package_data={
|
||||
"orisys": ["configs/*.json"],
|
||||
},
|
||||
include_package_data=True,
|
||||
data_files=data_files,
|
||||
install_requires=[
|
||||
"numpy>=1.24.4",
|
||||
"opencv-python>=4.10.0.84",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _candidate_config_dirs() -> list[Path]:
|
||||
"""
|
||||
Candidate config directories for editable installs and packaged wheels.
|
||||
"""
|
||||
here = Path(__file__).resolve().parent
|
||||
candidates = [
|
||||
here / "configs", # packaged wheel layout: orisys/configs/*.json
|
||||
here.parent.parent / "config", # editable install from repo: repo_root/config/*.json
|
||||
here.parent / "config", # fallback
|
||||
Path.cwd() / "config", # script launched from repo root
|
||||
]
|
||||
|
||||
unique: list[Path] = []
|
||||
for path in candidates:
|
||||
if path not in unique:
|
||||
unique.append(path)
|
||||
return unique
|
||||
|
||||
|
||||
def _get_config_dir() -> Path:
|
||||
for candidate in _candidate_config_dirs():
|
||||
if candidate.exists() and any(candidate.glob("*.json")):
|
||||
return candidate
|
||||
# Default to the first candidate so error messages are stable.
|
||||
return _candidate_config_dirs()[0]
|
||||
|
||||
|
||||
CONFIG_DIR = _get_config_dir()
|
||||
|
||||
|
||||
def _config_files() -> list[Path]:
|
||||
if not CONFIG_DIR.exists():
|
||||
return []
|
||||
return sorted(CONFIG_DIR.glob("*.json"))
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_config(config_name_or_path):
|
||||
"""
|
||||
Load sensor configuration from a JSON file path or config name.
|
||||
|
||||
Config lookup order:
|
||||
1. Exact file path (absolute or relative)
|
||||
2. File stem under the config directory (e.g. ``ddjx01`` -> ``config/ddjx01.json``)
|
||||
3. JSON field ``"name"`` matching the requested name (keeps ``prototype`` compatible)
|
||||
"""
|
||||
config_path = Path(config_name_or_path)
|
||||
|
||||
if config_path.exists() and config_path.is_file():
|
||||
if config_path.suffix.lower() != ".json":
|
||||
raise ValueError(f"Config file must be a JSON file: {config_path}")
|
||||
return _load_json(config_path)
|
||||
|
||||
if config_path.is_absolute():
|
||||
raise FileNotFoundError(f"Config file not found at absolute path: {config_path}")
|
||||
|
||||
direct = CONFIG_DIR / f"{config_name_or_path}.json"
|
||||
if direct.exists():
|
||||
return _load_json(direct)
|
||||
|
||||
# Backward-compatible alias resolution: support old names via JSON "name" field.
|
||||
for candidate in _config_files():
|
||||
try:
|
||||
data = _load_json(candidate)
|
||||
except Exception:
|
||||
continue
|
||||
if data.get("name") == config_name_or_path:
|
||||
return data
|
||||
|
||||
available = [f.stem for f in _config_files()]
|
||||
available_str = ", ".join(available) if available else "none"
|
||||
raise FileNotFoundError(
|
||||
f"Input config file not found with name or path: {config_name_or_path}\n"
|
||||
f" Tried as file path: {config_path} (not found)\n"
|
||||
f" Tried as config file: {direct} (not found)\n"
|
||||
f" Available config files: {available_str}"
|
||||
)
|
||||
|
||||
|
||||
def list_config():
|
||||
"""
|
||||
List all available JSON configuration files by file stem.
|
||||
"""
|
||||
return [f.stem for f in _config_files()]
|
||||
|
||||
|
||||
def export_config(config_name=None, path="./config/exported_config.json"):
|
||||
"""
|
||||
Export a configuration to a JSON file with readable formatting.
|
||||
"""
|
||||
config = load_config(config_name)
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
formatted = _format_json_value(config)
|
||||
f.write(formatted)
|
||||
f.write("\n")
|
||||
|
||||
print(f"Config name: {config_name} exported to {path}")
|
||||
return config
|
||||
|
||||
|
||||
def _format_json_value(value, indent_level=0, indent_str=" "):
|
||||
if isinstance(value, dict):
|
||||
if not value:
|
||||
return "{}"
|
||||
items = []
|
||||
for k, v in value.items():
|
||||
key_str = json.dumps(k, ensure_ascii=False)
|
||||
value_str = _format_json_value(v, indent_level + 1, indent_str)
|
||||
items.append(f"{indent_str * (indent_level + 1)}{key_str}: {value_str}")
|
||||
return "{\n" + ",\n".join(items) + "\n" + indent_str * indent_level + "}"
|
||||
if isinstance(value, list):
|
||||
if not value:
|
||||
return "[]"
|
||||
items = [json.dumps(item, ensure_ascii=False) for item in value]
|
||||
return "[" + ", ".join(items) + "]"
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
@@ -1,172 +0,0 @@
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def _get_config_dir():
|
||||
"""Resolve config directory, robustly handling Nuitka compiled paths."""
|
||||
|
||||
# 1. Start with the directory of this file
|
||||
current_path = Path(__file__).parent
|
||||
|
||||
# 2. Check strict relative location (standard case)
|
||||
# If we are in src/orisys/configs/__init__.py, configs is current_path
|
||||
if (current_path / "prototype.json").exists():
|
||||
return current_path
|
||||
|
||||
# 3. Walk up the directory tree to find a 'configs' folder containing 'prototype.json'
|
||||
# This handles nested virtual paths like .../orisys/orisys/configs
|
||||
# We look for a physical directory that matches our expectation
|
||||
|
||||
search_path = current_path
|
||||
# Limit depth to avoid infinite loops, though unlikely with .parent
|
||||
for _ in range(5):
|
||||
# Check if 'configs' exists in this directory
|
||||
candidate = search_path / "configs"
|
||||
if candidate.exists() and candidate.is_dir():
|
||||
if (candidate / "prototype.json").exists():
|
||||
return candidate
|
||||
|
||||
# Also check if the current directory ITSELF is the configs dir we missed
|
||||
# (e.g. if we are somehow inside configs but missed check 1)
|
||||
if search_path.name == 'configs' and (search_path / "prototype.json").exists():
|
||||
return search_path
|
||||
|
||||
if search_path.parent == search_path: # Reached root
|
||||
break
|
||||
search_path = search_path.parent
|
||||
|
||||
# 4. Try finding 'orisys' package root via sys.modules (if loaded)
|
||||
try:
|
||||
if 'orisys' in sys.modules:
|
||||
mod = sys.modules['orisys']
|
||||
if hasattr(mod, '__file__') and mod.__file__:
|
||||
pkg_root = Path(mod.__file__).parent
|
||||
# pkg_root is .../site-packages/orisys
|
||||
candidate = pkg_root / "configs"
|
||||
if candidate.exists() and (candidate / "prototype.json").exists():
|
||||
return candidate
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to current path if nothing else works
|
||||
return current_path
|
||||
|
||||
CONFIG_DIR = _get_config_dir()
|
||||
|
||||
def load_config(config_name_or_path):
|
||||
"""
|
||||
Load sensor configuration from JSON file.
|
||||
|
||||
Accepts either:
|
||||
1. A file path (absolute or relative) to a JSON config file
|
||||
2. A config name (without .json extension) to load from package configs directory
|
||||
|
||||
Args:
|
||||
config_name_or_path: Either:
|
||||
- A file path string (e.g., "/path/to/config.json" or "./config.json")
|
||||
- A config name string (e.g., "prototype") to load from package configs
|
||||
|
||||
Returns:
|
||||
Dictionary containing the configuration
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the config file doesn't exist
|
||||
ValueError: If the file path is not a JSON file
|
||||
"""
|
||||
# Convert to Path object for easier handling
|
||||
config_path = Path(config_name_or_path)
|
||||
|
||||
# Check if it's an existing file (absolute or relative path)
|
||||
if config_path.exists() and config_path.is_file():
|
||||
# User provided a file path
|
||||
if config_path.suffix.lower() != '.json':
|
||||
raise ValueError(f"Config file must be a JSON file: {config_path}")
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
# If it's an absolute path but doesn't exist, raise error immediately
|
||||
if config_path.is_absolute():
|
||||
raise FileNotFoundError(f"Config file not found at absolute path: {config_path}")
|
||||
|
||||
# Otherwise, treat it as a config name and look in package directory
|
||||
package_config_path = CONFIG_DIR / f"{config_name_or_path}.json"
|
||||
|
||||
if not package_config_path.exists():
|
||||
# List available configs for better error message
|
||||
available = [f.stem for f in CONFIG_DIR.glob("*.json")]
|
||||
available_str = ", ".join(available) if available else "none"
|
||||
raise FileNotFoundError(
|
||||
f"Input config file not found with name or path: {config_name_or_path}\n"
|
||||
f" Tried as file path: {config_path} (not found)\n"
|
||||
f" Tried as package config: {package_config_path} (not found)\n"
|
||||
f" Available package configs: {available_str}"
|
||||
)
|
||||
|
||||
with open(package_config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
def list_config():
|
||||
"""
|
||||
List all available JSON configuration files in the configs directory.
|
||||
|
||||
Returns:
|
||||
List of configuration names (without .json extension), sorted alphabetically.
|
||||
Returns an empty list if no JSON files are found.
|
||||
"""
|
||||
config_files = sorted(CONFIG_DIR.glob("*.json"))
|
||||
return [f.stem for f in config_files]
|
||||
|
||||
def export_config(config_name = None, path="./config/exported_config.json"):
|
||||
"""
|
||||
Export configuration to JSON file with readable formatting.
|
||||
Arrays/lists are kept on a single line, while nested objects are indented.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary to export
|
||||
path: Output file path (default: "./config/exported_config.json")
|
||||
|
||||
Returns:
|
||||
The exported config dictionary
|
||||
"""
|
||||
config = load_config(config_name)
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
formatted = _format_json_value(config)
|
||||
f.write(formatted)
|
||||
f.write('\n') # Add trailing newline
|
||||
|
||||
print(f"Config name: {config_name} exported to {path}")
|
||||
return config
|
||||
|
||||
def _format_json_value(value, indent_level=0, indent_str=" "):
|
||||
"""
|
||||
Recursively format JSON value with arrays/lists on single line.
|
||||
|
||||
Args:
|
||||
value: The value to format (dict, list, or primitive)
|
||||
indent_level: Current indentation level
|
||||
indent_str: String used for indentation (default: 4 spaces)
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
if not value:
|
||||
return "{}"
|
||||
items = []
|
||||
for k, v in value.items():
|
||||
key_str = json.dumps(k, ensure_ascii=False)
|
||||
value_str = _format_json_value(v, indent_level + 1, indent_str)
|
||||
items.append(f"{indent_str * (indent_level + 1)}{key_str}: {value_str}")
|
||||
return "{\n" + ",\n".join(items) + "\n" + indent_str * indent_level + "}"
|
||||
elif isinstance(value, list):
|
||||
if not value:
|
||||
return "[]"
|
||||
# Format arrays/lists on a single line
|
||||
items = [json.dumps(item, ensure_ascii=False) for item in value]
|
||||
return "[" + ", ".join(items) + "]"
|
||||
else:
|
||||
# Primitive types (string, number, bool, None)
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"name": "prototype",
|
||||
"version": "1.0",
|
||||
"camera": {
|
||||
"resolution_raw": [1920, 1080],
|
||||
"fps": 30,
|
||||
"units": 15,
|
||||
"units_used": 9,
|
||||
"resolution_stitch": [365, 365],
|
||||
"stitch_res_list": [365,263,123,63]
|
||||
},
|
||||
"stitcher": {
|
||||
"min_area_frac": 0.01,
|
||||
"max_area_frac": 0.04,
|
||||
"max_aspect_ratio": 100,
|
||||
"index": [1,2,3,6,7,8,11,12,13],
|
||||
"margin": 10,
|
||||
"nominal_ov_pos": 100,
|
||||
"n": [3,3],
|
||||
"row_tol": 0.6,
|
||||
"box_size": [220, 220]
|
||||
},
|
||||
"tracker": {
|
||||
"adaptive": true,
|
||||
"lower_threshold": 12.0,
|
||||
"upper_threshold": 18.0,
|
||||
"loop_closure_interval": 1,
|
||||
"algorithm": "dis",
|
||||
"dis_preset": "medium",
|
||||
"dis_mode": 3,
|
||||
"algorithm_list": ["raft", "dis"]
|
||||
},
|
||||
"contact": {
|
||||
"min_contour_area": 3000,
|
||||
"flow_threshold":1,
|
||||
"div_threshold":0.02,
|
||||
"expansion_threshold": 0.01,
|
||||
"kernel_size": [5,5],
|
||||
"denoise_sigma": 15
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"name": "prototype",
|
||||
"version": "1.0",
|
||||
"camera": {
|
||||
"resolution_raw": [1920, 1080],
|
||||
"fps": 30,
|
||||
"units": 15,
|
||||
"units_used": 9,
|
||||
"resolution_stitch": [400, 400]
|
||||
},
|
||||
"stitcher": {
|
||||
"min_area_frac": 0.01,
|
||||
"max_area_frac": 0.04,
|
||||
"max_aspect_ratio": 100,
|
||||
"index": [1,2,3,6,7,8,11,12,13],
|
||||
"margin": 10,
|
||||
"nominal_ov_pos": 100,
|
||||
"n": [3,3],
|
||||
"row_tol": 0.6
|
||||
},
|
||||
"tracker": {
|
||||
"adaptive": true,
|
||||
"lower_threshold": 12.0,
|
||||
"upper_threshold": 18.0,
|
||||
"loop_closure_interval": 1,
|
||||
"dis_preset": "medium",
|
||||
"finest_scale": 3
|
||||
},
|
||||
"contact": {
|
||||
"min_contour_area": 3000,
|
||||
"flow_threshold":1,
|
||||
"div_threshold":0.02,
|
||||
"expansion_threshold": 0.01,
|
||||
"kernel_size": [5,5],
|
||||
"denoise_sigma": 15
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
HHD subsystem package.
|
||||
|
||||
This package is intended to hold the refactored implementation currently living in
|
||||
`orisys.HHD`. The old monolithic file can remain as a compatibility layer while code
|
||||
is moved here gradually.
|
||||
|
||||
Suggested migration target:
|
||||
- `api.py` -> public `nHHD` class
|
||||
- `grid.py` -> `StructuredGrid`
|
||||
- `poisson.py` -> `PoissonSolver`
|
||||
- `timing.py` -> `Timer`
|
||||
- `kernels.py` -> helper kernels / backend-specific math
|
||||
"""
|
||||
from .api import nHHD
|
||||
from .grid import StructuredGrid
|
||||
from .poisson import PoissonSolver
|
||||
from .timing import Timer
|
||||
|
||||
__all__ = ["nHHD", "StructuredGrid", "PoissonSolver", "Timer"]
|
||||
@@ -0,0 +1,456 @@
|
||||
import numpy
|
||||
|
||||
from ..backend_availability import (
|
||||
CUPY_AVAILABLE,
|
||||
OPENCL_AVAILABLE,
|
||||
KERNEL_AVAILABLE,
|
||||
cp,
|
||||
_torque_kernel,
|
||||
)
|
||||
from .grid import StructuredGrid
|
||||
from .poisson import PoissonSolver
|
||||
|
||||
|
||||
class nHHD(object):
|
||||
|
||||
# constructor
|
||||
def __init__(self, cuda=True, **kwargs):
|
||||
# Select Poisson backend: 'auto' (default), 'cupy', 'opencl', or 'cpu'
|
||||
backend = kwargs.get('poisson_backend', 'auto').lower()
|
||||
if backend not in ('auto', 'cupy', 'opencl', 'cpu'):
|
||||
raise ValueError("poisson_backend must be one of: 'auto', 'cupy', 'opencl', 'cpu'")
|
||||
|
||||
# Resolve 'auto' to a specific backend
|
||||
if backend == 'auto':
|
||||
backend = 'cupy' if CUPY_AVAILABLE else ('opencl' if OPENCL_AVAILABLE else 'cpu')
|
||||
|
||||
# Validate backend availability and fallback to 'cpu' if needed
|
||||
if backend == 'cupy' and not CUPY_AVAILABLE:
|
||||
backend = 'cpu'
|
||||
elif backend == 'opencl' and not OPENCL_AVAILABLE:
|
||||
backend = 'cpu'
|
||||
|
||||
self.debug = kwargs.get('debug', False)
|
||||
self.poisson_backend = backend
|
||||
|
||||
# Initialize CUDA streams for cupy backend
|
||||
if self.poisson_backend == 'cupy':
|
||||
self.stream1 = cp.cuda.Stream()
|
||||
self.stream2 = cp.cuda.Stream()
|
||||
|
||||
self.cuda = cuda
|
||||
|
||||
args = kwargs.keys()
|
||||
|
||||
if ('grid' in args) == ('points' in args):
|
||||
raise SyntaxError("nHHD Solver needs either shape of a regular grid or the points in an rectilinear/unstructured grid")
|
||||
|
||||
if ('grid' in args) != ('spacings' in args):
|
||||
raise SyntaxError("nHHD needs spacings for regular grid")
|
||||
|
||||
if 'grid' in args:
|
||||
self.mesh = StructuredGrid(grid=kwargs['grid'], spacings=kwargs['spacings'])
|
||||
self.psolver = PoissonSolver(grid=self.mesh.dims, spacings=self.mesh.dx, poisson_backend=self.poisson_backend)
|
||||
|
||||
self.dim = self.mesh.dim
|
||||
if (self.dim != 2) and (self.dim != 3):
|
||||
raise ValueError("nHHD Solver works only for 2 and 3 dimensions")
|
||||
|
||||
self._force_cpu_cache = None
|
||||
self.psolver.prepare(False)
|
||||
|
||||
def get_force(self, vfield, verbose=False):
|
||||
if self.poisson_backend == 'cupy':
|
||||
return self.get_force_cuda(vfield, verbose)
|
||||
else:
|
||||
return self.get_force_cpu(vfield, verbose)
|
||||
|
||||
def get_shear(self):
|
||||
if self.poisson_backend == 'cpu' or self.poisson_backend == 'opencl':
|
||||
return self.h
|
||||
elif self.poisson_backend == 'cupy':
|
||||
# Use CPU copy from get_force_cuda when set (stable snapshot; avoids jumps)
|
||||
if hasattr(self, 'h') and self.h is not None:
|
||||
return self.h
|
||||
return cp.asnumpy(self._h_gpu)
|
||||
|
||||
def get_normal(self):
|
||||
if self.poisson_backend == 'cpu' or self.poisson_backend == 'opencl':
|
||||
return self.d
|
||||
elif self.poisson_backend == 'cupy':
|
||||
# Use CPU copy from get_force_cuda when set (stable snapshot; avoids jumps)
|
||||
if hasattr(self, 'd') and self.d is not None:
|
||||
return self.d
|
||||
return cp.asnumpy(self._d_gpu)
|
||||
|
||||
def _solve_poisson_backend(self, field, verbose=False):
|
||||
"""
|
||||
Dispatch Poisson solve using the selected backend.
|
||||
"""
|
||||
if self.poisson_backend == 'opencl':
|
||||
return self.psolver.solve_opencl(field, verbose, profile=self.debug)
|
||||
|
||||
if self.poisson_backend == 'cupy':
|
||||
return self.psolver.solve_gpu(field, verbose)
|
||||
|
||||
if self.poisson_backend == 'cpu':
|
||||
return self.psolver.solve(field, verbose)
|
||||
|
||||
# create the 3-component decomposition
|
||||
def decompose(self, vfield, verbose=0):
|
||||
# decompose for cpu and opencl backend
|
||||
|
||||
if vfield.shape[-1] != self.dim:
|
||||
raise ValueError("nHHD.decompose requires a valid-dimensional vector field")
|
||||
|
||||
if verbose > 0:
|
||||
print('\n ==> nhhd.decompose', vfield.shape)
|
||||
|
||||
if self.dim == 2:
|
||||
|
||||
# compute div curl
|
||||
(self.div, self.curlw) = self.mesh.divcurl(vfield)
|
||||
|
||||
if self.poisson_backend == 'opencl':
|
||||
self.div = numpy.ascontiguousarray(self.div, dtype=numpy.float32)
|
||||
self.curlw = numpy.ascontiguousarray(self.curlw, dtype=numpy.float32)
|
||||
|
||||
if verbose > 0:
|
||||
print('div =', self.div.shape, self.div.min(), self.div.max())
|
||||
print('curlw =', self.curlw.shape, self.curlw.min(), self.curlw.max())
|
||||
|
||||
# compute potentials
|
||||
if self.poisson_backend == 'opencl' and ('OPENCL_AVAILABLE' in globals() and OPENCL_AVAILABLE):
|
||||
self.nD, self.nRu = self.psolver.solve_opencl_batch2(self.div, self.curlw, verbose > 0, profile=self.debug)
|
||||
else:
|
||||
self.nD = self._solve_poisson_backend(self.div, verbose > 0)
|
||||
self.nRu = self._solve_poisson_backend(self.curlw, verbose > 0)
|
||||
if verbose > 0:
|
||||
print('nD =', self.nD.shape, self.nD.min(), self.nD.max())
|
||||
print('nR =', self.nRu.shape, self.nRu.min(), self.nRu.max())
|
||||
|
||||
# compute fields as gradients of potentials
|
||||
self.d = self.mesh.gradient(self.nD)
|
||||
if verbose > 0:
|
||||
print('d =', self.d.shape, self.d.min(), self.d.max())
|
||||
|
||||
self.r = self.mesh.rotated_gradient(self.nRu)
|
||||
if verbose > 0:
|
||||
print('r =', self.r.shape, self.r.min(), self.r.max())
|
||||
else:
|
||||
|
||||
# compute div curl
|
||||
(self.div, self.curlu, self.curlv, self.curlw) = self.mesh.divcurl(vfield)
|
||||
|
||||
if verbose > 0:
|
||||
print('div =', self.div.shape, self.div.min(), self.div.max())
|
||||
print('curlu =', self.curlu.shape, self.curlu.min(), self.curlu.max())
|
||||
print('curlv =', self.curlv.shape, self.curlv.min(), self.curlv.max())
|
||||
print('curlw =', self.curlw.shape, self.curlw.min(), self.curlw.max())
|
||||
|
||||
# compute potentials
|
||||
self.nD = self._solve_poisson_backend(self.div, verbose > 0)
|
||||
if verbose > 0:
|
||||
print('nD =', self.nD.shape, self.nD.min(), self.nD.max())
|
||||
|
||||
self.nRu = self._solve_poisson_backend(self.curlu, verbose > 0)
|
||||
self.nRu *= -1.0
|
||||
if verbose > 0:
|
||||
print('nRu =', self.nRu.shape, self.nRu.min(), self.nRu.max())
|
||||
|
||||
self.nRv = self._solve_poisson_backend(self.curlv, verbose > 0)
|
||||
self.nRv *= -1.0
|
||||
if verbose > 0:
|
||||
print('nRv =', self.nRv.shape, self.nRv.min(), self.nRv.max())
|
||||
|
||||
self.nRw = self._solve_poisson_backend(self.curlw, verbose > 0)
|
||||
self.nRw *= -1.0
|
||||
|
||||
if verbose > 0:
|
||||
print('nRw =', self.nRw.shape, self.nRw.min(), self.nRw.max())
|
||||
|
||||
# compute divergent field as gradient of D potential
|
||||
self.d = self.mesh.gradient(self.nD)
|
||||
if verbose > 0:
|
||||
print('d =', self.d.shape, self.d.min(), self.d.max())
|
||||
|
||||
# temporary use of r
|
||||
self.r = numpy.stack((self.nRu, self.nRv, self.nRw), axis=-1)
|
||||
|
||||
# compute rotational field as curl of R potential
|
||||
(cu, cv, cw) = self.mesh.curl3D(self.r)
|
||||
self.r = numpy.stack((cu, cv, cw), axis=-1)
|
||||
if verbose > 0:
|
||||
print('r =', self.r.shape, self.r.min(), self.r.max())
|
||||
|
||||
self.h = numpy.add(self.d, self.r)
|
||||
numpy.subtract(vfield, self.h, self.h)
|
||||
|
||||
if verbose > 0:
|
||||
print('h =', self.h.shape, self.h.min(), self.h.max())
|
||||
|
||||
def decompose_cuda(self, vfield, verbose=0):
|
||||
"""
|
||||
GPU-optimized decomposition that keeps intermediate results on GPU.
|
||||
Returns GPU arrays to avoid CPU-GPU transfers.
|
||||
|
||||
Args:
|
||||
vfield: Vector field (numpy array or CuPy array)
|
||||
verbose: Verbosity level (default: 0)
|
||||
|
||||
Returns:
|
||||
Tuple of GPU arrays: (d_gpu, h_gpu, r_gpu, nRu_gpu) for 2D fields
|
||||
"""
|
||||
if not CUPY_AVAILABLE:
|
||||
if verbose > 0:
|
||||
print("CuPy not available, falling back to CPU decompose")
|
||||
self.decompose(vfield, verbose)
|
||||
return None
|
||||
|
||||
if vfield.shape[-1] != self.dim:
|
||||
raise ValueError("nHHD.decompose requires a valid-dimensional vector field")
|
||||
|
||||
if verbose > 0:
|
||||
print('\n ==> nhhd.decompose_cuda', vfield.shape)
|
||||
|
||||
# Convert input to GPU if needed
|
||||
if isinstance(vfield, numpy.ndarray):
|
||||
vfield_gpu = cp.asarray(vfield, dtype=cp.float32)
|
||||
else:
|
||||
vfield_gpu = vfield
|
||||
|
||||
if self.dim == 2:
|
||||
# Compute div curl on GPU
|
||||
dx0, dx1 = self.mesh.dx[0], self.mesh.dx[1]
|
||||
|
||||
dudy, dudx = cp.gradient(vfield_gpu[:, :, 0], dx0, dx1)
|
||||
dvdy, dvdx = cp.gradient(vfield_gpu[:, :, 1], dx0, dx1)
|
||||
|
||||
div_gpu = cp.add(dudx, dvdy)
|
||||
curlw_gpu = cp.subtract(dvdx, dudy)
|
||||
|
||||
if verbose > 0:
|
||||
print('div =', div_gpu.shape, float(div_gpu.min()), float(div_gpu.max()))
|
||||
print('curlw =', curlw_gpu.shape, float(curlw_gpu.min()), float(curlw_gpu.max()))
|
||||
|
||||
# Use a single stream for both solves. Running concurrent FFT-based
|
||||
# solves on separate streams can cause occasional long-run jitter.
|
||||
nD_gpu = self.psolver._solve_gpu_internal(div_gpu)
|
||||
nRu_gpu = self.psolver._solve_gpu_internal(curlw_gpu)
|
||||
cp.cuda.get_current_stream().synchronize()
|
||||
|
||||
if verbose > 0:
|
||||
print('nD =', nD_gpu.shape, float(nD_gpu.min()), float(nD_gpu.max()))
|
||||
print('nR =', nRu_gpu.shape, float(nRu_gpu.min()), float(nRu_gpu.max()))
|
||||
|
||||
# Compute gradients on GPU
|
||||
ddy, ddx = cp.gradient(nD_gpu, dx0, dx1)
|
||||
d_gpu = cp.stack((ddx, ddy), axis=-1)
|
||||
|
||||
if verbose > 0:
|
||||
print('d =', d_gpu.shape, float(d_gpu.min()), float(d_gpu.max()))
|
||||
|
||||
# Compute rotated gradient on GPU
|
||||
rdy, rdx = cp.gradient(nRu_gpu, dx0, dx1)
|
||||
rdy = cp.multiply(rdy, -1.0)
|
||||
r_gpu = cp.stack((rdy, rdx), axis=-1)
|
||||
|
||||
if verbose > 0:
|
||||
print('r =', r_gpu.shape, float(r_gpu.min()), float(r_gpu.max()))
|
||||
|
||||
# Compute h on GPU
|
||||
h_gpu = cp.add(d_gpu, r_gpu)
|
||||
h_gpu = cp.subtract(vfield_gpu, h_gpu)
|
||||
|
||||
if verbose > 0:
|
||||
print('h =', h_gpu.shape, float(h_gpu.min()), float(h_gpu.max()))
|
||||
|
||||
# Ensure all default-stream work (gradient, etc.) is complete before use
|
||||
cp.cuda.get_current_stream().synchronize()
|
||||
|
||||
# Store GPU arrays
|
||||
self._d_gpu = d_gpu
|
||||
self._h_gpu = h_gpu
|
||||
self._r_gpu = r_gpu
|
||||
self._nRu_gpu = nRu_gpu
|
||||
|
||||
return (d_gpu, h_gpu, r_gpu, nRu_gpu)
|
||||
|
||||
else:
|
||||
# For 3D, fall back to CPU for now
|
||||
if verbose > 0:
|
||||
print("3D GPU decompose not yet implemented, using CPU")
|
||||
self.decompose(vfield, verbose)
|
||||
return None
|
||||
|
||||
def get_force_cpu(self, vfield, verbose=False):
|
||||
shape = (vfield.shape[0], vfield.shape[1])
|
||||
if (not hasattr(self, '_force_cpu_cache') or
|
||||
self._force_cpu_cache is None or
|
||||
self._force_cpu_cache['shape'] != shape):
|
||||
self._init_force_cpu_cache(shape)
|
||||
grid = self._force_cpu_cache['grid']
|
||||
|
||||
self.decompose(vfield, verbose)
|
||||
|
||||
# Optimized: use abs instead of absolute (slightly faster)
|
||||
self.Sn = numpy.sum(numpy.abs(self.d))
|
||||
|
||||
# Optimized: direct indexing and sum (already efficient)
|
||||
self.Stx = numpy.sum(self.h[:, :, 0])
|
||||
self.Sty = numpy.sum(self.h[:, :, 1])
|
||||
|
||||
# Optimized: find both max and min indices in single pass through flattened array
|
||||
nRu_flat = self.nRu.ravel()
|
||||
nR_max_idx = numpy.argmax(nRu_flat)
|
||||
nR_min_idx = numpy.argmin(nRu_flat)
|
||||
# Convert flat indices to 2D coordinates (faster than unravel_index)
|
||||
nR_max_loc = (nR_max_idx // shape[1], nR_max_idx % shape[1])
|
||||
nR_min_loc = (nR_min_idx // shape[1], nR_min_idx % shape[1])
|
||||
|
||||
# Optimized: compute cross products directly without creating intermediate arrays
|
||||
# For 2D: cross(a, b) = a[0]*b[1] - a[1]*b[0]
|
||||
# Extract components once for reuse
|
||||
r_x = self.r[:, :, 0]
|
||||
r_y = self.r[:, :, 1]
|
||||
grid_x = grid[:, :, 0]
|
||||
grid_y = grid[:, :, 1]
|
||||
|
||||
# Compute cross products directly (faster than numpy.cross for 2D)
|
||||
# cross(r_p, r) where r_p = grid - nR_max_loc
|
||||
max_y, max_x = nR_max_loc[0], nR_max_loc[1]
|
||||
cross_p = (grid_x - max_x) * r_y - (grid_y - max_y) * r_x
|
||||
|
||||
# cross(r_n, r) where r_n = grid - nR_min_loc
|
||||
min_y, min_x = nR_min_loc[0], nR_min_loc[1]
|
||||
cross_n = (grid_x - min_x) * r_y - (grid_y - min_y) * r_x
|
||||
|
||||
self.Stor = numpy.sum(cross_p) + numpy.sum(cross_n)
|
||||
|
||||
# Optimized: pre-allocate output array with explicit dtype
|
||||
output = numpy.array([self.Sn, self.Stx, self.Sty, self.Stor], dtype=numpy.float32)
|
||||
|
||||
# Optimized: combine threshold checks (short-circuit evaluation)
|
||||
flag = (self.Sn >= 300 and self.Stx >= 300 and self.Sty >= 300 and numpy.abs(self.Stor) >= 1000)
|
||||
|
||||
return flag, output
|
||||
|
||||
def _init_force_cpu_cache(self, shape):
|
||||
"""
|
||||
Initialize cached CPU grid arrays for force computation.
|
||||
Avoids per-frame mgrid/stack allocations and reduces jitter.
|
||||
"""
|
||||
mgrid = numpy.mgrid[0:shape[0], 0:shape[1]]
|
||||
self._force_cpu_cache = {
|
||||
'shape': shape,
|
||||
'grid': numpy.stack((mgrid[0], mgrid[1]), axis=2),
|
||||
}
|
||||
|
||||
def _init_force_gpu_cache(self, shape):
|
||||
"""
|
||||
Initialize cached GPU arrays for force computation.
|
||||
Call once when shape is known to avoid repeated allocations.
|
||||
|
||||
Args:
|
||||
shape: Tuple (height, width) of the vector field
|
||||
"""
|
||||
if not CUPY_AVAILABLE:
|
||||
return
|
||||
|
||||
# Cache grid arrays (only need to create once per shape)
|
||||
self._force_gpu_cache = {
|
||||
'shape': shape,
|
||||
'mgrid_0': cp.arange(shape[0], dtype=cp.float32)[:, None],
|
||||
'mgrid_1': cp.arange(shape[1], dtype=cp.float32)[None, :],
|
||||
}
|
||||
# Pre-broadcast for efficiency
|
||||
self._force_gpu_cache['mgrid_0_bc'] = cp.broadcast_to(
|
||||
self._force_gpu_cache['mgrid_0'], shape
|
||||
).copy() # .copy() to make contiguous
|
||||
self._force_gpu_cache['mgrid_1_bc'] = cp.broadcast_to(
|
||||
self._force_gpu_cache['mgrid_1'], shape
|
||||
).copy()
|
||||
|
||||
def get_force_cuda(self, vfield, verbose=False):
|
||||
"""
|
||||
Further optimized GPU force computation using CuPy ElementwiseKernel.
|
||||
Fuses all operations into a single kernel for maximum performance.
|
||||
|
||||
Args:
|
||||
vfield: Vector field (numpy array)
|
||||
verbose: Print debug information (default: False)
|
||||
|
||||
Returns:
|
||||
tuple: (flag, output) where:
|
||||
- flag: Boolean indicating if force thresholds are met
|
||||
- output: Array [Sn, Stx, Sty, Stor] containing force components
|
||||
"""
|
||||
if not CUPY_AVAILABLE:
|
||||
if verbose:
|
||||
print("CuPy not available, falling back to CPU get_force")
|
||||
return self.get_force(vfield, verbose)
|
||||
|
||||
# Decompose the vector field
|
||||
self.decompose_cuda(vfield, verbose)
|
||||
|
||||
d_gpu = self._d_gpu
|
||||
h_gpu = self._h_gpu
|
||||
r_gpu = self._r_gpu
|
||||
nRu_gpu = self._nRu_gpu
|
||||
|
||||
# Use reduction kernels for better performance
|
||||
# Compute Sn as L1 sum directly on GPU (faster than linalg.norm for this case)
|
||||
Sn = float(cp.sum(cp.abs(d_gpu)))
|
||||
|
||||
# Compute Stx, Sty
|
||||
Stx = float(cp.sum(h_gpu[:, :, 0]))
|
||||
Sty = float(cp.sum(h_gpu[:, :, 1]))
|
||||
|
||||
# Find extrema indices
|
||||
nR_max_idx = int(cp.argmax(nRu_gpu))
|
||||
nR_min_idx = int(cp.argmin(nRu_gpu))
|
||||
|
||||
shape = nRu_gpu.shape
|
||||
max_0, max_1 = nR_max_idx // shape[1], nR_max_idx % shape[1]
|
||||
min_0, min_1 = nR_min_idx // shape[1], nR_min_idx % shape[1]
|
||||
|
||||
# Initialize cache if needed
|
||||
if not hasattr(self, '_force_gpu_cache') or self._force_gpu_cache is None or self._force_gpu_cache['shape'] != shape:
|
||||
self._init_force_gpu_cache(shape)
|
||||
|
||||
mgrid_0 = self._force_gpu_cache['mgrid_0_bc']
|
||||
mgrid_1 = self._force_gpu_cache['mgrid_1_bc']
|
||||
|
||||
r_x = r_gpu[:, :, 0]
|
||||
r_y = r_gpu[:, :, 1]
|
||||
|
||||
if KERNEL_AVAILABLE and _torque_kernel is not None:
|
||||
torque = _torque_kernel(
|
||||
r_x, r_y, mgrid_0, mgrid_1,
|
||||
float(max_0), float(max_1), float(min_0), float(min_1)
|
||||
)
|
||||
Stor = float(cp.sum(torque))
|
||||
else:
|
||||
Stor = float(cp.sum(
|
||||
(mgrid_0 - max_0) * r_y - (mgrid_1 - max_1) * r_x +
|
||||
(mgrid_0 - min_0) * r_y - (mgrid_1 - min_1) * r_x
|
||||
))
|
||||
|
||||
self.Sn, self.Stx, self.Sty, self.Stor = Sn, Stx, Sty, Stor
|
||||
output = numpy.array([Sn, Stx, Sty, Stor], dtype=numpy.float32)
|
||||
|
||||
# Keep stable CPU copies for get_normal()/get_shear() so callers get this frame's
|
||||
# result instead of reading GPU memory that can change (avoids CuPy-only jumps)
|
||||
self.d = cp.asnumpy(self._d_gpu)
|
||||
self.h = cp.asnumpy(self._h_gpu)
|
||||
|
||||
flag = (Sn >= 300 and Stx >= 300 and Sty >= 300 and abs(Stor) >= 1000)
|
||||
|
||||
return flag, output
|
||||
|
||||
def clear_force_gpu_cache(self):
|
||||
"""Clear the GPU cache for force computation to free memory."""
|
||||
if hasattr(self, '_force_gpu_cache'):
|
||||
self._force_gpu_cache = None
|
||||
if CUPY_AVAILABLE:
|
||||
cp.get_default_memory_pool().free_all_blocks()
|
||||
@@ -0,0 +1,385 @@
|
||||
import numpy
|
||||
import timeit
|
||||
import sys
|
||||
# import cv2
|
||||
from scipy import signal, spatial
|
||||
|
||||
from ..backend_availability import (
|
||||
CUPY_AVAILABLE, OPENCL_AVAILABLE, KERNEL_AVAILABLE,
|
||||
cp, cp_signal, _torque_kernel,
|
||||
cl, cla, cl_rfftn, cl_irfftn
|
||||
)
|
||||
from .timing import Timer
|
||||
|
||||
class StructuredGrid(object):
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------
|
||||
def __init__(self, **kwargs):
|
||||
|
||||
'''
|
||||
kwargs:
|
||||
grid: ndarray of grid dimensions (Y,X) or (Z,Y,X)
|
||||
spacings: ndarray of grid spacings (dy, dx) or (dz, dy, dx)
|
||||
verbose: verbosity level
|
||||
'''
|
||||
|
||||
args = kwargs.keys()
|
||||
|
||||
if ('grid' not in args) or ('spacings' not in args):
|
||||
raise SyntaxError("Dimensions and spacings of the grid are required")
|
||||
|
||||
verbose = 0
|
||||
if 'verbose' in args:
|
||||
verbose = kwargs['verbose']
|
||||
|
||||
self.dims = kwargs['grid']
|
||||
self.dx = kwargs['spacings']
|
||||
self.dim = len(self.dims)
|
||||
|
||||
if self.dim != 2 and self.dim != 3:
|
||||
raise ValueError("StructuredGrid works for 2D and 3D only")
|
||||
|
||||
if self.dim != len(self.dx):
|
||||
raise ValueError("Dimensions of spacings should match that of the grid")
|
||||
|
||||
# Initialize GPU cache
|
||||
self._gpu_divcurl_cache = None
|
||||
|
||||
if verbose > 0:
|
||||
print(' Initializing', self.dim, 'D structured grid...', end ='')
|
||||
sys.stdout.flush()
|
||||
mtimer = Timer()
|
||||
print(' Done!', end ='')
|
||||
mtimer.end()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------
|
||||
def divcurl(self, vfield, verbose=False):
|
||||
# compute div and curl of vfield (vector field)
|
||||
#if (vfield.shape[-1] != self.dim) or (vfield.shape[0:self.dim] - self.dims).any():
|
||||
if (vfield.shape[-1] != self.dim) or (vfield.shape[0:self.dim] != self.dims):
|
||||
raise ValueError("Dimensions of vector field should match that of the grid")
|
||||
|
||||
if verbose:
|
||||
print(' Computing div and curl...', end ='')
|
||||
sys.stdout.flush()
|
||||
mtimer = Timer()
|
||||
|
||||
if self.dim == 2:
|
||||
|
||||
# self.dx = (dy,dx)
|
||||
dudy, dudx = numpy.gradient(vfield[:,:,0], self.dx[0], self.dx[1])
|
||||
dvdy, dvdx = numpy.gradient(vfield[:,:,1], self.dx[0], self.dx[1])
|
||||
|
||||
numpy.add(dudx, dvdy, dudx)
|
||||
numpy.subtract(dvdx, dudy, dvdx)
|
||||
|
||||
if verbose:
|
||||
print(' Done!', end ='')
|
||||
mtimer.end()
|
||||
|
||||
return (dudx, dvdx)
|
||||
|
||||
elif self.dim == 3:
|
||||
|
||||
# self.dx = (dz,dy,dx)
|
||||
dudz, dudy, dudx = numpy.gradient(vfield[:,:,:,0], self.dx[0], self.dx[1], self.dx[2])
|
||||
dvdz, dvdy, dvdx = numpy.gradient(vfield[:,:,:,1], self.dx[0], self.dx[1], self.dx[2])
|
||||
dwdz, dwdy, dwdx = numpy.gradient(vfield[:,:,:,2], self.dx[0], self.dx[1], self.dx[2])
|
||||
|
||||
numpy.add(dudx, dvdy, dudx)
|
||||
numpy.add(dudx, dvdz, dudx)
|
||||
|
||||
numpy.subtract(dwdy, dvdz, dwdy)
|
||||
numpy.subtract(dudz, dwdx, dudz)
|
||||
numpy.subtract(dvdx, dudy, dvdx)
|
||||
|
||||
if verbose:
|
||||
print(' Done!', end ='')
|
||||
mtimer.end()
|
||||
|
||||
return (dudx, dwdy, dudz, dvdx)
|
||||
|
||||
# def divcurl_gpu(self, vfield, verbose=False):
|
||||
# """
|
||||
# GPU-accelerated computation of divergence and curl of vector field.
|
||||
# Uses CuPy for faster gradient computation on GPU.
|
||||
# """
|
||||
# if not CUPY_AVAILABLE:
|
||||
# if verbose:
|
||||
# print('CuPy not available, falling back to CPU')
|
||||
# return self.divcurl(vfield, verbose)
|
||||
|
||||
# if (vfield.shape[-1] != self.dim) or (vfield.shape[0:self.dim] != self.dims):
|
||||
# raise ValueError("Dimensions of vector field should match that of the grid")
|
||||
|
||||
# if verbose:
|
||||
# print(' Computing div and curl on GPU...', end ='')
|
||||
# sys.stdout.flush()
|
||||
# mtimer = Timer()
|
||||
|
||||
# if self.dim == 2:
|
||||
# # Transfer to GPU
|
||||
# vfield_gpu = cp.asarray(vfield, dtype=cp.float32)
|
||||
|
||||
# # Compute gradients on GPU
|
||||
# dudy, dudx = cp.gradient(vfield_gpu[:,:,0], self.dx[0], self.dx[1])
|
||||
# dvdy, dvdx = cp.gradient(vfield_gpu[:,:,1], self.dx[0], self.dx[1])
|
||||
|
||||
# # Compute div and curl on GPU
|
||||
# div_gpu = dudx + dvdy
|
||||
# curl_gpu = dvdx - dudy
|
||||
|
||||
# # Transfer back to CPU
|
||||
# div = cp.asnumpy(div_gpu)
|
||||
# curl = cp.asnumpy(curl_gpu)
|
||||
|
||||
# if verbose:
|
||||
# print(' Done!', end ='')
|
||||
# mtimer.end()
|
||||
|
||||
# return (div, curl)
|
||||
|
||||
# elif self.dim == 3:
|
||||
# # Transfer to GPU
|
||||
# vfield_gpu = cp.asarray(vfield, dtype=cp.float32)
|
||||
|
||||
# # Compute gradients on GPU
|
||||
# dudz, dudy, dudx = cp.gradient(vfield_gpu[:,:,:,0], self.dx[0], self.dx[1], self.dx[2])
|
||||
# dvdz, dvdy, dvdx = cp.gradient(vfield_gpu[:,:,:,1], self.dx[0], self.dx[1], self.dx[2])
|
||||
# dwdz, dwdy, dwdx = cp.gradient(vfield_gpu[:,:,:,2], self.dx[0], self.dx[1], self.dx[2])
|
||||
|
||||
# # Compute div and curl components on GPU
|
||||
# div_gpu = dudx + dvdy + dwdz
|
||||
# curlu_gpu = dwdy - dvdz
|
||||
# curlv_gpu = dudz - dwdx
|
||||
# curlw_gpu = dvdx - dudy
|
||||
|
||||
# # Transfer back to CPU
|
||||
# div = cp.asnumpy(div_gpu)
|
||||
# curlu = cp.asnumpy(curlu_gpu)
|
||||
# curlv = cp.asnumpy(curlv_gpu)
|
||||
# curlw = cp.asnumpy(curlw_gpu)
|
||||
|
||||
# if verbose:
|
||||
# print(' Done!', end ='')
|
||||
# mtimer.end()
|
||||
|
||||
# return (div, curlu, curlv, curlw)
|
||||
|
||||
def curl3D(self, vfield, verbose=False):
|
||||
|
||||
#if (vfield.shape[-1] != self.dim) or (vfield.shape[0:self.dim] - self.dims).any():
|
||||
if (vfield.shape[-1] != self.dim) or (vfield.shape[0:self.dim] != self.dims):
|
||||
raise ValueError("Dimensions of vector field should match that of the grid")
|
||||
|
||||
if self.dim != 3:
|
||||
raise ValueError("curl3D works only for 2D")
|
||||
|
||||
if verbose:
|
||||
print(' Computing curl...', end ='')
|
||||
sys.stdout.flush()
|
||||
mtimer = Timer()
|
||||
|
||||
# self.dx = (dz,dy,dx)
|
||||
dudz, dudy, dudx = numpy.gradient(vfield[:,:,:,0], self.dx[0], self.dx[1], self.dx[2])
|
||||
dvdz, dvdy, dvdx = numpy.gradient(vfield[:,:,:,1], self.dx[0], self.dx[1], self.dx[2])
|
||||
dwdz, dwdy, dwdx = numpy.gradient(vfield[:,:,:,2], self.dx[0], self.dx[1], self.dx[2])
|
||||
|
||||
numpy.subtract(dwdy, dvdz, dwdy)
|
||||
numpy.subtract(dudz, dwdx, dudz)
|
||||
numpy.subtract(dvdx, dudy, dvdx)
|
||||
|
||||
if verbose:
|
||||
print(' Done!', end ='')
|
||||
mtimer.end()
|
||||
|
||||
return (dwdy, dudz, dvdx)
|
||||
|
||||
def curl3D_gpu(self, vfield, verbose=False):
|
||||
"""
|
||||
GPU-accelerated computation of curl for 3D vector field.
|
||||
Uses CuPy for faster gradient computation on GPU.
|
||||
"""
|
||||
if not CUPY_AVAILABLE:
|
||||
if verbose:
|
||||
print('CuPy not available, falling back to CPU')
|
||||
return self.curl3D(vfield, verbose)
|
||||
|
||||
if (vfield.shape[-1] != self.dim) or (vfield.shape[0:self.dim] != self.dims):
|
||||
raise ValueError("Dimensions of vector field should match that of the grid")
|
||||
|
||||
if self.dim != 3:
|
||||
raise ValueError("curl3D works only for 3D")
|
||||
|
||||
if verbose:
|
||||
print(' Computing curl on GPU...', end ='')
|
||||
sys.stdout.flush()
|
||||
mtimer = Timer()
|
||||
|
||||
# Transfer to GPU
|
||||
vfield_gpu = cp.asarray(vfield, dtype=cp.float32)
|
||||
|
||||
# Compute gradients on GPU
|
||||
dudz, dudy, dudx = cp.gradient(vfield_gpu[:,:,:,0], self.dx[0], self.dx[1], self.dx[2])
|
||||
dvdz, dvdy, dvdx = cp.gradient(vfield_gpu[:,:,:,1], self.dx[0], self.dx[1], self.dx[2])
|
||||
dwdz, dwdy, dwdx = cp.gradient(vfield_gpu[:,:,:,2], self.dx[0], self.dx[1], self.dx[2])
|
||||
|
||||
# Compute curl components on GPU
|
||||
curl_u = dwdy - dvdz
|
||||
curl_v = dudz - dwdx
|
||||
curl_w = dvdx - dudy
|
||||
|
||||
# Transfer back to CPU
|
||||
curlu = cp.asnumpy(curl_u)
|
||||
curlv = cp.asnumpy(curl_v)
|
||||
curlw = cp.asnumpy(curl_w)
|
||||
|
||||
if verbose:
|
||||
print(' Done!', end ='')
|
||||
mtimer.end()
|
||||
|
||||
return (curlu, curlv, curlw)
|
||||
|
||||
def rotated_gradient(self, sfield, verbose=False):
|
||||
|
||||
if (sfield.shape != self.dims):
|
||||
#if (sfield.shape - self.dims).any():
|
||||
raise ValueError("Dimensions of scalar field should match that of the grid")
|
||||
|
||||
if self.dim != 2:
|
||||
raise ValueError("rotated_gradient works only for 2D")
|
||||
|
||||
if verbose:
|
||||
print(' Computing rotated gradient...', end ='')
|
||||
sys.stdout.flush()
|
||||
mtimer = Timer()
|
||||
|
||||
ddy, ddx = numpy.gradient(sfield, self.dx[0], self.dx[1])
|
||||
ddy *= -1.0
|
||||
|
||||
grad = numpy.stack((ddy, ddx), axis=-1)
|
||||
|
||||
if verbose:
|
||||
print(' Done!', end ='')
|
||||
mtimer.end()
|
||||
|
||||
return grad
|
||||
|
||||
def rotated_gradient_gpu(self, sfield, verbose=False):
|
||||
"""
|
||||
GPU-accelerated computation of rotated gradient for 2D scalar field.
|
||||
Uses CuPy for faster gradient computation on GPU.
|
||||
"""
|
||||
if not CUPY_AVAILABLE:
|
||||
if verbose:
|
||||
print('CuPy not available, falling back to CPU')
|
||||
return self.rotated_gradient(sfield, verbose)
|
||||
|
||||
if (sfield.shape != self.dims):
|
||||
raise ValueError("Dimensions of scalar field should match that of the grid")
|
||||
|
||||
if self.dim != 2:
|
||||
raise ValueError("rotated_gradient works only for 2D")
|
||||
|
||||
if verbose:
|
||||
print(' Computing rotated gradient on GPU...', end ='')
|
||||
sys.stdout.flush()
|
||||
mtimer = Timer()
|
||||
|
||||
# Transfer to GPU
|
||||
sfield_gpu = cp.asarray(sfield, dtype=cp.float32)
|
||||
|
||||
# Compute gradients on GPU
|
||||
ddy, ddx = cp.gradient(sfield_gpu, self.dx[0], self.dx[1])
|
||||
ddy *= -1.0
|
||||
|
||||
# Stack and transfer back to CPU
|
||||
grad_gpu = cp.stack((ddy, ddx), axis=-1)
|
||||
grad = cp.asnumpy(grad_gpu)
|
||||
|
||||
if verbose:
|
||||
print(' Done!', end ='')
|
||||
mtimer.end()
|
||||
|
||||
return grad
|
||||
|
||||
def gradient(self, sfield, verbose=False):
|
||||
|
||||
if (sfield.shape != self.dims):
|
||||
#if (sfield.shape - self.dims).any():
|
||||
raise ValueError("Dimensions of scalar field should match that of the grid")
|
||||
|
||||
if verbose:
|
||||
print(' Computing gradient...', end ='')
|
||||
sys.stdout.flush()
|
||||
mtimer = Timer()
|
||||
|
||||
if self.dim == 2:
|
||||
|
||||
# self.dx = (dy,dx)
|
||||
ddy, ddx = numpy.gradient(sfield, self.dx[0], self.dx[1])
|
||||
grad = numpy.stack((ddx, ddy), axis = -1)
|
||||
|
||||
elif self.dim == 3:
|
||||
|
||||
# self.dx = (dz,dy,dx)
|
||||
ddz, ddy, ddx = numpy.gradient(sfield, self.dx[0], self.dx[1], self.dx[2])
|
||||
grad = numpy.stack((ddx, ddy, ddz), axis = -1)
|
||||
|
||||
if verbose:
|
||||
print(' Done!', end ='')
|
||||
mtimer.end()
|
||||
|
||||
return grad
|
||||
|
||||
def gradient_gpu(self, sfield, verbose=False):
|
||||
"""
|
||||
GPU-accelerated computation of gradient for scalar field.
|
||||
Uses CuPy for faster gradient computation on GPU.
|
||||
"""
|
||||
if not CUPY_AVAILABLE:
|
||||
if verbose:
|
||||
print('CuPy not available, falling back to CPU')
|
||||
return self.gradient(sfield, verbose)
|
||||
|
||||
if (sfield.shape != self.dims):
|
||||
raise ValueError("Dimensions of scalar field should match that of the grid")
|
||||
|
||||
if verbose:
|
||||
print(' Computing gradient on GPU...', end ='')
|
||||
sys.stdout.flush()
|
||||
mtimer = Timer()
|
||||
|
||||
# Transfer to GPU
|
||||
sfield_gpu = cp.asarray(sfield, dtype=cp.float32)
|
||||
|
||||
if self.dim == 2:
|
||||
# Compute gradients on GPU
|
||||
ddy, ddx = cp.gradient(sfield_gpu, self.dx[0], self.dx[1])
|
||||
grad_gpu = cp.stack((ddx, ddy), axis=-1)
|
||||
|
||||
elif self.dim == 3:
|
||||
# Compute gradients on GPU
|
||||
ddz, ddy, ddx = cp.gradient(sfield_gpu, self.dx[0], self.dx[1], self.dx[2])
|
||||
grad_gpu = cp.stack((ddx, ddy, ddz), axis=-1)
|
||||
|
||||
# Transfer back to CPU
|
||||
grad = cp.asnumpy(grad_gpu)
|
||||
|
||||
if verbose:
|
||||
print(' Done!', end ='')
|
||||
mtimer.end()
|
||||
|
||||
return grad
|
||||
|
||||
def clear_gpu_cache(self):
|
||||
"""Clear GPU cache to free GPU memory"""
|
||||
if self._gpu_divcurl_cache is not None:
|
||||
self._gpu_divcurl_cache = None
|
||||
if CUPY_AVAILABLE:
|
||||
cp.get_default_memory_pool().free_all_blocks()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Low-level kernels and shared numeric helpers for HHD.
|
||||
|
||||
Use this file for pieces that should not live inside the public `nHHD` API or the
|
||||
`PoissonSolver` class, for example:
|
||||
- finite-difference helper functions
|
||||
- small backend-dispatch helpers
|
||||
- reusable CuPy/OpenCL kernel preparation code
|
||||
- shared CPU math that does not depend on object state
|
||||
"""
|
||||
|
||||
# No standalone helpers from the old monolithic `HHD.py` are required yet.
|
||||
# Keep this module in place so future extracted CUDA/OpenCL/CPU helpers have a
|
||||
# stable home without changing the public package layout again.
|
||||
|
||||
__all__ = []
|
||||
@@ -0,0 +1,954 @@
|
||||
import numpy
|
||||
import timeit
|
||||
import sys
|
||||
# import cv2
|
||||
from scipy import signal, spatial
|
||||
|
||||
from ..backend_availability import (
|
||||
CUPY_AVAILABLE, OPENCL_AVAILABLE, KERNEL_AVAILABLE,
|
||||
cp, cp_signal, _torque_kernel,
|
||||
cl, cla, cl_rfftn, cl_irfftn
|
||||
)
|
||||
from .timing import Timer
|
||||
|
||||
'''
|
||||
# ------------------------------------------------------------------------------
|
||||
Poisson Solver for 1D, 2D, and 3D scalar fields.
|
||||
Data may be defined on a rectlinear grid or unstructured points.
|
||||
|
||||
1) create an object
|
||||
PoissonSolver(args)
|
||||
'solver' : type of solver
|
||||
'S' for spatial convolution
|
||||
only option for unstructured points
|
||||
'F' for convolution in frequency space
|
||||
default option for regular grids
|
||||
|
||||
--> for regular grid
|
||||
'grid' : shape of the rectlinear grid
|
||||
tuple of dimensions (Z,Y,X)
|
||||
'spacings' : spacings of the rectlinear grid
|
||||
tuple of spacings (dz,dy,dx)
|
||||
|
||||
--> for unstrucutred points
|
||||
'points' : unstructured points
|
||||
numpy array of shape (N, dim)
|
||||
'pvolumes' : point volumes for each point
|
||||
numpy array of shape (N, 1)
|
||||
|
||||
2) call prepare()
|
||||
this initializes internal datastructures depending upon the mesh.
|
||||
|
||||
Once initialized, solve() can be called any number of times to
|
||||
solve the Poisson equation on the same domain. If the domain
|
||||
changes (e.g., a Lagrangian mesh), create a new object.
|
||||
|
||||
3) call solve(f), where, f is a numpy array defining a scalar field
|
||||
|
||||
for rectlinear grid, f.shape = grid.shape
|
||||
for unstructured points, f.shape = (points.shape[0], 1)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
'''
|
||||
|
||||
class PoissonSolver(object):
|
||||
|
||||
# constructor
|
||||
def __init__(self, **kwargs):
|
||||
|
||||
args = kwargs.keys()
|
||||
|
||||
if ('grid' in args) == ('points' in args):
|
||||
raise SyntaxError("Poisson Solver needs either shape of a regular grid or the points in an rectilinear/unstructured grid")
|
||||
|
||||
if ('grid' in args) != ('spacings' in args):
|
||||
raise SyntaxError("Poisson Solver needs spacings for regular grid")
|
||||
|
||||
if ('points' in args) != ('pvolumes' in args):
|
||||
raise SyntaxError("Poisson Solver needs point volumes for unstructured points")
|
||||
|
||||
# (default) data type
|
||||
self.dtype = numpy.float32
|
||||
if 'dtype' in args:
|
||||
self.dtype = kwargs['dtype']
|
||||
|
||||
if 'grid' in args:
|
||||
self.ptype = 'G'
|
||||
self.stype = 'F' # default
|
||||
self.gdims = kwargs['grid']
|
||||
self.gdx = kwargs['spacings']
|
||||
self.dim = len(self.gdims)
|
||||
if self.dim != len(self.gdx):
|
||||
raise ValueError("Dimensions of spacings should match that of the grid")
|
||||
|
||||
elif 'points' in args:
|
||||
self.ptype = 'P'
|
||||
self.stype = 'S' # only option
|
||||
self.points = kwargs['points']
|
||||
self.pvolumes = kwargs['pvolumes']
|
||||
self.dim = len(self.points.shape)
|
||||
if self.pvolumes.shape[0] != self.points.shape[0]:
|
||||
raise ValueError("Number of pvolumes should match that of the points")
|
||||
|
||||
if 'solver' in args:
|
||||
if (kwargs['solver'] != 'S' and kwargs['solver'] != 'F'):
|
||||
raise SyntaxError('Solver can be only of type S(patial) or F(requency)')
|
||||
|
||||
if (kwargs['solver'] == 'F') and ('points' in args):
|
||||
raise SyntaxError('Only Spatial solver supported for unstructured points')
|
||||
|
||||
if (kwargs['solver'] == 'S') and ('grid' in args):
|
||||
raise SyntaxError('Frequncy solver strongly suggested for structured grids')
|
||||
|
||||
self.stype = kwargs['solver']
|
||||
if 'poisson_backend' in args:
|
||||
self.poisson_backend = kwargs['poisson_backend']
|
||||
if self.poisson_backend not in ('cpu', 'opencl', 'cupy'):
|
||||
raise ValueError("poisson_backend must be one of: 'cpu', 'opencl', 'cupy'")
|
||||
|
||||
if (self.dim > 3):
|
||||
raise ValueError("Poisson Solver works only for 1, 2, or 3 dimensions")
|
||||
|
||||
self.ready = False
|
||||
if self.poisson_backend == 'cupy':
|
||||
self._gpu_cache = None # Cache for GPU arrays
|
||||
if self.poisson_backend == 'opencl':
|
||||
self._opencl_cache = None # Cache for OpenCL FFT buffers
|
||||
self._opencl_queue = None # OpenCL command queue
|
||||
self._opencl_context = None # OpenCL context (set when queue is created)
|
||||
self._opencl_crop_kernel = None # Cached OpenCL crop kernel
|
||||
self._opencl_scatter_kernel = None # Cached scatter (reverse-crop) kernel
|
||||
self._opencl_fused_mul_kernel = None # Cached fused complex multiply kernel
|
||||
self._opencl_scatter_batch_kernel = None # Cached batch scatter kernel
|
||||
self._opencl_crop_batch_kernel = None # Cached batch crop kernel
|
||||
self._opencl_batch_mul_kernel = None # Cached batch complex multiply kernel
|
||||
self._opencl_last_timing = None # Last per-stage OpenCL timing in ms
|
||||
# Cache for OpenCV FFT (CPU backend)
|
||||
self._opencv_cache = None # Cache for OpenCV FFT buffers
|
||||
# if (self.ptype == 'G'):
|
||||
# print( 'PoissonSolver:', self.dim, 'D grid =', self.gdims, 'with spacings', self.gdx)
|
||||
# elif (self.ptype == 'P'):
|
||||
# print( 'PoissonSolver', self.dim, 'D points =', self.points.shape)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# create the Green's function in appropriate representation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# generate a grid -(N-1) to (N-1) to define distance function
|
||||
# where N is the size of the original grid
|
||||
def _create_radialGrid(self, verbose):
|
||||
|
||||
mtimer = Timer()
|
||||
if verbose:
|
||||
print( ' - creating distance kernel:', end ='')
|
||||
|
||||
rdims = tuple(2*d - 1 for d in self.gdims)
|
||||
|
||||
R = numpy.indices(rdims, dtype=self.dtype)
|
||||
for d in range(self.dim):
|
||||
R[d] += 1-self.gdims[d]
|
||||
R[d] *= self.gdx[d]
|
||||
|
||||
if self.dim == 1:
|
||||
numpy.absolute(R[0], R[0])
|
||||
R = R[0]
|
||||
|
||||
elif self.dim == 2:
|
||||
numpy.hypot(R[1], R[0], R[0])
|
||||
R = R[0]
|
||||
|
||||
# half of the average grid spacing
|
||||
zval = 0.5 * (self.gdx[0]+self.gdx[1]) / 2.0
|
||||
R[self.gdims[0]-1, self.gdims[1]-1] = zval
|
||||
|
||||
elif self.dim == 3:
|
||||
numpy.hypot(R[2], R[1], R[1])
|
||||
numpy.hypot(R[1], R[0], R[0])
|
||||
R = R[0]
|
||||
|
||||
# half of the average grid spacing
|
||||
zval = 0.5 * (self.gdx[0]+self.gdx[1]+self.gdx[2]) / 3.0
|
||||
R[self.gdims[0]-1, self.gdims[1]-1, self.gdims[2]-1] = zval
|
||||
|
||||
if verbose:
|
||||
print( R.shape, R.min(), R.max())
|
||||
mtimer.end()
|
||||
|
||||
return R
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# scale a function by point volumes for correct integration
|
||||
def _scale(self, func):
|
||||
|
||||
if self.ptype == 'P':
|
||||
return numpy.multiply(func, self.pvolumes)
|
||||
|
||||
elif self.ptype == 'G':
|
||||
sfunc = func * numpy.prod(self.gdx)
|
||||
|
||||
'''
|
||||
# volume of the boundary elements is only half the voxel
|
||||
if self.dim == 1:
|
||||
sfunc[ 0] *= 0.5
|
||||
sfunc[-1] *= 0.5
|
||||
elif self.dim == 2:
|
||||
sfunc[ 0, :] *= 0.5
|
||||
sfunc[-1, :] *= 0.5
|
||||
sfunc[ :, 0] *= 0.5
|
||||
sfunc[ :,-1] *= 0.5
|
||||
elif self.dim == 3:
|
||||
sfunc[ 0, :, :] *= 0.5
|
||||
sfunc[-1, :, :] *= 0.5
|
||||
sfunc[ :, 0, :] *= 0.5
|
||||
sfunc[ :,-1, :] *= 0.5
|
||||
sfunc[ :, :, 0] *= 0.5
|
||||
sfunc[ :, :,-1] *= 0.5
|
||||
'''
|
||||
return sfunc
|
||||
|
||||
def prepare(self, verbose=False):
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
gtimer = Timer()
|
||||
if verbose:
|
||||
print( '\nInitializing Poisson solver, type =', self.stype)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# generate a grid to store pairwise distances to store Green's function
|
||||
self.G = None # definition depends upon solver type
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# for Fourier solution,
|
||||
if self.stype == 'F':
|
||||
|
||||
if self.ptype == 'G':
|
||||
self.G = self._create_radialGrid(verbose)
|
||||
|
||||
elif self.ptype == 'P':
|
||||
raise SyntaxError('Frequncy solver not supported for unstructured points')
|
||||
#self.G = create_distArray()
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# for spatial solution,
|
||||
# use the pairwise distance matrix, N x N
|
||||
# where N is the number of points
|
||||
elif self.stype == 'S':
|
||||
|
||||
if self.ptype == 'G':
|
||||
raise SyntaxError('Spatial solver not suggested for structured grids')
|
||||
#self.G = self.create_points(verbose)
|
||||
|
||||
elif self.ptype == 'P':
|
||||
# for 1D points, add an extra axis
|
||||
if (self.dim == 1) and (len(self.points.shape) == 1):
|
||||
self.points = self.points[:,numpy.newaxis]
|
||||
|
||||
# create pairwise distance matrix now
|
||||
ltimer = Timer()
|
||||
self.G = spatial.distance.cdist(self.points, self.points)
|
||||
|
||||
# TODO: use a good approximation here
|
||||
zval = numpy.power(10.0, -10.0)
|
||||
self.G += zval * numpy.identity(self.G.shape[0])
|
||||
|
||||
if verbose:
|
||||
print(' - created pairwise distance matrix:', self.G.shape, self.G.min(), self.G.max(), end = "")
|
||||
ltimer.end()
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# compute the Green's function
|
||||
ltimer = Timer()
|
||||
if verbose:
|
||||
print( ' - computing the Green\'s function:')
|
||||
|
||||
if self.dim == 1:
|
||||
numpy.multiply(self.G, 0.5, self.G)
|
||||
|
||||
elif self.dim == 2:
|
||||
numpy.log(self.G, self.G)
|
||||
numpy.multiply(self.G, (0.5 / numpy.pi), self.G)
|
||||
|
||||
elif self.dim == 3:
|
||||
numpy.reciprocal(self.G, self.G)
|
||||
numpy.multiply(self.G, (-0.25 / numpy.pi), self.G)
|
||||
|
||||
if verbose:
|
||||
print( self.G.shape, self.G.min(), self.G.max(), end = "")
|
||||
ltimer.end()
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
self.ready = True
|
||||
|
||||
if verbose:
|
||||
print( 'Poisson solver initialized', end ='')
|
||||
gtimer.end()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# compute the integral solution
|
||||
# --------------------------------------------------------------------------
|
||||
def solve(self, f, verbose=False):
|
||||
|
||||
if not self.ready:
|
||||
self.prepare(verbose)
|
||||
|
||||
fshape = f.shape
|
||||
|
||||
gtimer = Timer()
|
||||
if verbose:
|
||||
print( '\nSolving Poisson Eq.')
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# regular grid
|
||||
if self.ptype == 'G':
|
||||
|
||||
#if (self.gdims - fshape).any():
|
||||
if self.gdims != fshape:
|
||||
print( self.gdims, fshape)
|
||||
raise ValueError("Shape of function should match shape of grid")
|
||||
|
||||
# convolution in frequency domain using OpenCV FFT (2D) or numpy FFT (3D)
|
||||
if self.stype == 'F':
|
||||
if self.dim == 2:
|
||||
# # Use OpenCV DFT for 2D (optimized with caching)
|
||||
# # Convert to float32 for OpenCV (use ascontiguousarray for better performance)
|
||||
# f_32 = numpy.ascontiguousarray(f, dtype=numpy.float32)
|
||||
|
||||
# # For 'same' mode convolution, pad to (2*N-1) size
|
||||
# full_shape = tuple(2 * s - 1 for s in f_32.shape)
|
||||
|
||||
# # Initialize or validate cache
|
||||
# cache_valid = (
|
||||
# self._opencv_cache is not None and
|
||||
# self._opencv_cache['fshape'] == fshape and
|
||||
# self._opencv_cache['full_shape'] == full_shape
|
||||
# )
|
||||
|
||||
# if not cache_valid:
|
||||
# # Cache G_fft (G doesn't change, so compute once)
|
||||
# G_32 = numpy.ascontiguousarray(self.G, dtype=numpy.float32)
|
||||
# G_fft = cv2.dft(G_32, flags=cv2.DFT_COMPLEX_OUTPUT)
|
||||
|
||||
# # Pre-compute crop slices for 'same' mode extraction
|
||||
# # For circular convolution at size (2*H-1, 2*W-1), the center corresponds to
|
||||
# # position [H-1, W-1], which matches scipy's fftconvolve mode='same' extraction
|
||||
# starts = [fshape[i] - 1 for i in range(len(fshape))]
|
||||
# slices = tuple(slice(starts[i], starts[i] + fshape[i]) for i in range(len(fshape)))
|
||||
|
||||
# # Pre-allocate reusable buffers
|
||||
# self._opencv_cache = {
|
||||
# 'fshape': fshape,
|
||||
# 'full_shape': full_shape,
|
||||
# 'G_fft': G_fft, # Cached: G doesn't change, so compute FFT once
|
||||
# 'slices': slices, # Cached: crop slices pre-computed
|
||||
# 'gdx_prod': numpy.prod(self.gdx), # Cached: grid spacing product
|
||||
# # Reusable buffers (avoid reallocation on each call)
|
||||
# 'f_pad': numpy.zeros(full_shape, dtype=numpy.float32), # Reused for padding
|
||||
# 'conv_fft': numpy.empty((full_shape[0], full_shape[1], 2), dtype=numpy.float32), # Output for mulSpectrums
|
||||
# }
|
||||
|
||||
# cache = self._opencv_cache
|
||||
|
||||
# # Zero-pad f into pre-allocated buffer (faster than creating new array)
|
||||
# f_pad = cache['f_pad']
|
||||
# f_pad.fill(0.0) # Clear previous data efficiently
|
||||
# f_slices = tuple(slice(0, s) for s in f_32.shape)
|
||||
# f_pad[f_slices] = f_32
|
||||
|
||||
# # Compute FFT of f (cv2.dft returns new array, but we can reuse for mulSpectrums)
|
||||
# f_fft = cv2.dft(f_pad, flags=cv2.DFT_COMPLEX_OUTPUT)
|
||||
|
||||
# # Multiply in frequency domain using OpenCV's optimized mulSpectrums
|
||||
# # This is faster than manual complex multiplication and supports output buffer
|
||||
# # Signature: mulSpectrums(a, b, flags, c=None, conjB=False)
|
||||
# cv2.mulSpectrums(f_fft, cache['G_fft'], cv2.DFT_COMPLEX_OUTPUT,
|
||||
# cache['conv_fft'], False)
|
||||
|
||||
# # Compute inverse FFT (cv2.idft returns new array)
|
||||
# conv_full = cv2.idft(cache['conv_fft'], flags=cv2.DFT_SCALE | cv2.DFT_REAL_OUTPUT)
|
||||
|
||||
# # Extract 'same' mode result (use cached slices, scale in-place)
|
||||
# p = conv_full[cache['slices']].copy()
|
||||
# numpy.multiply(p, cache['gdx_prod'], p)
|
||||
# from scipy import signal
|
||||
p = signal.fftconvolve(f, self.G, mode='same')
|
||||
# Scale by grid spacing
|
||||
numpy.multiply(p, numpy.prod(self.gdx), p)
|
||||
else:
|
||||
# For 3D, use numpy FFT (OpenCV DFT only supports 2D)
|
||||
# from scipy import signal
|
||||
p = signal.fftconvolve(f, self.G, mode='same')
|
||||
# Scale by grid spacing
|
||||
numpy.multiply(p, numpy.prod(self.gdx), p)
|
||||
|
||||
# convolution in spatial domain
|
||||
elif self.stype == 'S':
|
||||
raise SyntaxError('Spatial solver not suggested for structured grids')
|
||||
#p = (self.G).dot(f.reshape(-1)).reshape(fshape)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# unstructured points
|
||||
elif self.ptype == 'P':
|
||||
|
||||
if (self.points.shape[0] != fshape[0]) or (1 != len(fshape)):
|
||||
raise ValueError("Shape of function should be (N,1) with N as the number of points")
|
||||
|
||||
# convolution in spatial domain
|
||||
# Dec 13, 2017 -- pre scaling works for unstructured grids!
|
||||
if self.stype == 'S':
|
||||
p = numpy.multiply(f, self.pvolumes)
|
||||
numpy.dot(self.G, p, p)
|
||||
#numpy.multiply(p, self.pvolumes, p)
|
||||
|
||||
elif self.stype == 'F':
|
||||
raise SyntaxError('Frequncy solver not supported for unstructured points')
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
if verbose:
|
||||
print( 'Poisson solver finished')
|
||||
gtimer.end()
|
||||
|
||||
return p
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GPU-accelerated solve using CuPy
|
||||
# --------------------------------------------------------------------------
|
||||
def solve_gpu(self, f, verbose=False, return_gpu=False):
|
||||
"""
|
||||
GPU-accelerated Poisson solver using CuPy.
|
||||
Falls back to CPU if CuPy is not available.
|
||||
|
||||
Args:
|
||||
f: Input field (numpy array)
|
||||
verbose: Print timing information
|
||||
|
||||
Returns:
|
||||
p: Solution (numpy array on CPU)
|
||||
"""
|
||||
if not CUPY_AVAILABLE:
|
||||
if verbose:
|
||||
print("CuPy not available, falling back to CPU solver")
|
||||
return self.solve(f, verbose)
|
||||
|
||||
if not self.ready:
|
||||
self.prepare(verbose)
|
||||
|
||||
fshape = f.shape
|
||||
|
||||
gtimer = Timer()
|
||||
if verbose:
|
||||
print('\nSolving Poisson Eq. on GPU')
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# regular grid
|
||||
if self.ptype == 'G':
|
||||
if self.gdims != fshape:
|
||||
print(self.gdims, fshape)
|
||||
raise ValueError("Shape of function should match shape of grid")
|
||||
|
||||
# convolution in frequency domain using GPU
|
||||
if self.stype == 'F':
|
||||
# Initialize GPU cache if needed
|
||||
if self._gpu_cache is None:
|
||||
if verbose:
|
||||
print(' - Transferring Green\'s function to GPU...')
|
||||
self._gpu_cache = {
|
||||
'G_gpu': cp.asarray(self.G, dtype=cp.float32),
|
||||
'gdx_prod': numpy.prod(self.gdx)
|
||||
}
|
||||
|
||||
# Transfer input to GPU
|
||||
f_gpu = cp.asarray(f, dtype=cp.float32)
|
||||
|
||||
# Perform FFT convolution on GPU
|
||||
# CuPy's fftconvolve is GPU-accelerated
|
||||
p_gpu = cp_signal.fftconvolve(f_gpu, self._gpu_cache['G_gpu'], mode='same')
|
||||
|
||||
# Scale by grid spacing (on GPU)
|
||||
p_gpu *= self._gpu_cache['gdx_prod']
|
||||
|
||||
if verbose:
|
||||
print('Poisson solver (GPU) finished')
|
||||
gtimer.end()
|
||||
|
||||
# Transfer result back to CPU
|
||||
if return_gpu:
|
||||
return p_gpu # Return CuPy array
|
||||
return cp.asnumpy(p_gpu)
|
||||
# p = cp.asnumpy(p_gpu)
|
||||
|
||||
elif self.stype == 'S':
|
||||
raise SyntaxError('Spatial solver not suggested for structured grids')
|
||||
|
||||
def _solve_gpu_internal(self, f_gpu):
|
||||
"""Internal GPU solve that accepts and returns GPU arrays."""
|
||||
"""
|
||||
GPU-accelerated Poisson solver using CuPy.
|
||||
Falls back to CPU if CuPy is not available.
|
||||
|
||||
Args:
|
||||
f_gpu: Input field (numpy array)
|
||||
|
||||
Returns:
|
||||
p_gpu: Solution (CuPy array)
|
||||
"""
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# regular grid
|
||||
if self.ptype == 'G':
|
||||
|
||||
# convolution in frequency domain using GPU
|
||||
if self.stype == 'F':
|
||||
# Initialize GPU cache if needed
|
||||
if self._gpu_cache is None:
|
||||
self._gpu_cache = {
|
||||
'G_gpu': cp.asarray(self.G, dtype=cp.float32),
|
||||
'gdx_prod': numpy.prod(self.gdx)
|
||||
}
|
||||
|
||||
# Perform FFT convolution on GPU
|
||||
# CuPy's fftconvolve is GPU-accelerated
|
||||
p_gpu = cp_signal.fftconvolve(f_gpu, self._gpu_cache['G_gpu'], mode='same')
|
||||
|
||||
# Scale by grid spacing (on GPU)
|
||||
p_gpu *= self._gpu_cache['gdx_prod']
|
||||
|
||||
return p_gpu # Return CuPy array
|
||||
|
||||
def clear_gpu_cache(self):
|
||||
"""Clear GPU cache to free GPU memory"""
|
||||
if self._gpu_cache is not None:
|
||||
self._gpu_cache = None
|
||||
if CUPY_AVAILABLE:
|
||||
cp.get_default_memory_pool().free_all_blocks()
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# unstructured points - not optimized for GPU
|
||||
# elif self.ptype == 'P':
|
||||
# if verbose:
|
||||
# print(' - GPU solver not optimized for unstructured points, using CPU')
|
||||
# return self.solve(f, verbose)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
# return p
|
||||
|
||||
def _get_opencl_queue(self):
|
||||
"""Create and cache an OpenCL queue on first available GPU."""
|
||||
if self._opencl_queue is not None:
|
||||
return self._opencl_queue
|
||||
|
||||
if not OPENCL_AVAILABLE:
|
||||
raise RuntimeError("OpenCL/pyvkfft is not available")
|
||||
|
||||
ctx = None
|
||||
for platform in cl.get_platforms():
|
||||
for device in platform.get_devices():
|
||||
if device.type & cl.device_type.GPU == 0:
|
||||
continue
|
||||
ctx = cl.Context(devices=(device,))
|
||||
break
|
||||
if ctx is not None:
|
||||
break
|
||||
|
||||
if ctx is None:
|
||||
raise RuntimeError("No OpenCL GPU device found")
|
||||
|
||||
self._opencl_queue = cl.CommandQueue(ctx)
|
||||
self._opencl_context = ctx
|
||||
return self._opencl_queue
|
||||
|
||||
def _get_opencl_crop_kernel(self, queue):
|
||||
"""Create and cache OpenCL kernel for cropping convolution result."""
|
||||
if hasattr(self, '_opencl_crop_kernel') and self._opencl_crop_kernel is not None:
|
||||
return self._opencl_crop_kernel
|
||||
|
||||
# Simple 2D copy kernel
|
||||
kernel_code = """
|
||||
__kernel void crop_and_scale(
|
||||
__global const float* src,
|
||||
__global float* dst,
|
||||
const int src_width,
|
||||
const int dst_width,
|
||||
const int dst_height,
|
||||
const int start_x,
|
||||
const int start_y,
|
||||
const float scale
|
||||
) {
|
||||
int gid_x = get_global_id(0);
|
||||
int gid_y = get_global_id(1);
|
||||
|
||||
if (gid_x < dst_width && gid_y < dst_height) {
|
||||
int src_idx = (start_y + gid_y) * src_width + (start_x + gid_x);
|
||||
int dst_idx = gid_y * dst_width + gid_x;
|
||||
dst[dst_idx] = src[src_idx] * scale;
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
program = cl.Program(self._opencl_context, kernel_code).build()
|
||||
self._opencl_crop_kernel = program.crop_and_scale
|
||||
return self._opencl_crop_kernel
|
||||
|
||||
def _get_opencl_scatter_kernel(self, queue):
|
||||
"""Kernel that copies unpadded input into a zero-padded device buffer (reverse of crop)."""
|
||||
if self._opencl_scatter_kernel is not None:
|
||||
return self._opencl_scatter_kernel
|
||||
|
||||
kernel_code = """
|
||||
__kernel void scatter_to_padded(
|
||||
__global const float* src,
|
||||
__global float* dst,
|
||||
const int src_width,
|
||||
const int src_height,
|
||||
const int dst_width
|
||||
) {
|
||||
int gid_x = get_global_id(0);
|
||||
int gid_y = get_global_id(1);
|
||||
if (gid_x < src_width && gid_y < src_height) {
|
||||
dst[gid_y * dst_width + gid_x] = src[gid_y * src_width + gid_x];
|
||||
}
|
||||
}
|
||||
"""
|
||||
program = cl.Program(self._opencl_context, kernel_code).build()
|
||||
self._opencl_scatter_kernel = program.scatter_to_padded
|
||||
return self._opencl_scatter_kernel
|
||||
|
||||
def _get_opencl_fused_mul_kernel(self, queue):
|
||||
"""Kernel that multiplies two complex spectra by one Green's spectrum in a single pass."""
|
||||
if self._opencl_fused_mul_kernel is not None:
|
||||
return self._opencl_fused_mul_kernel
|
||||
|
||||
kernel_code = """
|
||||
__kernel void fused_complex_mul(
|
||||
__global float2* fft1,
|
||||
__global float2* fft2,
|
||||
__global const float2* g,
|
||||
const int n
|
||||
) {
|
||||
int i = get_global_id(0);
|
||||
if (i < n) {
|
||||
float2 gv = g[i];
|
||||
float2 a = fft1[i];
|
||||
float2 b = fft2[i];
|
||||
fft1[i] = (float2)(a.x * gv.x - a.y * gv.y,
|
||||
a.x * gv.y + a.y * gv.x);
|
||||
fft2[i] = (float2)(b.x * gv.x - b.y * gv.y,
|
||||
b.x * gv.y + b.y * gv.x);
|
||||
}
|
||||
}
|
||||
"""
|
||||
program = cl.Program(self._opencl_context, kernel_code).build()
|
||||
self._opencl_fused_mul_kernel = program.fused_complex_mul
|
||||
return self._opencl_fused_mul_kernel
|
||||
|
||||
def _get_opencl_scatter_batch_kernel(self, queue):
|
||||
"""Batched scatter: copies two unpadded inputs into a stacked zero-padded device buffer."""
|
||||
if self._opencl_scatter_batch_kernel is not None:
|
||||
return self._opencl_scatter_batch_kernel
|
||||
|
||||
kernel_code = """
|
||||
__kernel void scatter_batch(
|
||||
__global const float* src,
|
||||
__global float* dst,
|
||||
const int src_width,
|
||||
const int src_height,
|
||||
const int dst_width,
|
||||
const int src_plane,
|
||||
const int dst_plane
|
||||
) {
|
||||
int gid_x = get_global_id(0);
|
||||
int gid_y = get_global_id(1);
|
||||
int gid_z = get_global_id(2);
|
||||
if (gid_x < src_width && gid_y < src_height) {
|
||||
int src_idx = gid_z * src_plane + gid_y * src_width + gid_x;
|
||||
int dst_idx = gid_z * dst_plane + gid_y * dst_width + gid_x;
|
||||
dst[dst_idx] = src[src_idx];
|
||||
}
|
||||
}
|
||||
"""
|
||||
program = cl.Program(self._opencl_context, kernel_code).build()
|
||||
self._opencl_scatter_batch_kernel = program.scatter_batch
|
||||
return self._opencl_scatter_batch_kernel
|
||||
|
||||
def _get_opencl_crop_batch_kernel(self, queue):
|
||||
"""Batched crop+scale: extracts 'same' region from two stacked full-conv results."""
|
||||
if self._opencl_crop_batch_kernel is not None:
|
||||
return self._opencl_crop_batch_kernel
|
||||
|
||||
kernel_code = """
|
||||
__kernel void crop_batch(
|
||||
__global const float* src,
|
||||
__global float* dst,
|
||||
const int src_width,
|
||||
const int dst_width,
|
||||
const int dst_height,
|
||||
const int start_x,
|
||||
const int start_y,
|
||||
const float scale,
|
||||
const int src_plane,
|
||||
const int dst_plane
|
||||
) {
|
||||
int gid_x = get_global_id(0);
|
||||
int gid_y = get_global_id(1);
|
||||
int gid_z = get_global_id(2);
|
||||
if (gid_x < dst_width && gid_y < dst_height) {
|
||||
int src_idx = gid_z * src_plane + (start_y + gid_y) * src_width + (start_x + gid_x);
|
||||
int dst_idx = gid_z * dst_plane + gid_y * dst_width + gid_x;
|
||||
dst[dst_idx] = src[src_idx] * scale;
|
||||
}
|
||||
}
|
||||
"""
|
||||
program = cl.Program(self._opencl_context, kernel_code).build()
|
||||
self._opencl_crop_batch_kernel = program.crop_batch
|
||||
return self._opencl_crop_batch_kernel
|
||||
|
||||
def _get_opencl_batch_mul_kernel(self, queue):
|
||||
"""Batched complex multiply: multiplies each batch plane by the same Green's spectrum.
|
||||
Uses multiple elements per work item, __constant for broadcast-read Green's spectrum,
|
||||
and fma for better throughput/precision."""
|
||||
if self._opencl_batch_mul_kernel is not None:
|
||||
return self._opencl_batch_mul_kernel
|
||||
|
||||
kernel_code = """
|
||||
__kernel void batch_complex_mul(
|
||||
__global float2* fft_batch,
|
||||
__global const float2* g,
|
||||
const int n
|
||||
) {
|
||||
const int b = get_global_id(0);
|
||||
const int base = b * n;
|
||||
const int i0 = get_global_id(1) * 4;
|
||||
const int i_end = min(i0 + 4, n);
|
||||
for (int i = i0; i < i_end; i++) {
|
||||
float2 gv = g[i];
|
||||
float2 a = fft_batch[base + i];
|
||||
fft_batch[base + i] = (float2)(fma(a.x, gv.x, -(a.y * gv.y)),
|
||||
fma(a.x, gv.y, a.y * gv.x));
|
||||
}
|
||||
}
|
||||
"""
|
||||
program = cl.Program(self._opencl_context, kernel_code).build()
|
||||
self._opencl_batch_mul_kernel = program.batch_complex_mul
|
||||
return self._opencl_batch_mul_kernel
|
||||
|
||||
def _get_opencl_alloc_host_ptr(self):
|
||||
"""Return an allocator that creates buffers with ALLOC_HOST_PTR for pinned host access (reduces H2D/D2H transfer cost)."""
|
||||
ctx = self._opencl_context
|
||||
mf = cl.mem_flags
|
||||
|
||||
def allocator(nbytes):
|
||||
return cl.Buffer(ctx, mf.READ_WRITE | mf.ALLOC_HOST_PTR, size=nbytes)
|
||||
|
||||
return allocator
|
||||
|
||||
@staticmethod
|
||||
def _center_crop_same(full_conv, target_shape):
|
||||
"""Crop full convolution result to scipy 'same' output shape."""
|
||||
starts = [(full_conv.shape[i] - target_shape[i]) // 2 for i in range(len(target_shape))]
|
||||
slices = tuple(slice(starts[i], starts[i] + target_shape[i]) for i in range(len(target_shape)))
|
||||
return full_conv[slices]
|
||||
|
||||
|
||||
def solve_opencl_batch2(self, f1, f2, verbose=False, profile=False):
|
||||
"""
|
||||
Batched OpenCL Poisson solver for two 2D fields.
|
||||
Optimized: scatter H2D (~4x smaller transfer), fused multiply
|
||||
(reads g_fft once), async D2H (one sync instead of two).
|
||||
When profile=True, records and optionally prints per-stage timings.
|
||||
"""
|
||||
if not OPENCL_AVAILABLE:
|
||||
if verbose:
|
||||
print("OpenCL/pyvkfft not available, falling back to CPU solver")
|
||||
return self.solve(f1, verbose), self.solve(f2, verbose)
|
||||
|
||||
# if self.dim != 2:
|
||||
# return self.solve_opencl(f1, verbose, profile), self.solve_opencl(f2, verbose, profile)
|
||||
|
||||
if not self.ready:
|
||||
self.prepare(verbose)
|
||||
|
||||
fshape = f1.shape
|
||||
if fshape != f2.shape:
|
||||
raise ValueError("solve_opencl_batch2 requires inputs with identical shapes")
|
||||
|
||||
if self.ptype != 'G' or self.stype != 'F':
|
||||
return self.solve_opencl(f1, verbose, profile), self.solve_opencl(f2, verbose, profile)
|
||||
|
||||
if self.gdims != fshape:
|
||||
print(self.gdims, fshape)
|
||||
raise ValueError("Shape of function should match shape of grid")
|
||||
|
||||
timing = {'cache_prepare_ms': 0.0}
|
||||
t_total = timeit.default_timer()
|
||||
|
||||
t0 = timeit.default_timer()
|
||||
queue = self._get_opencl_queue()
|
||||
timing['queue_ms'] = (timeit.default_timer() - t0) * 1000.0
|
||||
|
||||
t0 = timeit.default_timer()
|
||||
fshape = f1.shape
|
||||
full_shape = tuple(2 * s - 1 for s in fshape)
|
||||
timing['host_prepare_ms'] = (timeit.default_timer() - t0) * 1000.0
|
||||
|
||||
cache_valid = (
|
||||
self._opencl_cache is not None and
|
||||
self._opencl_cache['fshape'] == fshape and
|
||||
self._opencl_cache['full_shape'] == full_shape and
|
||||
'f_batch_dev' in self._opencl_cache
|
||||
)
|
||||
|
||||
if not cache_valid:
|
||||
t0 = timeit.default_timer()
|
||||
g_host = numpy.asarray(self.G, dtype=numpy.float32)
|
||||
g_pad = numpy.zeros(full_shape, dtype=numpy.float32)
|
||||
g_slices = tuple(slice(0, s) for s in g_host.shape)
|
||||
g_pad[g_slices] = g_host
|
||||
g_dev = cla.to_device(queue, g_pad)
|
||||
g_fft_dev = cl_rfftn(g_dev)
|
||||
if profile:
|
||||
queue.finish()
|
||||
|
||||
# For circular conv at size (2s-1) with G of shape (2s-1) and f of shape s,
|
||||
# alias-free positions are [s-1, 2s-2], matching scipy 'same' extraction.
|
||||
same_starts = [fshape[i] - 1 for i in range(self.dim)]
|
||||
fft_shape = tuple(list(full_shape[:-1]) + [full_shape[-1] // 2 + 1])
|
||||
fft_n_elements = 1
|
||||
for d in fft_shape:
|
||||
fft_n_elements *= d
|
||||
src_plane = fshape[0] * fshape[1]
|
||||
dst_plane = full_shape[0] * full_shape[1]
|
||||
|
||||
# Pinned allocator for staging (H2D) and output (D2H) buffers
|
||||
alloc_pinned = self._get_opencl_alloc_host_ptr()
|
||||
|
||||
self._opencl_cache = {
|
||||
'fshape': fshape,
|
||||
'full_shape': full_shape,
|
||||
'fft_shape': fft_shape,
|
||||
'fft_n_elements': fft_n_elements,
|
||||
'g_fft_dev': g_fft_dev,
|
||||
'same_starts': same_starts,
|
||||
'gdx_prod': numpy.prod(self.gdx),
|
||||
'src_plane': src_plane,
|
||||
'dst_plane': dst_plane,
|
||||
'f_batch_dev': cla.zeros(queue, (2,) + full_shape, dtype=numpy.float32),
|
||||
'host_stage': numpy.empty((2,) + fshape, dtype=numpy.float32),
|
||||
'f_stage_dev': cla.empty(queue, (2,) + fshape, dtype=numpy.float32),
|
||||
'f_fft_batch_dev': cla.empty(queue, (2,) + fft_shape, dtype=numpy.complex64),
|
||||
'conv_batch_dev': cla.empty(queue, (2,) + full_shape, dtype=numpy.float32),
|
||||
'p_batch_dev': cla.empty(queue, (2,) + fshape, dtype=numpy.float32, allocator=alloc_pinned),
|
||||
'p1_host': numpy.empty(fshape, dtype=numpy.float32),
|
||||
'p2_host': numpy.empty(fshape, dtype=numpy.float32),
|
||||
}
|
||||
timing['cache_prepare_ms'] = (timeit.default_timer() - t0) * 1000.0
|
||||
|
||||
c = self._opencl_cache
|
||||
fshape_c = c['fshape']
|
||||
full_shape_c = c['full_shape']
|
||||
|
||||
# H2D: compact transfer of unpadded data, then scatter to padded buffer on device
|
||||
t0 = timeit.default_timer()
|
||||
H, W = fshape_c
|
||||
f1_f32 = f1 if (f1.flags['C_CONTIGUOUS'] and f1.dtype == numpy.float32) else numpy.ascontiguousarray(f1, dtype=numpy.float32)
|
||||
f2_f32 = f2 if (f2.flags['C_CONTIGUOUS'] and f2.dtype == numpy.float32) else numpy.ascontiguousarray(f2, dtype=numpy.float32)
|
||||
host_stage = c['host_stage']
|
||||
numpy.copyto(host_stage[0], f1_f32)
|
||||
numpy.copyto(host_stage[1], f2_f32)
|
||||
f_stage_dev = c['f_stage_dev']
|
||||
cl.enqueue_copy(queue, f_stage_dev.data, host_stage, is_blocking=False)
|
||||
c['f_batch_dev'].fill(0, queue=queue)
|
||||
scatter_batch = self._get_opencl_scatter_batch_kernel(queue)
|
||||
scatter_batch(
|
||||
queue, (W, H, 2), None,
|
||||
f_stage_dev.data, c['f_batch_dev'].data,
|
||||
numpy.int32(W), numpy.int32(H),
|
||||
numpy.int32(full_shape_c[1]),
|
||||
numpy.int32(H * W),
|
||||
numpy.int32(c['dst_plane']),
|
||||
)
|
||||
if profile:
|
||||
queue.finish()
|
||||
timing['h2d_set_ms'] = (timeit.default_timer() - t0) * 1000.0
|
||||
|
||||
# Single batched 2D FFT for both fields (ndim=2 treats dim-0 as batch)
|
||||
t0 = timeit.default_timer()
|
||||
fft_batch = cl_rfftn(c['f_batch_dev'], c['f_fft_batch_dev'], ndim=2)
|
||||
if profile:
|
||||
queue.finish()
|
||||
timing['fft_f_ms'] = (timeit.default_timer() - t0) * 1000.0
|
||||
|
||||
# Batched multiply: each batch plane * same Green's spectrum (4 elements/work item, explicit local size)
|
||||
t0 = timeit.default_timer()
|
||||
n_fft = c['fft_n_elements']
|
||||
global_n = max(1, (n_fft + 3) // 4)
|
||||
local_n = min(256, global_n)
|
||||
global_n = ((global_n + local_n - 1) // local_n) * local_n
|
||||
batch_mul = self._get_opencl_batch_mul_kernel(queue)
|
||||
batch_mul(
|
||||
queue, (2, global_n), (1, local_n),
|
||||
fft_batch.data,
|
||||
c['g_fft_dev'].data,
|
||||
numpy.int32(n_fft)
|
||||
)
|
||||
if profile:
|
||||
queue.finish()
|
||||
timing['mul_ms'] = (timeit.default_timer() - t0) * 1000.0
|
||||
|
||||
# Single batched 2D iFFT for both fields
|
||||
t0 = timeit.default_timer()
|
||||
conv_batch = cl_irfftn(fft_batch, c['conv_batch_dev'], ndim=2)
|
||||
if profile:
|
||||
queue.finish()
|
||||
timing['ifft_ms'] = (timeit.default_timer() - t0) * 1000.0
|
||||
|
||||
# Batch crop + scale on device
|
||||
t0 = timeit.default_timer()
|
||||
crop_batch = self._get_opencl_crop_batch_kernel(queue)
|
||||
same_starts = c['same_starts']
|
||||
crop_batch(
|
||||
queue, (fshape_c[1], fshape_c[0], 2), None,
|
||||
conv_batch.data, c['p_batch_dev'].data,
|
||||
numpy.int32(full_shape_c[1]),
|
||||
numpy.int32(fshape_c[1]),
|
||||
numpy.int32(fshape_c[0]),
|
||||
numpy.int32(same_starts[1]),
|
||||
numpy.int32(same_starts[0]),
|
||||
numpy.float32(c['gdx_prod']),
|
||||
numpy.int32(c['dst_plane']),
|
||||
numpy.int32(c['src_plane'])
|
||||
)
|
||||
if profile:
|
||||
queue.finish()
|
||||
timing['crop_scale_ms'] = (timeit.default_timer() - t0) * 1000.0
|
||||
|
||||
# D2H: map pinned buffer, copy into pre-allocated host arrays (zero allocation)
|
||||
t0 = timeit.default_timer()
|
||||
p1 = c['p1_host']
|
||||
p2 = c['p2_host']
|
||||
mapped_p = c['p_batch_dev'].map_to_host(queue=queue, flags=cl.map_flags.READ, is_blocking=True)
|
||||
numpy.copyto(p1, mapped_p[0])
|
||||
numpy.copyto(p2, mapped_p[1])
|
||||
mapped_p.base.release(queue=queue)
|
||||
timing['d2h_get_ms'] = (timeit.default_timer() - t0) * 1000.0
|
||||
|
||||
timing['total_ms'] = (timeit.default_timer() - t_total) * 1000.0
|
||||
self._opencl_last_timing = timing
|
||||
|
||||
if verbose:
|
||||
print('Poisson solver (OpenCL FFT batch2) finished')
|
||||
if profile:
|
||||
print(
|
||||
"OpenCL batch2 timing [ms]: "
|
||||
f"total={timing['total_ms']:.3f}, "
|
||||
f"queue={timing['queue_ms']:.3f}, "
|
||||
f"cache={timing['cache_prepare_ms']:.3f}, "
|
||||
f"host={timing['host_prepare_ms']:.3f}, "
|
||||
f"h2d={timing['h2d_set_ms']:.3f}, "
|
||||
f"fft={timing['fft_f_ms']:.3f}, "
|
||||
f"mul={timing['mul_ms']:.3f}, "
|
||||
f"ifft={timing['ifft_ms']:.3f}, "
|
||||
f"d2h={timing['d2h_get_ms']:.3f}, "
|
||||
f"crop={timing['crop_scale_ms']:.3f}"
|
||||
)
|
||||
return p1, p2
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import numpy
|
||||
import timeit
|
||||
|
||||
class Timer(object):
|
||||
|
||||
def display(self):
|
||||
|
||||
tseconds = self.etime - self.stime
|
||||
|
||||
print(' [[ elapsed time:', end = "")
|
||||
if tseconds < numpy.power(10.,-6.):
|
||||
print("%.3f micro-sec." % (tseconds*numpy.power(10,6)),end = "")
|
||||
|
||||
elif tseconds < numpy.power(10.,-3.):
|
||||
print("%.3f milli-sec." % (tseconds*numpy.power(10.,3.)),end = "")
|
||||
|
||||
elif tseconds < 60.0:
|
||||
print("%.3f sec." % (tseconds),end = "")
|
||||
|
||||
else:
|
||||
m = int(tseconds/ 60.0)
|
||||
print("%d min. %.3f sec." % (m, (tseconds - 60*m)),end = "")
|
||||
|
||||
print(']]')
|
||||
|
||||
def start(self):
|
||||
self.stime = timeit.default_timer()
|
||||
|
||||
def end(self, print_time=True):
|
||||
self.etime = timeit.default_timer()
|
||||
|
||||
if print_time:
|
||||
self.display()
|
||||
|
||||
def __init__(self):
|
||||
self.start()
|
||||
@@ -19,7 +19,7 @@ from typing import Tuple
|
||||
|
||||
from .Stitcher import Stitcher
|
||||
from .tracker import Tracker
|
||||
from .HHD import nHHD
|
||||
from .hhd import nHHD
|
||||
from . import configs
|
||||
from .get_depth import Contact3D
|
||||
from .backend_availability import (check_cupy_available, check_opencl_available)
|
||||
@@ -202,7 +202,7 @@ class Sensor:
|
||||
Capture and preprocess a frame.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Processed grayscale image
|
||||
np.ndarray | None: Processed image, or None on capture failure.
|
||||
"""
|
||||
# Simple direct read - buffer size of 1 should minimize frame accumulation
|
||||
# Timing measurement helps identify if capture or processing is the bottleneck
|
||||
@@ -265,39 +265,66 @@ class Sensor:
|
||||
def detect_motion(self, threshold=6):
|
||||
|
||||
# detect motion by optical flow magnitude
|
||||
mag = np.linalg.norm(self.flow, axis=1)
|
||||
mag = np.linalg.norm(self.flow, axis=-1)
|
||||
# print(f"mag: {mag.max()}, {mag.min()}, {mag.mean()}")
|
||||
return mag.mean() > threshold
|
||||
|
||||
def compute_deformation(self, decompose=True, check_motion=False, threshold=6, tail_frames = 5):
|
||||
def compute_deformation(self, decompose=True, check_motion=True, threshold=6, tail_frames=10):
|
||||
"""
|
||||
Compute optical flow and decompose into force components.
|
||||
Compute optical flow and optionally decompose it into force components.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Optical flow field
|
||||
Parameters
|
||||
----------
|
||||
decompose : bool
|
||||
If True, run HHD decomposition (`compute_hhd`) after flow computation.
|
||||
check_motion : bool
|
||||
If True, gate decomposition with `detect_motion(threshold)`. When motion
|
||||
falls below threshold, decomposition may still run for up to
|
||||
`tail_frames` additional frames to avoid abrupt force drop-off.
|
||||
threshold : float
|
||||
Motion-detection threshold on mean flow magnitude.
|
||||
tail_frames : int
|
||||
Number of extra non-motion frames allowed to keep running HHD when
|
||||
`check_motion=True`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool | None
|
||||
- `True` : motion is currently detected (`check_motion=True`)
|
||||
- `False` : motion is currently not detected (`check_motion=True`)
|
||||
- `None` : motion gating disabled (`check_motion=False`)
|
||||
"""
|
||||
if self.img is None:
|
||||
raise Exception("Warning: Get image first")
|
||||
|
||||
self.compute_flow()
|
||||
# self.compute_hhd()
|
||||
if check_motion:
|
||||
if self.detect_motion(threshold=threshold):
|
||||
if not check_motion:
|
||||
self.tail_frames_count = 0
|
||||
self.compute_hhd()
|
||||
return True
|
||||
else:
|
||||
if self.tail_frames_count <= tail_frames:
|
||||
self.tail_frames_count +=1
|
||||
self.compute_hhd()
|
||||
return True
|
||||
return False
|
||||
else:
|
||||
if decompose:
|
||||
self.compute_hhd()
|
||||
|
||||
return None
|
||||
# return True
|
||||
|
||||
is_motion = self.detect_motion(threshold=threshold)
|
||||
try:
|
||||
tail_frames = max(0, int(tail_frames))
|
||||
except (TypeError, ValueError):
|
||||
tail_frames = 0
|
||||
|
||||
if is_motion:
|
||||
self.tail_frames_count = 0
|
||||
if decompose:
|
||||
self.compute_hhd()
|
||||
return True
|
||||
|
||||
# No motion this frame. Keep HHD alive briefly if requested, but do not
|
||||
# report this as "motion detected".
|
||||
if decompose and self.tail_frames_count < tail_frames:
|
||||
self.tail_frames_count += 1
|
||||
self.compute_hhd()
|
||||
else:
|
||||
self.tail_frames_count = tail_frames
|
||||
|
||||
return False
|
||||
|
||||
def compute_flow(self):
|
||||
if self.img is None:
|
||||
|
||||
@@ -1,449 +0,0 @@
|
||||
|
||||
"""
|
||||
Prototype tactile 2.5D motion analysis on top of the existing dense flow output.
|
||||
|
||||
This script compares several motion representations derived from optical flow:
|
||||
1. Raw flow arrows / magnitude
|
||||
2. Radial and tangential flow around an estimated contact center
|
||||
3. Differential flow features: divergence, curl, strain magnitude
|
||||
4. Pseudo-height reconstructed from divergence (qualitative only)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
WINDOW_TITLE = "Tactile 2.5D Motion Prototype"
|
||||
|
||||
_local_src = os.path.join(os.path.dirname(__file__), "src")
|
||||
if _local_src not in sys.path:
|
||||
sys.path.insert(0, _local_src)
|
||||
|
||||
import orisys
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--video", "-v", type=str, default="0", help="Camera ID / video file path")
|
||||
parser.add_argument("--config", "-c", type=str, default="./config/ddjx01.json", help="Configuration file path")
|
||||
parser.add_argument("--cal", "-cal", type=str, default="./config/ddjx01.npy", help="Calibration file path")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print detailed sensor logs")
|
||||
parser.add_argument("--no-display", action="store_true", help="Disable OpenCV windows")
|
||||
parser.add_argument("--save", type=str, default="", help="Optional output video path for the comparison panel")
|
||||
parser.add_argument("--max-frames", type=int, default=0, help="Stop after N frames (0 = unlimited)")
|
||||
parser.add_argument("--grid-spacing", type=int, default=20, help="Arrow visualization grid spacing")
|
||||
parser.add_argument("--flow-threshold", type=float, default=2.0, help="Arrow visualization magnitude threshold")
|
||||
parser.add_argument("--print-every", type=int, default=1, help="Print scalar metrics every N frames")
|
||||
parser.add_argument("--panel-size", type=int, default=320, help="Width/height of each panel tile")
|
||||
parser.add_argument("--plane-width-mm", type=float, default=20.0, help="Physical tactile imaging plane width in mm")
|
||||
parser.add_argument("--plane-height-mm", type=float, default=20.0, help="Physical tactile imaging plane height in mm")
|
||||
parser.add_argument("--camera-distance-mm", type=float, default=10.0, help="Distance from camera plane to imaging plane in mm")
|
||||
parser.add_argument("--display-max-width", type=int, default=1800, help="Maximum preview window width in pixels")
|
||||
parser.add_argument("--display-max-height", type=int, default=1000, help="Maximum preview window height in pixels")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def create_sensor(args):
|
||||
try:
|
||||
video_input = int(args.video)
|
||||
except ValueError:
|
||||
video_input = args.video
|
||||
|
||||
return orisys.Sensor(
|
||||
video_input,
|
||||
config_name=args.config,
|
||||
cal_path=args.cal,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
|
||||
|
||||
def ensure_bgr(image):
|
||||
if image.ndim == 2:
|
||||
return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
||||
return image
|
||||
|
||||
|
||||
def resize_panel(image, panel_size):
|
||||
return cv2.resize(ensure_bgr(image), (panel_size, panel_size), interpolation=cv2.INTER_LINEAR)
|
||||
|
||||
|
||||
def fit_display_image(image, max_width, max_height):
|
||||
h, w = image.shape[:2]
|
||||
scale = min(max_width / max(float(w), 1.0), max_height / max(float(h), 1.0), 1.0)
|
||||
if scale >= 0.999:
|
||||
return image
|
||||
new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
|
||||
return cv2.resize(image, new_size, interpolation=cv2.INTER_AREA)
|
||||
|
||||
|
||||
def signed_heatmap(field, colormap=cv2.COLORMAP_TURBO):
|
||||
max_abs = float(np.max(np.abs(field)))
|
||||
if max_abs <= 1e-9:
|
||||
norm = np.full(field.shape, 127, dtype=np.uint8)
|
||||
else:
|
||||
norm = np.clip((field / max_abs + 1.0) * 127.5, 0, 255).astype(np.uint8)
|
||||
return cv2.applyColorMap(norm, colormap)
|
||||
|
||||
|
||||
def magnitude_heatmap(field, colormap=cv2.COLORMAP_TURBO):
|
||||
max_val = float(np.max(field))
|
||||
if max_val <= 1e-9:
|
||||
norm = np.zeros(field.shape, dtype=np.uint8)
|
||||
else:
|
||||
norm = np.clip(field / max_val * 255.0, 0, 255).astype(np.uint8)
|
||||
return cv2.applyColorMap(norm, colormap)
|
||||
|
||||
|
||||
def add_title(image, title):
|
||||
panel = image.copy()
|
||||
cv2.rectangle(panel, (0, 0), (panel.shape[1], 28), (0, 0, 0), -1)
|
||||
cv2.putText(panel, title, (8, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2, cv2.LINE_AA)
|
||||
return panel
|
||||
|
||||
|
||||
def make_panel(image, title, panel_size):
|
||||
return add_title(resize_panel(image, panel_size), title)
|
||||
|
||||
|
||||
def stack_panels(panels, columns=4):
|
||||
filler = np.zeros_like(panels[0])
|
||||
rows = []
|
||||
for idx in range(0, len(panels), columns):
|
||||
row = panels[idx:idx + columns]
|
||||
if len(row) < columns:
|
||||
row = row + [filler] * (columns - len(row))
|
||||
rows.append(cv2.hconcat(row))
|
||||
return cv2.vconcat(rows)
|
||||
|
||||
|
||||
def estimate_contact_center(divergence, flow_magnitude):
|
||||
positive = np.maximum(divergence, 0.0)
|
||||
weights = positive * (flow_magnitude + 1e-6)
|
||||
total = float(weights.sum())
|
||||
h, w = divergence.shape
|
||||
|
||||
if total <= 1e-9:
|
||||
return (w // 2, h // 2)
|
||||
|
||||
ys, xs = np.indices(divergence.shape, dtype=np.float32)
|
||||
cx = int(np.clip((weights * xs).sum() / total, 0, w - 1))
|
||||
cy = int(np.clip((weights * ys).sum() / total, 0, h - 1))
|
||||
return (cx, cy)
|
||||
|
||||
|
||||
def prepare_support_mask(support_map):
|
||||
if support_map is None:
|
||||
return None
|
||||
|
||||
support = np.abs(support_map).astype(np.float32)
|
||||
max_support = float(np.max(support))
|
||||
if max_support > 0.0:
|
||||
support /= max_support
|
||||
support = (support > 0.05).astype(np.float32)
|
||||
if float(np.mean(support)) < 0.002:
|
||||
return None
|
||||
return support
|
||||
|
||||
|
||||
def decompose_radial_tangential(flow, center):
|
||||
h, w = flow.shape[:2]
|
||||
ys, xs = np.indices((h, w), dtype=np.float32)
|
||||
cx, cy = center
|
||||
|
||||
rx = xs - float(cx)
|
||||
ry = ys - float(cy)
|
||||
r_norm = np.sqrt(rx * rx + ry * ry)
|
||||
r_norm = np.where(r_norm < 1e-6, 1.0, r_norm)
|
||||
|
||||
rhat_x = rx / r_norm
|
||||
rhat_y = ry / r_norm
|
||||
that_x = -rhat_y
|
||||
that_y = rhat_x
|
||||
|
||||
u = flow[:, :, 0]
|
||||
v = flow[:, :, 1]
|
||||
radial = u * rhat_x + v * rhat_y
|
||||
tangential = u * that_x + v * that_y
|
||||
return radial, tangential
|
||||
|
||||
|
||||
def compute_flow_jacobian_features(flow):
|
||||
u = flow[:, :, 0]
|
||||
v = flow[:, :, 1]
|
||||
|
||||
du_dy, du_dx = np.gradient(u)
|
||||
dv_dy, dv_dx = np.gradient(v)
|
||||
|
||||
divergence = du_dx + dv_dy
|
||||
curl = dv_dx - du_dy
|
||||
strain_xx = du_dx
|
||||
strain_yy = dv_dy
|
||||
strain_xy = 0.5 * (du_dy + dv_dx)
|
||||
strain_mag = np.sqrt(strain_xx * strain_xx + strain_yy * strain_yy + 2.0 * strain_xy * strain_xy)
|
||||
|
||||
return {
|
||||
"divergence": divergence,
|
||||
"curl": curl,
|
||||
"strain_mag": strain_mag,
|
||||
}
|
||||
|
||||
|
||||
def reconstruct_pseudo_height(divergence, flow_magnitude=None, support_map=None):
|
||||
rhs = divergence.astype(np.float32).copy()
|
||||
|
||||
# No-contact guard: tiny flow noise becomes large low-frequency drift after Poisson solve.
|
||||
if flow_magnitude is not None:
|
||||
mean_mag = float(np.mean(flow_magnitude))
|
||||
max_mag = float(np.max(flow_magnitude))
|
||||
if mean_mag < 0.03 and max_mag < 0.15:
|
||||
return np.zeros_like(rhs, dtype=np.float32)
|
||||
|
||||
support = prepare_support_mask(support_map)
|
||||
if support_map is not None:
|
||||
if support is None:
|
||||
return np.zeros_like(rhs, dtype=np.float32)
|
||||
rhs *= support
|
||||
|
||||
noise_floor = max(float(np.std(rhs)) * 1.5, 1e-4)
|
||||
rhs[np.abs(rhs) < noise_floor] = 0.0
|
||||
if not np.any(rhs):
|
||||
return np.zeros_like(rhs, dtype=np.float32)
|
||||
|
||||
# Zero-pad before FFT solve to reduce periodic wrap-around ghosts, then crop back.
|
||||
h, w = rhs.shape
|
||||
pad_y = max(8, h // 2)
|
||||
pad_x = max(8, w // 2)
|
||||
rhs_padded = np.pad(rhs, ((pad_y, pad_y), (pad_x, pad_x)), mode="constant")
|
||||
rhs_padded = rhs_padded - float(rhs_padded.mean())
|
||||
|
||||
hp, wp = rhs_padded.shape
|
||||
fy = np.fft.fftfreq(hp).reshape(-1, 1)
|
||||
fx = np.fft.fftfreq(wp).reshape(1, -1)
|
||||
denom = (2.0 * np.pi * fx) ** 2 + (2.0 * np.pi * fy) ** 2
|
||||
denom[0, 0] = 1.0
|
||||
|
||||
rhs_hat = np.fft.fft2(rhs_padded)
|
||||
height_hat = -rhs_hat / denom
|
||||
height_hat[0, 0] = 0.0
|
||||
height_full = np.real(np.fft.ifft2(height_hat)).astype(np.float32)
|
||||
height = height_full[pad_y:pad_y + h, pad_x:pad_x + w]
|
||||
height = cv2.GaussianBlur(height, (0, 0), 1.0)
|
||||
return height
|
||||
|
||||
|
||||
def build_visualization(img, flow, center, features, radial, tangential, pseudo_height, depth_map, args):
|
||||
arrows = orisys.util.draw_arrows(
|
||||
img,
|
||||
flow,
|
||||
threshold=args.flow_threshold,
|
||||
grid_spacing=args.grid_spacing,
|
||||
arrow_scale=1.0,
|
||||
)
|
||||
|
||||
contact_view = ensure_bgr(img)
|
||||
cv2.circle(contact_view, center, 6, (0, 255, 0), -1)
|
||||
cv2.circle(contact_view, center, 12, (0, 255, 0), 2)
|
||||
|
||||
panel_images = [
|
||||
(contact_view, "Image / center"),
|
||||
(arrows, "Raw flow"),
|
||||
(signed_heatmap(radial), "Radial flow"),
|
||||
(signed_heatmap(tangential), "Tangential flow"),
|
||||
(signed_heatmap(features["divergence"]), "Divergence"),
|
||||
(signed_heatmap(features["curl"]), "Curl"),
|
||||
(magnitude_heatmap(features["strain_mag"]), "Strain mag"),
|
||||
(signed_heatmap(depth_map), "Depth map"),
|
||||
(signed_heatmap(pseudo_height), "Pseudo height"),
|
||||
]
|
||||
panels = [make_panel(image, title, args.panel_size) for image, title in panel_images]
|
||||
return stack_panels(panels)
|
||||
|
||||
|
||||
def make_writer(path, frame_size, fps):
|
||||
if not path:
|
||||
return None
|
||||
parent = Path(path).parent
|
||||
if parent and not parent.exists():
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
return cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*"mp4v"), fps, frame_size)
|
||||
|
||||
|
||||
def approximate_raw_flow_to_mm(flow_px, plane_width_mm, plane_height_mm, camera_distance_mm, image_shape):
|
||||
h, w = image_shape[:2]
|
||||
fx = camera_distance_mm * w / max(plane_width_mm, 1e-6)
|
||||
fy = camera_distance_mm * h / max(plane_height_mm, 1e-6)
|
||||
flow_mm = flow_px.copy().astype(np.float32)
|
||||
flow_mm[:, :, 0] *= camera_distance_mm / max(fx, 1e-6)
|
||||
flow_mm[:, :, 1] *= camera_distance_mm / max(fy, 1e-6)
|
||||
return flow_mm
|
||||
|
||||
|
||||
def compute_metrics(flow_magnitude, raw_flow_mm, radial, tangential, features, depth_map, pseudo_height):
|
||||
return {
|
||||
"flow_mean": float(flow_magnitude.mean()),
|
||||
"flow_mean_mm": float(np.linalg.norm(raw_flow_mm, axis=2).mean()),
|
||||
"radial_abs_mean": float(np.mean(np.abs(radial))),
|
||||
"tangential_abs_mean": float(np.mean(np.abs(tangential))),
|
||||
"divergence_mean": float(features["divergence"].mean()),
|
||||
"curl_abs_mean": float(np.mean(np.abs(features["curl"]))),
|
||||
"strain_mean": float(features["strain_mag"].mean()),
|
||||
"depth_peak": float(np.max(np.abs(depth_map))),
|
||||
"pseudo_height_peak": float(np.max(np.abs(pseudo_height))),
|
||||
}
|
||||
|
||||
|
||||
def format_metrics(frame_count, fps, metrics):
|
||||
line = (
|
||||
f"frame={frame_count:04d} fps={fps:6.2f} "
|
||||
f"flow_mean={metrics['flow_mean']:.4f} "
|
||||
f"flow_mean_mm={metrics['flow_mean_mm']:.4f} "
|
||||
f"radial_abs={metrics['radial_abs_mean']:.4f} "
|
||||
f"tangential_abs={metrics['tangential_abs_mean']:.4f} "
|
||||
f"div_mean={metrics['divergence_mean']:.4f} "
|
||||
f"curl_abs={metrics['curl_abs_mean']:.4f} "
|
||||
f"strain={metrics['strain_mean']:.4f} "
|
||||
f"depth_peak={metrics['depth_peak']:.4f} "
|
||||
f"height_peak={metrics['pseudo_height_peak']:.4f}"
|
||||
)
|
||||
t = metrics.get("_timings")
|
||||
if t:
|
||||
line += (
|
||||
f" | analyze {t['total']:.2f}ms"
|
||||
f" (mag:{t['magnitude']:.2f} jac:{t['jacobian']:.2f}"
|
||||
f" rad_tan:{t['rad_tan']:.2f} pseudo_h:{t['pseudo_h']:.2f}"
|
||||
f" metrics:{t['metrics']:.2f} vis:{t['visualize']:.2f})"
|
||||
)
|
||||
return line
|
||||
|
||||
|
||||
def read_sensor_frame(sensor):
|
||||
sensor.compute_flow()
|
||||
sensor.compute_hhd()
|
||||
sensor.compute_contact()
|
||||
return sensor.read_info(
|
||||
sensor.info.FPS,
|
||||
sensor.info.VRAW,
|
||||
sensor.info.IMG,
|
||||
sensor.info.DEPTH,
|
||||
)
|
||||
|
||||
|
||||
def analyze_frame(img, flow, depth_map, args):
|
||||
t = time.perf_counter()
|
||||
|
||||
flow_magnitude = np.linalg.norm(flow, axis=2)
|
||||
t_mag = time.perf_counter()
|
||||
|
||||
features = compute_flow_jacobian_features(flow)
|
||||
t_jac = time.perf_counter()
|
||||
|
||||
center = estimate_contact_center(features["divergence"], flow_magnitude)
|
||||
radial, tangential = decompose_radial_tangential(flow, center)
|
||||
t_decomp = time.perf_counter()
|
||||
|
||||
pseudo_height = reconstruct_pseudo_height(
|
||||
features["divergence"],
|
||||
flow_magnitude=flow_magnitude,
|
||||
support_map=depth_map,
|
||||
)
|
||||
t_height = time.perf_counter()
|
||||
|
||||
raw_flow_mm = approximate_raw_flow_to_mm(
|
||||
flow,
|
||||
args.plane_width_mm,
|
||||
args.plane_height_mm,
|
||||
args.camera_distance_mm,
|
||||
img.shape,
|
||||
)
|
||||
metrics = compute_metrics(flow_magnitude, raw_flow_mm, radial, tangential, features, depth_map, pseudo_height)
|
||||
t_metrics = time.perf_counter()
|
||||
|
||||
canvas = build_visualization(
|
||||
img,
|
||||
flow,
|
||||
center,
|
||||
features,
|
||||
radial,
|
||||
tangential,
|
||||
pseudo_height,
|
||||
depth_map,
|
||||
args,
|
||||
)
|
||||
t_vis = time.perf_counter()
|
||||
|
||||
metrics["_timings"] = {
|
||||
"magnitude": (t_mag - t) * 1000,
|
||||
"jacobian": (t_jac - t_mag) * 1000,
|
||||
"rad_tan": (t_decomp - t_jac) * 1000,
|
||||
"pseudo_h": (t_height - t_decomp) * 1000,
|
||||
"metrics": (t_metrics - t_height) * 1000,
|
||||
"visualize": (t_vis - t_metrics) * 1000,
|
||||
"total": (t_vis - t) * 1000,
|
||||
}
|
||||
return canvas, metrics
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
sensor = create_sensor(args)
|
||||
writer = None
|
||||
frame_count = 0
|
||||
print_every = max(args.print_every, 1)
|
||||
|
||||
try:
|
||||
print("\nPress 'q' to quit, 'r' to reset tracking.")
|
||||
|
||||
while True:
|
||||
img = sensor.get_img()
|
||||
if img is None:
|
||||
print("No more frames returned from sensor.")
|
||||
break
|
||||
|
||||
fps, flow, img, depth_map = read_sensor_frame(sensor)
|
||||
canvas, metrics = analyze_frame(img, flow, depth_map, args)
|
||||
|
||||
if writer is None and args.save:
|
||||
writer = make_writer(args.save, (canvas.shape[1], canvas.shape[0]), max(float(fps), 1.0))
|
||||
|
||||
if writer is not None:
|
||||
writer.write(canvas)
|
||||
|
||||
frame_count += 1
|
||||
|
||||
if frame_count % print_every == 0:
|
||||
print(format_metrics(frame_count, fps, metrics))
|
||||
|
||||
if not args.no_display:
|
||||
display_canvas = fit_display_image(
|
||||
canvas,
|
||||
max_width=args.display_max_width,
|
||||
max_height=args.display_max_height,
|
||||
)
|
||||
cv2.imshow(WINDOW_TITLE, display_canvas)
|
||||
key = cv2.waitKey(1) & 0xFF
|
||||
if key == ord("q"):
|
||||
print("\nExiting...")
|
||||
break
|
||||
if key == ord("r"):
|
||||
sensor.reset()
|
||||
print("Sensor reset.")
|
||||
|
||||
if args.max_frames > 0 and frame_count >= args.max_frames:
|
||||
print(f"Reached max_frames={args.max_frames}.")
|
||||
break
|
||||
finally:
|
||||
if writer is not None:
|
||||
writer.release()
|
||||
sensor.disconnect()
|
||||
if not args.no_display:
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,75 +0,0 @@
|
||||
import cv2
|
||||
import time
|
||||
|
||||
# Toggle these to test whether exposure is limiting delivered FPS.
|
||||
USE_MANUAL_EXPOSURE = False
|
||||
MANUAL_EXPOSURE = -6 # Typical V4L2/OpenCV manual range is negative values.
|
||||
|
||||
cap = cv2.VideoCapture(4, cv2.CAP_V4L2)
|
||||
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter.fourcc('M', 'J', 'P', 'G'))
|
||||
cap.set(cv2.CAP_PROP_FPS, 30)
|
||||
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920)
|
||||
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080)
|
||||
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||
|
||||
# For V4L2 in OpenCV: auto-exposure is usually 3, manual is usually 1.
|
||||
if USE_MANUAL_EXPOSURE:
|
||||
ok_auto = cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 1)
|
||||
ok_exp = cap.set(cv2.CAP_PROP_EXPOSURE, MANUAL_EXPOSURE)
|
||||
else:
|
||||
ok_auto = cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 3)
|
||||
ok_exp = True
|
||||
|
||||
actual_fourcc = int(cap.get(cv2.CAP_PROP_FOURCC))
|
||||
print(f"Backend: {cap.getBackendName()}")
|
||||
print(
|
||||
f"FOURCC: {chr(actual_fourcc & 0xFF)}"
|
||||
f"{chr((actual_fourcc >> 8) & 0xFF)}"
|
||||
f"{chr((actual_fourcc >> 16) & 0xFF)}"
|
||||
f"{chr((actual_fourcc >> 24) & 0xFF)}"
|
||||
)
|
||||
print(f"Resolution: {int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))}x{int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))}")
|
||||
print(f"FPS: {cap.get(cv2.CAP_PROP_FPS)}")
|
||||
print(
|
||||
f"Exposure mode: {'manual' if USE_MANUAL_EXPOSURE else 'auto'} "
|
||||
f"(set_auto_ok={ok_auto}, set_exp_ok={ok_exp})"
|
||||
)
|
||||
print(f"AutoExposure(readback): {cap.get(cv2.CAP_PROP_AUTO_EXPOSURE)}")
|
||||
print(f"Exposure(readback): {cap.get(cv2.CAP_PROP_EXPOSURE)}")
|
||||
|
||||
frame_count = 0
|
||||
start = time.perf_counter()
|
||||
while cap.isOpened():
|
||||
t0 = time.perf_counter()
|
||||
ok = cap.grab()
|
||||
t1 = time.perf_counter()
|
||||
if not ok:
|
||||
break
|
||||
|
||||
ret, frame = cap.retrieve()
|
||||
t2 = time.perf_counter()
|
||||
if not ret:
|
||||
continue
|
||||
|
||||
cv2.imshow("webcam", frame)
|
||||
t3 = time.perf_counter()
|
||||
key = cv2.waitKey(1) & 0xFF
|
||||
t4 = time.perf_counter()
|
||||
|
||||
frame_count += 1
|
||||
if frame_count % 30 == 0:
|
||||
elapsed = t4 - start
|
||||
print(
|
||||
f"Frame {frame_count}: "
|
||||
f"grab={1000*(t1-t0):.1f}ms, "
|
||||
f"retrieve={1000*(t2-t1):.1f}ms, "
|
||||
f"imshow={1000*(t3-t2):.1f}ms, "
|
||||
f"waitKey={1000*(t4-t3):.1f}ms, "
|
||||
f"avg_fps={frame_count/elapsed:.1f}"
|
||||
)
|
||||
|
||||
if key == ord('q'):
|
||||
break
|
||||
|
||||
cap.release()
|
||||
cv2.destroyAllWindows()
|
||||
@@ -1,252 +0,0 @@
|
||||
"""
|
||||
HHD Downsample Factor Comparison
|
||||
|
||||
Reads a video file and runs the full pipeline at multiple hhd_downsample factors.
|
||||
Computes per-frame HHD timing and vnormal quality metrics against the baseline (factor=1),
|
||||
then plots the results.
|
||||
|
||||
Usage:
|
||||
python test_downsample.py -v jx01_hex1.5_2.mp4 -c ./config/prototype_of3_720p.json -f 1,2,3,4 -n 50 -w 30
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
|
||||
_local_src = os.path.join(os.path.dirname(__file__), "src")
|
||||
if _local_src not in sys.path:
|
||||
sys.path.insert(0, _local_src)
|
||||
|
||||
import cv2
|
||||
import orisys
|
||||
import numpy as np
|
||||
import time
|
||||
import argparse
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
|
||||
def run_sensor(vid_src, config, cal, downsample, n_warmup, n_measure):
|
||||
"""Run sensor for n_warmup + n_measure frames. Capture timing and vnormal for every measurement frame."""
|
||||
try:
|
||||
vid = int(vid_src)
|
||||
except ValueError:
|
||||
vid = vid_src
|
||||
|
||||
sensor = orisys.Sensor(
|
||||
vid, config_name=config, cal_path=cal,
|
||||
isstitch=True, cuda=False, verbose=False,
|
||||
hhd_downsample=downsample,
|
||||
)
|
||||
|
||||
hhd_times = []
|
||||
vnormals = []
|
||||
frame_idx = 0
|
||||
n_total = n_warmup + n_measure
|
||||
|
||||
while frame_idx < n_total:
|
||||
try:
|
||||
sensor.get_img()
|
||||
except Exception:
|
||||
break
|
||||
|
||||
sensor.compute_flow()
|
||||
|
||||
t0 = time.perf_counter()
|
||||
sensor.compute_hhd()
|
||||
t_hhd = time.perf_counter() - t0
|
||||
|
||||
if frame_idx >= n_warmup:
|
||||
hhd_times.append(t_hhd)
|
||||
vn = sensor.vnormal.copy() if sensor.vnormal is not None else None
|
||||
vnormals.append(vn)
|
||||
|
||||
frame_idx += 1
|
||||
|
||||
sensor.disconnect()
|
||||
return {"hhd_times": hhd_times, "vnormals": vnormals}
|
||||
|
||||
|
||||
def per_frame_rmse(a, b):
|
||||
if a is None or b is None:
|
||||
return float("nan")
|
||||
diff = a.astype(np.float64) - b.astype(np.float64)
|
||||
return float(np.sqrt(np.mean(diff ** 2)))
|
||||
|
||||
|
||||
def per_frame_max_abs_err(a, b):
|
||||
if a is None or b is None:
|
||||
return float("nan")
|
||||
return float(np.max(np.abs(a.astype(np.float64) - b.astype(np.float64))))
|
||||
|
||||
|
||||
def per_frame_cosine_sim(a, b):
|
||||
if a is None or b is None:
|
||||
return float("nan")
|
||||
a64 = a.astype(np.float64)
|
||||
b64 = b.astype(np.float64)
|
||||
dot = np.sum(a64 * b64, axis=-1)
|
||||
mag_a = np.linalg.norm(a64, axis=-1)
|
||||
mag_b = np.linalg.norm(b64, axis=-1)
|
||||
denom = mag_a * mag_b
|
||||
valid = denom > 1e-12
|
||||
if not np.any(valid):
|
||||
return float("nan")
|
||||
return float(np.mean(dot[valid] / denom[valid]))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Compare HHD downsample factors")
|
||||
parser.add_argument("--video", "-v", type=str, default="2", help="Camera ID or video file path")
|
||||
parser.add_argument("--config", "-c", type=str, default="./config/prototype_of3_720p.json")
|
||||
parser.add_argument("--cal", "-cal", type=str, default="./config/ddjx01.npy")
|
||||
parser.add_argument("--factors", "-f", type=str, default="1,2,3", help="Comma-separated downsample factors")
|
||||
parser.add_argument("--warmup", "-w", type=int, default=5, help="Warmup frames (skipped)")
|
||||
parser.add_argument("--frames", "-n", type=int, default=30, help="Measurement frames")
|
||||
parser.add_argument("--output", "-o", type=str, default="downsample_comparison.png", help="Output plot filename")
|
||||
args = parser.parse_args()
|
||||
|
||||
factors = [int(f) for f in args.factors.split(",")]
|
||||
|
||||
print(f"Video: {args.video}")
|
||||
print(f"Config: {args.config}")
|
||||
print(f"Factors: {factors}")
|
||||
print(f"Warmup: {args.warmup} frames, Measure: {args.frames} frames")
|
||||
print()
|
||||
|
||||
# --- collect data ---
|
||||
results = {}
|
||||
for factor in factors:
|
||||
print(f"--- Running factor {factor} ---")
|
||||
results[factor] = run_sensor(
|
||||
args.video, args.config, args.cal,
|
||||
factor, args.warmup, args.frames,
|
||||
)
|
||||
n = len(results[factor]["hhd_times"])
|
||||
if n > 0:
|
||||
arr = np.array(results[factor]["hhd_times"]) * 1000
|
||||
print(f" {n} frames: hhd mean={arr.mean():.2f}ms, median={np.median(arr):.2f}ms, "
|
||||
f"min={arr.min():.2f}ms, max={arr.max():.2f}ms")
|
||||
else:
|
||||
print(" No frames captured")
|
||||
print()
|
||||
|
||||
# --- timing summary table ---
|
||||
print("=" * 72)
|
||||
print(f"{'Factor':<10} {'Mean HHD (ms)':<16} {'Median (ms)':<14} {'Speedup':<10}")
|
||||
print("-" * 72)
|
||||
baseline_median = None
|
||||
for factor in factors:
|
||||
arr = np.array(results[factor]["hhd_times"]) * 1000
|
||||
if len(arr) == 0:
|
||||
continue
|
||||
med = float(np.median(arr))
|
||||
if baseline_median is None:
|
||||
baseline_median = med
|
||||
speedup = baseline_median / med if med > 0 else 0
|
||||
print(f"{factor:<10} {arr.mean():<16.2f} {med:<14.2f} {speedup:<10.2f}x")
|
||||
print("=" * 72)
|
||||
print()
|
||||
|
||||
# --- compute per-frame quality metrics vs baseline (factor=1) ---
|
||||
has_baseline = 1 in factors and len(factors) >= 2
|
||||
metrics = {} # factor -> {"rmse": [...], "max_err": [...], "cosine": [...]}
|
||||
if has_baseline:
|
||||
base_vn = results[1]["vnormals"]
|
||||
n_base = len(base_vn)
|
||||
for factor in factors:
|
||||
if factor == 1:
|
||||
continue
|
||||
test_vn = results[factor]["vnormals"]
|
||||
n_common = min(n_base, len(test_vn))
|
||||
rmse_arr, maxerr_arr, cos_arr = [], [], []
|
||||
for i in range(n_common):
|
||||
rmse_arr.append(per_frame_rmse(base_vn[i], test_vn[i]))
|
||||
maxerr_arr.append(per_frame_max_abs_err(base_vn[i], test_vn[i]))
|
||||
cos_arr.append(per_frame_cosine_sim(base_vn[i], test_vn[i]))
|
||||
metrics[factor] = {
|
||||
"rmse": np.array(rmse_arr),
|
||||
"max_err": np.array(maxerr_arr),
|
||||
"cosine": np.array(cos_arr),
|
||||
}
|
||||
|
||||
# print per-factor summary
|
||||
print(f"{'Factor':<10} {'Mean RMSE':<14} {'Mean MaxErr':<14} {'Mean CosSim':<14}")
|
||||
print("-" * 52)
|
||||
for factor in sorted(metrics):
|
||||
m = metrics[factor]
|
||||
print(f"{factor:<10} {np.nanmean(m['rmse']):<14.6f} {np.nanmean(m['max_err']):<14.4f} "
|
||||
f"{np.nanmean(m['cosine']):<14.6f}")
|
||||
print("=" * 52)
|
||||
print()
|
||||
|
||||
# --- plot ---
|
||||
n_quality_factors = len(metrics)
|
||||
n_plots = 1 + (3 if has_baseline else 0) # timing + (rmse, max_err, cosine)
|
||||
fig, axes = plt.subplots(n_plots, 1, figsize=(12, 3.5 * n_plots), sharex=True)
|
||||
if n_plots == 1:
|
||||
axes = [axes]
|
||||
|
||||
colors = plt.cm.tab10.colors
|
||||
frame_nums = None
|
||||
|
||||
# 1) HHD timing per frame
|
||||
ax = axes[0]
|
||||
for i, factor in enumerate(factors):
|
||||
arr = np.array(results[factor]["hhd_times"]) * 1000
|
||||
frames = np.arange(len(arr))
|
||||
if frame_nums is None:
|
||||
frame_nums = frames
|
||||
ax.plot(frames, arr, label=f"ds={factor}", color=colors[i % len(colors)], linewidth=1)
|
||||
ax.set_ylabel("HHD time (ms)")
|
||||
ax.set_title("Per-frame HHD computation time")
|
||||
ax.legend(loc="upper right")
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
if has_baseline:
|
||||
# 2) RMSE
|
||||
ax = axes[1]
|
||||
for i, factor in enumerate(sorted(metrics)):
|
||||
m = metrics[factor]
|
||||
frames = np.arange(len(m["rmse"]))
|
||||
ci = factors.index(factor) if factor in factors else i
|
||||
ax.plot(frames, m["rmse"], label=f"ds={factor}", color=colors[ci % len(colors)], linewidth=1)
|
||||
ax.set_ylabel("RMSE (vnormal)")
|
||||
ax.set_title("Per-frame RMSE vs baseline (ds=1)")
|
||||
ax.legend(loc="upper right")
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
# 3) Max absolute error
|
||||
ax = axes[2]
|
||||
for i, factor in enumerate(sorted(metrics)):
|
||||
m = metrics[factor]
|
||||
frames = np.arange(len(m["max_err"]))
|
||||
ci = factors.index(factor) if factor in factors else i
|
||||
ax.plot(frames, m["max_err"], label=f"ds={factor}", color=colors[ci % len(colors)], linewidth=1)
|
||||
ax.set_ylabel("Max Abs Error (vnormal)")
|
||||
ax.set_title("Per-frame max absolute error vs baseline (ds=1)")
|
||||
ax.legend(loc="upper right")
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
# 4) Cosine similarity
|
||||
ax = axes[3]
|
||||
for i, factor in enumerate(sorted(metrics)):
|
||||
m = metrics[factor]
|
||||
frames = np.arange(len(m["cosine"]))
|
||||
ci = factors.index(factor) if factor in factors else i
|
||||
ax.plot(frames, m["cosine"], label=f"ds={factor}", color=colors[ci % len(colors)], linewidth=1)
|
||||
ax.set_ylabel("Cosine Similarity")
|
||||
ax.set_title("Per-frame cosine similarity vs baseline (ds=1)")
|
||||
ax.legend(loc="upper right")
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.set_ylim(bottom=min(0.999, ax.get_ylim()[0]))
|
||||
|
||||
axes[-1].set_xlabel("Frame")
|
||||
fig.tight_layout()
|
||||
fig.savefig(args.output, dpi=150)
|
||||
print(f"Plot saved to: {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,81 +0,0 @@
|
||||
import argparse
|
||||
import av
|
||||
import cv2
|
||||
import time
|
||||
|
||||
DEVICE = "/dev/video4"
|
||||
TARGET_FPS = 30
|
||||
WIDTH = 1920
|
||||
HEIGHT = 1080
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Capture from V4L2 via PyAV, optionally save to MP4.")
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
type=str,
|
||||
default="",
|
||||
help="Output MP4 path (e.g. capture.mp4). If empty, only preview, no file.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Open v4l2 device with same style options as ffmpeg CLI
|
||||
container = av.open(
|
||||
DEVICE,
|
||||
format="v4l2",
|
||||
options={
|
||||
"input_format": "mjpeg",
|
||||
"video_size": f"{WIDTH}x{HEIGHT}",
|
||||
"framerate": str(TARGET_FPS),
|
||||
},
|
||||
)
|
||||
|
||||
stream = container.streams.video[0]
|
||||
frame_count = 0
|
||||
t_start = time.perf_counter()
|
||||
|
||||
writer = None
|
||||
if args.output:
|
||||
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||
writer = cv2.VideoWriter(
|
||||
args.output, fourcc, float(TARGET_FPS), (WIDTH, HEIGHT)
|
||||
)
|
||||
if not writer.isOpened():
|
||||
container.close()
|
||||
raise RuntimeError(f"Could not open VideoWriter for {args.output}")
|
||||
print(f"Recording to {args.output} @ {TARGET_FPS} fps, {WIDTH}x{HEIGHT}")
|
||||
|
||||
try:
|
||||
for frame in container.decode(stream):
|
||||
t0 = time.perf_counter()
|
||||
img = frame.to_ndarray(format="bgr24") # OpenCV-compatible
|
||||
t1 = time.perf_counter()
|
||||
|
||||
if writer is not None:
|
||||
writer.write(img)
|
||||
|
||||
cv2.imshow("pyav_cam", img)
|
||||
key = cv2.waitKey(1) & 0xFF
|
||||
t2 = time.perf_counter()
|
||||
|
||||
frame_count += 1
|
||||
if frame_count % 30 == 0:
|
||||
elapsed = t2 - t_start
|
||||
print(
|
||||
f"Frame {frame_count}: convert={1000*(t1-t0):.1f}ms, "
|
||||
f"imshow+waitKey={1000*(t2-t1):.1f}ms, avg_fps={frame_count/elapsed:.1f}"
|
||||
)
|
||||
|
||||
if key == ord("q"):
|
||||
break
|
||||
finally:
|
||||
container.close()
|
||||
if writer is not None:
|
||||
writer.release()
|
||||
print(f"Saved {frame_count} frames to {args.output}")
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,33 +0,0 @@
|
||||
import pyopencl as cl
|
||||
|
||||
# Get all platforms
|
||||
platforms = cl.get_platforms()
|
||||
if not platforms:
|
||||
raise RuntimeError("No OpenCL platforms found")
|
||||
|
||||
# Use the first platform (rusticl in your case)
|
||||
platform = platforms[0]
|
||||
print("Using platform:", platform.name)
|
||||
|
||||
# Get devices for that platform
|
||||
devices = platform.get_devices()
|
||||
if not devices:
|
||||
raise RuntimeError("No devices found on platform")
|
||||
|
||||
for platform in cl.get_platforms():
|
||||
for device in platform.get_devices():
|
||||
print("Device:", device.name)
|
||||
# if device.type & cl.device_type.GPU == 0:
|
||||
# print("Skipping non-GPU device:", device.name)
|
||||
|
||||
# Use the first device (your Adreno GPU)
|
||||
device = devices[0]
|
||||
print("Using device:", device.name)
|
||||
|
||||
# Create a context with that device
|
||||
ctx = cl.Context([device])
|
||||
|
||||
# Create a command queue
|
||||
queue = cl.CommandQueue(ctx)
|
||||
|
||||
print("Context and command queue created successfully!")
|
||||
@@ -1,77 +0,0 @@
|
||||
"""
|
||||
示例 1: 基础用法 - 单传感器同步处理
|
||||
|
||||
本示例演示 orisys 的基本使用流程:
|
||||
1. 初始化传感器(视频文件或摄像头)
|
||||
2. 循环获取图像并计算形变和触碰点
|
||||
3. 读取各类传感器数据
|
||||
4. 可视化显示
|
||||
"""
|
||||
import os
|
||||
import argparse
|
||||
|
||||
import cv2
|
||||
import orisys
|
||||
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--video", "-v", type=str, default="0", help="Camera ID / Video file path")
|
||||
parser.add_argument("--config", "-c", type=str, default="./config/ddjx01.json", help="Configuration file path")
|
||||
parser.add_argument("--cal", "-cal", type=str, default="./config/ddjx01.npy", help="Calibration file path")
|
||||
parser.add_argument("--verbose", "-verbose", type=bool, default=True, help="Print detailed information")
|
||||
parser.add_argument(
|
||||
"--match-brightness",
|
||||
dest="match_brightness",
|
||||
action="store_true",
|
||||
help="Enable per-tile brightness matching",
|
||||
)
|
||||
parser.set_defaults(match_brightness=False)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
vid_src = int(args.video)
|
||||
except ValueError:
|
||||
vid_src = args.video
|
||||
|
||||
# 步骤1:创建传感器对象 (必要步骤),
|
||||
# config_name为配置文件名(内置配置文件名)或路径(自定义配置文件路径)
|
||||
# cal_path为校准文件路径, 每一个设备需要创建/使用一个校准文件
|
||||
sensor = orisys.Sensor(vid_src, config_name=args.config, cal_path=args.cal, verbose=args.verbose)
|
||||
sensor.stitcher.configure_feature_stitch(match_brightness=args.match_brightness)
|
||||
|
||||
print("\n按 'q' 退出程序,按 'r' 重置传感器追踪器")
|
||||
|
||||
while True:
|
||||
# 步骤2:信息提取
|
||||
# 步骤 2.1: 获取并拼接图像 (必要步骤)
|
||||
img = sensor.get_img()
|
||||
if img is None:
|
||||
print("Failed to get image (end of stream or capture error). Exiting.")
|
||||
break
|
||||
|
||||
if img is not None:
|
||||
cv2.imshow("OpenCV Stitch (feature-based)", img)
|
||||
|
||||
# 步骤 2.2: 计算形变场 (必要步骤) — sensor.compute_deformation(...)
|
||||
# 步骤 2.3: 计算触碰点
|
||||
# 步骤 3: 键盘控制
|
||||
key = cv2.waitKey(1) & 0xFF
|
||||
if key == ord("q"):
|
||||
print("\n退出程序...")
|
||||
break
|
||||
if key == ord("r"):
|
||||
# 重置光流追踪器(当追踪漂移时使用)
|
||||
sensor.reset()
|
||||
print("传感器已重置")
|
||||
|
||||
# =========================================================================
|
||||
# 4. 清理资源
|
||||
# =========================================================================
|
||||
sensor.disconnect() # 释放视频源和相关资源
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,213 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
USB camera format and FPS tester for Jetson.
|
||||
|
||||
- Lists available formats/resolutions via v4l2-ctl.
|
||||
- For selected modes, opens the camera with OpenCV and measures per-frame
|
||||
capture time (ms) to show real delivered FPS/latency.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import statistics
|
||||
from typing import List, Tuple
|
||||
|
||||
import cv2
|
||||
|
||||
|
||||
def run_v4l2_list_formats(device: str) -> str:
|
||||
"""Return the raw output of v4l2-ctl --list-formats-ext."""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["v4l2-ctl", f"--device={device}", "--list-formats-ext"],
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
return out
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
print(f"[WARN] Failed to run v4l2-ctl: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def parse_formats(output: str) -> List[Tuple[str, int, int, List[float]]]:
|
||||
"""
|
||||
Parse v4l2-ctl --list-formats-ext output.
|
||||
|
||||
Returns list of (fourcc, width, height, fps_list).
|
||||
"""
|
||||
lines = output.splitlines()
|
||||
formats: List[Tuple[str, int, int, List[float]]] = []
|
||||
|
||||
current_fourcc = None
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line.startswith("[") and "):" in line and "'" in line:
|
||||
# Example: [0]: 'MJPG' (Motion-JPEG, compressed)
|
||||
try:
|
||||
fourcc = line.split("'")[1]
|
||||
current_fourcc = fourcc
|
||||
except Exception:
|
||||
current_fourcc = None
|
||||
elif line.startswith("Size: Discrete") and current_fourcc:
|
||||
# Example: Size: Discrete 1280x720
|
||||
try:
|
||||
parts = line.split()
|
||||
wh = parts[-1]
|
||||
w_str, h_str = wh.split("x")
|
||||
width = int(w_str)
|
||||
height = int(h_str)
|
||||
formats.append((current_fourcc, width, height, []))
|
||||
except Exception:
|
||||
pass
|
||||
elif "Interval:" in line and "fps" in line and formats:
|
||||
# Example: Interval: Discrete 0.033s (30.000 fps)
|
||||
try:
|
||||
fps_part = line.split("(")[-1].split("fps")[0]
|
||||
fps = float(fps_part)
|
||||
fourcc, w, h, fps_list = formats[-1]
|
||||
fps_list.append(fps)
|
||||
formats[-1] = (fourcc, w, h, fps_list)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return formats
|
||||
|
||||
|
||||
def fourcc_to_int(code: str) -> int:
|
||||
return cv2.VideoWriter_fourcc(*code)
|
||||
|
||||
|
||||
def benchmark_mode(
|
||||
device_index: int,
|
||||
fourcc: str,
|
||||
width: int,
|
||||
height: int,
|
||||
target_fps: float,
|
||||
warmup: int = 10,
|
||||
measure_frames: int = 60,
|
||||
) -> None:
|
||||
"""Open a specific mode and measure per-frame capture time."""
|
||||
print(f"\n=== Testing {fourcc} {width}x{height} @ {target_fps:.1f} fps ===")
|
||||
|
||||
cap = cv2.VideoCapture(device_index, cv2.CAP_V4L2)
|
||||
if not cap.isOpened():
|
||||
print(" [ERROR] Failed to open device with V4L2 backend.")
|
||||
return
|
||||
|
||||
cap.set(cv2.CAP_PROP_FOURCC, fourcc_to_int(fourcc))
|
||||
cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
|
||||
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
|
||||
cap.set(cv2.CAP_PROP_FPS, target_fps)
|
||||
|
||||
# Query negotiated parameters
|
||||
negotiated_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
negotiated_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
negotiated_fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
negotiated_fourcc_val = int(cap.get(cv2.CAP_PROP_FOURCC))
|
||||
negotiated_fourcc = "".join(
|
||||
chr((negotiated_fourcc_val >> 8 * i) & 0xFF) for i in range(4)
|
||||
)
|
||||
print(
|
||||
f" Negotiated: {negotiated_w}x{negotiated_h} {negotiated_fourcc} "
|
||||
f"@ {negotiated_fps:.1f} fps (backend={cap.getBackendName()})"
|
||||
)
|
||||
|
||||
# Warm-up frames (to let auto-exposure, buffers, etc. settle)
|
||||
for i in range(warmup):
|
||||
ret, _ = cap.read()
|
||||
if not ret:
|
||||
print(f" [WARN] Warmup frame {i} failed.")
|
||||
time.sleep(0.05)
|
||||
|
||||
times_ms: List[float] = []
|
||||
for i in range(measure_frames):
|
||||
t0 = time.perf_counter()
|
||||
ret, _ = cap.read()
|
||||
t1 = time.perf_counter()
|
||||
if not ret:
|
||||
print(f" [WARN] Capture failed at frame {i}.")
|
||||
break
|
||||
times_ms.append((t1 - t0) * 1000.0)
|
||||
|
||||
cap.release()
|
||||
|
||||
if not times_ms:
|
||||
print(" [ERROR] No successful frames captured.")
|
||||
return
|
||||
|
||||
mean_ms = statistics.mean(times_ms)
|
||||
med_ms = statistics.median(times_ms)
|
||||
min_ms = min(times_ms)
|
||||
max_ms = max(times_ms)
|
||||
eff_fps = 1000.0 / mean_ms
|
||||
|
||||
print(
|
||||
f" Frames measured: {len(times_ms)}\n"
|
||||
f" Capture time (ms): mean={mean_ms:.2f}, median={med_ms:.2f}, "
|
||||
f"min={min_ms:.2f}, max={max_ms:.2f}\n"
|
||||
f" Effective FPS from capture timing: {eff_fps:.2f}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="List USB camera formats and benchmark capture times."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--device",
|
||||
default="/dev/video4",
|
||||
help="V4L2 device path (default: /dev/video0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--index",
|
||||
type=int,
|
||||
default=0,
|
||||
help="OpenCV device index (default: 0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test-modes",
|
||||
nargs="*",
|
||||
default=["MJPG:640x480:30", "MJPG:1280x720:30"],
|
||||
help=(
|
||||
"Modes to benchmark as FOURCC:WxH:FPS. "
|
||||
"Example: MJPG:640x480:30 YUYV:640x480:30"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"== v4l2-ctl formats for {args.device} ==")
|
||||
raw = run_v4l2_list_formats(args.device)
|
||||
if not raw:
|
||||
print("No v4l2-ctl output; skipping format list.")
|
||||
else:
|
||||
print(raw)
|
||||
parsed = parse_formats(raw)
|
||||
if parsed:
|
||||
print("\n== Parsed formats (FOURCC WxH [fps...]) ==")
|
||||
for fourcc, w, h, fps_list in parsed:
|
||||
fps_str = ", ".join(f"{f:.1f}" for f in fps_list) if fps_list else "?"
|
||||
print(f" {fourcc} {w}x{h} [{fps_str}]")
|
||||
|
||||
print("\n== Benchmarking capture time with OpenCV ==")
|
||||
for mode_str in args.test_modes:
|
||||
try:
|
||||
fourcc_s, wh_s, fps_s = mode_str.split(":")
|
||||
w_s, h_s = wh_s.lower().split("x")
|
||||
w = int(w_s)
|
||||
h = int(h_s)
|
||||
fps = float(fps_s)
|
||||
except ValueError:
|
||||
print(f"[WARN] Cannot parse test mode '{mode_str}', expected FOURCC:WxH:FPS")
|
||||
continue
|
||||
|
||||
benchmark_mode(args.index, fourcc_s, w, h, fps)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted by user.", file=sys.stderr)
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import bpy
|
||||
import numpy as np
|
||||
import json
|
||||
import os
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
def export_camera_intrinsics(camera_obj, scene):
|
||||
"""Return camera intrinsic matrix K (3x3) and distortion (none)."""
|
||||
cam = camera_obj.data
|
||||
render = scene.render
|
||||
res_x = render.resolution_x
|
||||
res_y = render.resolution_y
|
||||
|
||||
# Sensor size and focal length
|
||||
if cam.sensor_fit == 'HORIZONTAL':
|
||||
sensor_width = cam.sensor_width
|
||||
sensor_height = sensor_width / (res_x / res_y)
|
||||
else:
|
||||
sensor_height = cam.sensor_height
|
||||
sensor_width = sensor_height * (res_x / res_y)
|
||||
focal = cam.lens # in mm
|
||||
|
||||
# Pixel sizes in mm
|
||||
pixel_width = sensor_width / res_x
|
||||
pixel_height = sensor_height / res_y
|
||||
|
||||
# Intrinsic matrix (OpenCV style: fx, fy in pixels, cx, cy principal point)
|
||||
fx = focal / pixel_width
|
||||
fy = focal / pixel_height
|
||||
cx = res_x / 2.0
|
||||
cy = res_y / 2.0
|
||||
|
||||
K = np.array([[fx, 0, cx],
|
||||
[0, fy, cy],
|
||||
[0, 0, 1]], dtype=np.float64)
|
||||
return K, res_x, res_y
|
||||
|
||||
def export_mesh_data(obj, filepath):
|
||||
"""Export mesh vertices, faces, and UVs to a compressed .npz file."""
|
||||
me = obj.data
|
||||
# Transform vertices to world space
|
||||
vertices = np.array([obj.matrix_world @ v.co for v in me.vertices], dtype=np.float32)
|
||||
faces = np.array([poly.vertices[:] for poly in me.polygons], dtype=np.int32)
|
||||
|
||||
# UVs (per loop, need mapping to vertices for simplicity we'll store per-face)
|
||||
uv_layer = me.uv_layers.active
|
||||
if uv_layer:
|
||||
uv_data = np.array([uv_layer.data[i].uv for i in range(len(uv_layer.data))], dtype=np.float32)
|
||||
loop_to_vertex = np.array([me.loops[i].vertex_index for i in range(len(me.loops))], dtype=np.int32)
|
||||
else:
|
||||
uv_data = np.zeros((0,2), dtype=np.float32)
|
||||
loop_to_vertex = np.zeros(0, dtype=np.int32)
|
||||
|
||||
np.savez_compressed(filepath,
|
||||
vertices=vertices,
|
||||
faces=faces,
|
||||
uv_data=uv_data,
|
||||
loop_to_vertex=loop_to_vertex)
|
||||
print(f"Mesh exported to {filepath}")
|
||||
|
||||
def export_camera_pose(camera_obj, filepath):
|
||||
"""Export camera extrinsics (world-to-camera transform matrix)."""
|
||||
# Camera world matrix: transforms from camera local to world
|
||||
cam_to_world = np.array(camera_obj.matrix_world, dtype=np.float32)
|
||||
# We need world-to-camera transform (inverse)
|
||||
world_to_cam = np.linalg.inv(cam_to_world)
|
||||
np.save(filepath, world_to_cam)
|
||||
print(f"Camera pose exported to {filepath}")
|
||||
|
||||
def export_pixel_to_3d_lookup(obj, camera_obj, scene, filepath):
|
||||
"""
|
||||
Precompute for every pixel the 3D hit point (world coordinates)
|
||||
and save as a 2D array (height, width, 3). This avoids raycasting later.
|
||||
"""
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
render = scene.render
|
||||
res_x = render.resolution_x
|
||||
res_y = render.resolution_y
|
||||
|
||||
# Get camera data
|
||||
cam = camera_obj.data
|
||||
if cam.sensor_fit == 'HORIZONTAL':
|
||||
sensor_width = cam.sensor_width
|
||||
sensor_height = sensor_width / (res_x / res_y)
|
||||
else:
|
||||
sensor_height = cam.sensor_height
|
||||
sensor_width = sensor_height * (res_x / res_y)
|
||||
focal = cam.lens
|
||||
frame_width = sensor_width / focal
|
||||
frame_height = sensor_height / focal
|
||||
|
||||
# Preallocate array (height, width, 3) for XYZ world coordinates
|
||||
xyz_map = np.full((res_y, res_x, 3), np.nan, dtype=np.float32)
|
||||
|
||||
origin = camera_obj.matrix_world.translation
|
||||
cam_rot = camera_obj.matrix_world.to_quaternion()
|
||||
|
||||
for y in range(res_y):
|
||||
# Normalized sensor coordinate v (0=bottom, 1=top)
|
||||
v_ndc = (y + 0.5) / res_y
|
||||
sy = (v_ndc - 0.5) * frame_height
|
||||
for x in range(res_x):
|
||||
u_ndc = (x + 0.5) / res_x
|
||||
sx = (u_ndc - 0.5) * frame_width
|
||||
|
||||
direction_cam = Vector((sx, sy, -1.0))
|
||||
direction_world = cam_rot @ direction_cam
|
||||
direction_world.normalize()
|
||||
|
||||
hit, location, normal, face_index, hit_obj, matrix = scene.ray_cast(
|
||||
depsgraph, origin, direction_world)
|
||||
if hit and hit_obj == obj:
|
||||
xyz_map[y, x, :] = location
|
||||
|
||||
np.savez_compressed(filepath, xyz_map=xyz_map)
|
||||
print(f"Pixel-to-3D lookup saved to {filepath} (shape {res_y}x{res_x})")
|
||||
|
||||
def export_all():
|
||||
# Configuration
|
||||
output_dir = bpy.path.abspath("//exported_data/")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
obj = bpy.context.active_object # or specify name
|
||||
cam = bpy.data.objects.get("Camera")
|
||||
if not cam:
|
||||
cam = bpy.context.scene.camera
|
||||
if not obj or not cam:
|
||||
raise RuntimeError("Select mesh and ensure camera exists.")
|
||||
|
||||
# 1. Mesh data
|
||||
export_mesh_data(obj, os.path.join(output_dir, "mesh.npz"))
|
||||
|
||||
# 2. Camera intrinsics (K matrix) and resolution
|
||||
K, w, h = export_camera_intrinsics(cam, bpy.context.scene)
|
||||
np.savez(os.path.join(output_dir, "camera_intrinsics.npz"), K=K, width=w, height=h)
|
||||
|
||||
# 3. Camera pose (world-to-camera)
|
||||
export_camera_pose(cam, os.path.join(output_dir, "world_to_cam.npy"))
|
||||
|
||||
# 4. Optional: precomputed pixel-to-3D map (fastest)
|
||||
export_pixel_to_3d_lookup(obj, cam, bpy.context.scene,
|
||||
os.path.join(output_dir, "pixel_to_xyz.npz"))
|
||||
|
||||
print("Export complete.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
export_all()
|
||||
@@ -0,0 +1,585 @@
|
||||
"""
|
||||
Texture Generator for Arbitrary 3D Surfaces (Blender Script)
|
||||
- Mode: "patches" (black/white checker patches) or "dots" (circular markers)
|
||||
- Mesh UVs are filled white so that non‑visible parts become white.
|
||||
- Texture background (no‑mesh areas) stays black.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import os
|
||||
import time
|
||||
from mathutils import Vector
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Common Utilities
|
||||
# ----------------------------------------------------------------------
|
||||
def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color):
|
||||
"""
|
||||
Draw a cross (plus sign) in pixel space.
|
||||
cx_uv, cy_uv: centre of cross in UV coordinates (0‑1), origin at bottom‑left.
|
||||
length_px: total length of each arm (pixels).
|
||||
thickness_px: line thickness (odd number gives symmetric result).
|
||||
"""
|
||||
# Convert UV centre to pixel coordinates (y from bottom)
|
||||
cx = int(round(cx_uv * (width - 1)))
|
||||
cy = int(round(cy_uv * (height - 1)))
|
||||
|
||||
half_len = length_px // 2
|
||||
half_thick = thickness_px // 2
|
||||
|
||||
# Horizontal arm
|
||||
x0 = max(0, cx - half_len)
|
||||
x1 = min(width - 1, cx + half_len)
|
||||
y0 = max(0, cy - half_thick)
|
||||
y1 = min(height - 1, cy + half_thick)
|
||||
for y in range(y0, y1 + 1):
|
||||
for x in range(x0, x1 + 1):
|
||||
idx = (y * width + x) * 4
|
||||
pixels[idx:idx+4] = color
|
||||
|
||||
# Vertical arm
|
||||
x0 = max(0, cx - half_thick)
|
||||
x1 = min(width - 1, cx + half_thick)
|
||||
y0 = max(0, cy - half_len)
|
||||
y1 = min(height - 1, cy + half_len)
|
||||
for y in range(y0, y1 + 1):
|
||||
for x in range(x0, x1 + 1):
|
||||
idx = (y * width + x) * 4
|
||||
pixels[idx:idx+4] = color
|
||||
|
||||
def fill_background(pixels, width, height, color):
|
||||
"""Fill the entire image with a solid RGBA color (0-1 floats)."""
|
||||
r, g, b, a = color
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
idx = (y * width + x) * 4
|
||||
pixels[idx] = r
|
||||
pixels[idx+1] = g
|
||||
pixels[idx+2] = b
|
||||
pixels[idx+3] = a
|
||||
|
||||
def fill_polygon(image, pixels, width, height, polygon, color):
|
||||
"""Fill a polygon in the pixel array (flattened, RGBA)."""
|
||||
poly = [(int(round(x)), int(round(y))) for x, y in polygon]
|
||||
if len(poly) < 3:
|
||||
return
|
||||
min_x = max(0, min(p[0] for p in poly))
|
||||
max_x = min(width-1, max(p[0] for p in poly))
|
||||
min_y = max(0, min(p[1] for p in poly))
|
||||
max_y = min(height-1, max(p[1] for p in poly))
|
||||
for y in range(min_y, max_y + 1):
|
||||
intersections = []
|
||||
for i in range(len(poly)):
|
||||
x1, y1 = poly[i]
|
||||
x2, y2 = poly[(i+1) % len(poly)]
|
||||
if y1 == y2:
|
||||
continue
|
||||
if (y1 <= y < y2) or (y2 <= y < y1):
|
||||
t = (y - y1) / (y2 - y1)
|
||||
x = x1 + t * (x2 - x1)
|
||||
intersections.append(x)
|
||||
intersections.sort()
|
||||
for k in range(0, len(intersections), 2):
|
||||
if k+1 >= len(intersections):
|
||||
break
|
||||
x_start = int(round(intersections[k]))
|
||||
x_end = int(round(intersections[k+1]))
|
||||
x_start = max(0, min(width-1, x_start))
|
||||
x_end = max(0, min(width-1, x_end))
|
||||
for x in range(x_start, x_end + 1):
|
||||
idx = (y * width + x) * 4
|
||||
pixels[idx:idx+4] = color
|
||||
|
||||
|
||||
def fill_polygon_value(grid, width, height, polygon, value):
|
||||
"""Fill a polygon into a 2D scalar grid."""
|
||||
poly = [(int(round(x)), int(round(y))) for x, y in polygon]
|
||||
if len(poly) < 3:
|
||||
return
|
||||
min_x = max(0, min(p[0] for p in poly))
|
||||
max_x = min(width - 1, max(p[0] for p in poly))
|
||||
min_y = max(0, min(p[1] for p in poly))
|
||||
max_y = min(height - 1, max(p[1] for p in poly))
|
||||
for y in range(min_y, max_y + 1):
|
||||
intersections = []
|
||||
for i in range(len(poly)):
|
||||
x1, y1 = poly[i]
|
||||
x2, y2 = poly[(i + 1) % len(poly)]
|
||||
if y1 == y2:
|
||||
continue
|
||||
if (y1 <= y < y2) or (y2 <= y < y1):
|
||||
t = (y - y1) / (y2 - y1)
|
||||
x = x1 + t * (x2 - x1)
|
||||
intersections.append(x)
|
||||
intersections.sort()
|
||||
for k in range(0, len(intersections), 2):
|
||||
if k + 1 >= len(intersections):
|
||||
break
|
||||
x_start = int(round(intersections[k]))
|
||||
x_end = int(round(intersections[k + 1]))
|
||||
x_start = max(0, min(width - 1, x_start))
|
||||
x_end = max(0, min(width - 1, x_end))
|
||||
grid[y, x_start:x_end + 1] = value
|
||||
|
||||
def draw_ellipse(pixels, width, height, cx, cy, a, b, angle, color):
|
||||
"""Filled ellipse with semi‑axes a,b (pixels), rotation angle (rad)."""
|
||||
cos_a = np.cos(angle)
|
||||
sin_a = np.sin(angle)
|
||||
rx = abs(a * cos_a) + abs(b * sin_a)
|
||||
ry = abs(a * sin_a) + abs(b * cos_a)
|
||||
x_min = int(max(0, cx - rx))
|
||||
x_max = int(min(width-1, cx + rx))
|
||||
y_min = int(max(0, cy - ry))
|
||||
y_max = int(min(height-1, cy + ry))
|
||||
for y in range(y_min, y_max + 1):
|
||||
for x in range(x_min, x_max + 1):
|
||||
dx = x - cx
|
||||
dy = y - cy
|
||||
u = dx * cos_a + dy * sin_a
|
||||
v = -dx * sin_a + dy * cos_a
|
||||
if (u/a)**2 + (v/b)**2 <= 1.0:
|
||||
idx = (y * width + x) * 4
|
||||
pixels[idx:idx+4] = color
|
||||
|
||||
def get_uv_from_face(obj, face_index, location):
|
||||
"""World‑space location → UV coordinate on the mesh face."""
|
||||
me = obj.data
|
||||
local_loc = obj.matrix_world.inverted() @ location
|
||||
poly = me.polygons[face_index]
|
||||
vert_indices = poly.vertices
|
||||
loop_indices = poly.loop_indices
|
||||
if len(vert_indices) < 3:
|
||||
return None
|
||||
v0 = me.vertices[vert_indices[0]].co
|
||||
v1 = me.vertices[vert_indices[1]].co
|
||||
v2 = me.vertices[vert_indices[2]].co
|
||||
uv_layer = me.uv_layers.active.data
|
||||
uv0 = uv_layer[loop_indices[0]].uv
|
||||
uv1 = uv_layer[loop_indices[1]].uv
|
||||
uv2 = uv_layer[loop_indices[2]].uv
|
||||
def tri_area(a, b, c):
|
||||
return 0.5 * ((b - a).cross(c - a)).length
|
||||
total_area = tri_area(v0, v1, v2)
|
||||
if total_area == 0:
|
||||
return None
|
||||
w0 = tri_area(local_loc, v1, v2) / total_area
|
||||
w1 = tri_area(local_loc, v2, v0) / total_area
|
||||
w2 = 1.0 - w0 - w1
|
||||
uv = w0 * uv0 + w1 * uv1 + w2 * uv2
|
||||
return Vector((uv.x, uv.y))
|
||||
|
||||
def raycast_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height):
|
||||
"""Camera raycast through normalised sensor coordinates (0‑1)."""
|
||||
sx = (u_ndc - 0.5) * frame_width
|
||||
sy = (v_ndc - 0.5) * frame_height
|
||||
direction_cam = Vector((sx, sy, -1.0))
|
||||
direction_world = cam.matrix_world.to_quaternion() @ direction_cam
|
||||
direction_world.normalize()
|
||||
origin = cam.matrix_world.translation
|
||||
hit, location, normal, face_index, hit_obj, matrix = scene.ray_cast(
|
||||
depsgraph, origin, direction_world)
|
||||
if hit and hit_obj == obj:
|
||||
return location, face_index
|
||||
return None, None
|
||||
|
||||
def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1)):
|
||||
"""
|
||||
Fill all triangles of the active UV map with 'color' (default white).
|
||||
This marks every part of the mesh, visible or not, as white.
|
||||
"""
|
||||
me = obj.data
|
||||
uv_layer = me.uv_layers.active
|
||||
if not uv_layer:
|
||||
print("Warning: No active UV layer – mesh rasterization skipped.")
|
||||
return
|
||||
uv_data = uv_layer.data
|
||||
for poly in me.polygons:
|
||||
# Get loop indices for this polygon
|
||||
loops = poly.loop_indices
|
||||
# Build UV polygon
|
||||
uv_poly = []
|
||||
for loop_idx in loops:
|
||||
uv = uv_data[loop_idx].uv
|
||||
uv_poly.append((uv.x, uv.y))
|
||||
# Convert UV (0‑1) to pixel coordinates
|
||||
pixel_poly = [(u * (tex_width - 1), v * (tex_height - 1)) for u, v in uv_poly]
|
||||
fill_polygon(None, pixels, tex_width, tex_height, pixel_poly, color)
|
||||
|
||||
|
||||
def rasterize_mesh_mask(obj, tex_width, tex_height):
|
||||
"""Rasterize the active UV map to a boolean mesh mask."""
|
||||
mask = np.zeros((tex_height, tex_width), dtype=bool)
|
||||
me = obj.data
|
||||
uv_layer = me.uv_layers.active
|
||||
if not uv_layer:
|
||||
return mask
|
||||
uv_data = uv_layer.data
|
||||
for poly in me.polygons:
|
||||
uv_poly = []
|
||||
for loop_idx in poly.loop_indices:
|
||||
uv = uv_data[loop_idx].uv
|
||||
uv_poly.append((uv.x, uv.y))
|
||||
pixel_poly = [(u * (tex_width - 1), v * (tex_height - 1)) for u, v in uv_poly]
|
||||
fill_polygon_value(mask, tex_width, tex_height, pixel_poly, True)
|
||||
return mask
|
||||
|
||||
|
||||
def paint_mask_color(pixels, width, height, mask, color):
|
||||
"""Paint all True pixels of a boolean mask with an RGBA color."""
|
||||
r, g, b, a = color
|
||||
ys, xs = np.nonzero(mask)
|
||||
for y, x in zip(ys, xs):
|
||||
idx = (y * width + x) * 4
|
||||
pixels[idx:idx+4] = (r, g, b, a)
|
||||
|
||||
|
||||
def complete_checker_gaps(pixels, width, height, mesh_mask, patch_state, max_gap_px=6):
|
||||
"""
|
||||
Fill small white slivers inside the mesh by expanding nearby checker colors.
|
||||
Only grows locally (max_gap_px) so non-visible mesh areas remain white.
|
||||
patch_state: -1 = unassigned, 0 = black patch, 1 = white patch
|
||||
"""
|
||||
for _ in range(max_gap_px):
|
||||
candidate = mesh_mask & (patch_state < 0)
|
||||
if not np.any(candidate):
|
||||
break
|
||||
|
||||
black_counts = np.zeros_like(patch_state, dtype=np.uint8)
|
||||
white_counts = np.zeros_like(patch_state, dtype=np.uint8)
|
||||
|
||||
for dy in (-1, 0, 1):
|
||||
for dx in (-1, 0, 1):
|
||||
if dx == 0 and dy == 0:
|
||||
continue
|
||||
|
||||
src_y0 = max(0, -dy)
|
||||
src_y1 = height - max(0, dy)
|
||||
src_x0 = max(0, -dx)
|
||||
src_x1 = width - max(0, dx)
|
||||
dst_y0 = max(0, dy)
|
||||
dst_y1 = height - max(0, -dy)
|
||||
dst_x0 = max(0, dx)
|
||||
dst_x1 = width - max(0, -dx)
|
||||
|
||||
neighbor_vals = patch_state[src_y0:src_y1, src_x0:src_x1]
|
||||
black_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 0)
|
||||
white_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 1)
|
||||
|
||||
fillable = candidate & ((black_counts + white_counts) > 0)
|
||||
if not np.any(fillable):
|
||||
break
|
||||
|
||||
patch_state[fillable] = np.where(
|
||||
black_counts[fillable] > white_counts[fillable], 0, 1
|
||||
).astype(np.int8)
|
||||
|
||||
paint_mask_color(pixels, width, height, patch_state == 0, (0.0, 0.0, 0.0, 1.0))
|
||||
paint_mask_color(pixels, width, height, patch_state == 1, (1.0, 1.0, 1.0, 1.0))
|
||||
|
||||
def checker_patch_color(i, j):
|
||||
"""Return a black/white checker color for patch index (i, j)."""
|
||||
if (i + j) % 2 == 0:
|
||||
return np.array([1.0, 1.0, 1.0], dtype=np.float32)
|
||||
return np.array([0.0, 0.0, 0.0], dtype=np.float32)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Mode 1: Colored Patches
|
||||
# ----------------------------------------------------------------------
|
||||
def generate_patch_texture(
|
||||
obj, cam, scene, depsgraph,
|
||||
nx=40, ny=30, r=0.05,
|
||||
texture_width=2048, texture_height=2048,
|
||||
output_path="//patch_texture.png",uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1),
|
||||
gap_fill_px=6
|
||||
):
|
||||
cam_data = cam.data
|
||||
render = scene.render
|
||||
res_x = render.resolution_x
|
||||
res_y = render.resolution_y
|
||||
if cam_data.sensor_fit == 'HORIZONTAL':
|
||||
sensor_width = cam_data.sensor_width
|
||||
sensor_height = sensor_width / (res_x / res_y)
|
||||
else:
|
||||
sensor_height = cam_data.sensor_height
|
||||
sensor_width = sensor_height * (res_x / res_y)
|
||||
focal = cam_data.lens
|
||||
frame_width = sensor_width / focal
|
||||
frame_height = sensor_height / focal
|
||||
|
||||
patch_uv_polygons = []
|
||||
patch_colors = np.zeros((ny, nx, 3), dtype=np.float32)
|
||||
|
||||
print(f"Raycasting {nx*ny} patches...")
|
||||
for j in range(ny):
|
||||
v_start = j / ny
|
||||
v_end = (j+1) / ny
|
||||
for i in range(nx):
|
||||
u_start = i / nx
|
||||
u_end = (i+1) / nx
|
||||
corners_ndc = [(u_start, v_start), (u_end, v_start),
|
||||
(u_end, v_end), (u_start, v_end)]
|
||||
uv_poly = []
|
||||
for (u_ndc, v_ndc) in corners_ndc:
|
||||
loc, face_idx = raycast_from_camera(scene, depsgraph, cam, obj,
|
||||
u_ndc, v_ndc, frame_width, frame_height)
|
||||
if loc is not None:
|
||||
uv = get_uv_from_face(obj, face_idx, loc)
|
||||
if uv is not None:
|
||||
uv_poly.append(uv)
|
||||
if len(uv_poly) < 3:
|
||||
patch_uv_polygons.append(None)
|
||||
continue
|
||||
patch_uv_polygons.append(uv_poly)
|
||||
|
||||
patch_colors[j, i] = checker_patch_color(i, j)
|
||||
|
||||
# Create image – start with black background
|
||||
img_name = "PatchTexture"
|
||||
if img_name in bpy.data.images:
|
||||
bpy.data.images.remove(bpy.data.images[img_name])
|
||||
image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
|
||||
pixels = list(image.pixels)
|
||||
mesh_mask = rasterize_mesh_mask(obj, texture_width, texture_height)
|
||||
patch_state = np.full((texture_height, texture_width), -1, dtype=np.int8)
|
||||
|
||||
# Fill entire image with black (no mesh areas)
|
||||
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
|
||||
|
||||
# Rasterize all mesh UVs in white (visible + non‑visible parts)
|
||||
paint_mask_color(pixels, texture_width, texture_height, mesh_mask, (1,1,1,1))
|
||||
|
||||
# Now draw the visible patches on top
|
||||
idx = 0
|
||||
for j in range(ny):
|
||||
for i in range(nx):
|
||||
uv_poly = patch_uv_polygons[idx]; idx += 1
|
||||
if uv_poly is None: continue
|
||||
pixel_poly = [(u*(texture_width-1), v*(texture_height-1)) for u,v in uv_poly]
|
||||
color = patch_colors[j,i]
|
||||
rgba = (color[0], color[1], color[2], 1.0)
|
||||
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
|
||||
fill_polygon_value(
|
||||
patch_state,
|
||||
texture_width,
|
||||
texture_height,
|
||||
pixel_poly,
|
||||
1 if np.mean(color) >= 0.5 else 0,
|
||||
)
|
||||
|
||||
# Fill small steep-edge gaps from neighboring checker cells.
|
||||
complete_checker_gaps(
|
||||
pixels, texture_width, texture_height, mesh_mask, patch_state, max_gap_px=gap_fill_px
|
||||
)
|
||||
|
||||
# (after drawing all patches)
|
||||
if uv_centre is not None:
|
||||
draw_cross(pixels, texture_width, texture_height,
|
||||
uv_centre[0], uv_centre[1],
|
||||
cross_length, cross_thickness, cross_color)
|
||||
|
||||
image.pixels[:] = pixels
|
||||
image.update()
|
||||
abs_path = bpy.path.abspath(output_path)
|
||||
image.filepath_raw = abs_path
|
||||
image.file_format = 'PNG'
|
||||
image.save()
|
||||
print(f"Patch texture saved to {abs_path}")
|
||||
return image
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Mode 2: Circular Dot Markers
|
||||
# ----------------------------------------------------------------------
|
||||
def generate_dot_texture(
|
||||
obj, cam, scene, depsgraph,
|
||||
dot_diameter_pixels=12,
|
||||
spacing_pixels=40,
|
||||
dot_color=(0,0,0,1), # RGBA 0-1
|
||||
texture_width=2048, texture_height=2048,
|
||||
output_path="//dot_texture.png",uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1)
|
||||
):
|
||||
render = scene.render
|
||||
res_x = render.resolution_x
|
||||
res_y = render.resolution_y
|
||||
cam_data = cam.data
|
||||
if cam_data.sensor_fit == 'HORIZONTAL':
|
||||
sensor_width = cam_data.sensor_width
|
||||
sensor_height = sensor_width / (res_x / res_y)
|
||||
else:
|
||||
sensor_height = cam_data.sensor_height
|
||||
sensor_width = sensor_height * (res_x / res_y)
|
||||
focal = cam_data.lens
|
||||
frame_width = sensor_width / focal
|
||||
frame_height = sensor_height / focal
|
||||
|
||||
margin = spacing_pixels // 2
|
||||
x_centers = np.arange(margin, res_x, spacing_pixels)
|
||||
y_centers = np.arange(margin, res_y, spacing_pixels)
|
||||
offset_pix = max(2, dot_diameter_pixels // 4)
|
||||
du_ndc = offset_pix / res_x
|
||||
dv_ndc = offset_pix / res_y
|
||||
|
||||
dots = [] # (cx_uv, cy_uv, a_uv, b_uv, angle_uv)
|
||||
print(f"Raycasting {len(x_centers)*len(y_centers)} dot centers...")
|
||||
for y_pix in y_centers:
|
||||
v_ndc = y_pix / res_y
|
||||
for x_pix in x_centers:
|
||||
u_ndc = x_pix / res_x
|
||||
center_loc, face_idx = raycast_from_camera(scene, depsgraph, cam, obj,
|
||||
u_ndc, v_ndc, frame_width, frame_height)
|
||||
if center_loc is None: continue
|
||||
center_uv = get_uv_from_face(obj, face_idx, center_loc)
|
||||
if center_uv is None: continue
|
||||
|
||||
offsets = [
|
||||
( u_ndc + du_ndc, v_ndc),
|
||||
( u_ndc - du_ndc, v_ndc),
|
||||
( u_ndc, v_ndc + dv_ndc),
|
||||
( u_ndc, v_ndc - dv_ndc)
|
||||
]
|
||||
uv_offsets = []
|
||||
valid = True
|
||||
for (u_off, v_off) in offsets:
|
||||
loc_off, f_idx_off = raycast_from_camera(scene, depsgraph, cam, obj,
|
||||
u_off, v_off, frame_width, frame_height)
|
||||
if loc_off is None:
|
||||
valid = False; break
|
||||
uv_off = get_uv_from_face(obj, f_idx_off, loc_off)
|
||||
if uv_off is None:
|
||||
valid = False; break
|
||||
uv_offsets.append(uv_off)
|
||||
if not valid: continue
|
||||
|
||||
scale_u = 1.0 / res_x
|
||||
scale_v = 1.0 / res_y
|
||||
du_dx = (uv_offsets[0] - uv_offsets[1]) / (2 * offset_pix * scale_u)
|
||||
dv_dx = (uv_offsets[2] - uv_offsets[3]) / (2 * offset_pix * scale_v)
|
||||
r_pix = dot_diameter_pixels / 2.0
|
||||
a_uv = r_pix * du_dx.length * scale_u
|
||||
b_uv = r_pix * dv_dx.length * scale_v
|
||||
angle = np.arctan2(du_dx.y, du_dx.x) if du_dx.length > 0 else 0.0
|
||||
dots.append((center_uv.x, center_uv.y, a_uv, b_uv, angle))
|
||||
|
||||
print(f"Found {len(dots)} valid dots.")
|
||||
|
||||
if uv_centre is not None:
|
||||
draw_cross(pixels, texture_width, texture_height,
|
||||
uv_centre[0], uv_centre[1],
|
||||
cross_length, cross_thickness, cross_color)
|
||||
|
||||
# Create image
|
||||
img_name = "DotTexture"
|
||||
if img_name in bpy.data.images:
|
||||
bpy.data.images.remove(bpy.data.images[img_name])
|
||||
image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
|
||||
pixels = list(image.pixels)
|
||||
|
||||
# Black background (no mesh)
|
||||
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
|
||||
# White for all mesh areas
|
||||
rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1))
|
||||
# Draw dots
|
||||
for (cx_uv, cy_uv, a_uv, b_uv, angle_uv) in dots:
|
||||
cx_px = cx_uv * (texture_width - 1)
|
||||
cy_px = cy_uv * (texture_height - 1)
|
||||
a_px = a_uv * texture_width
|
||||
b_px = b_uv * texture_height
|
||||
draw_ellipse(pixels, texture_width, texture_height,
|
||||
cx_px, cy_px, a_px, b_px, angle_uv, dot_color)
|
||||
|
||||
image.pixels[:] = pixels
|
||||
image.update()
|
||||
abs_path = bpy.path.abspath(output_path)
|
||||
image.filepath_raw = abs_path
|
||||
image.file_format = 'PNG'
|
||||
image.save()
|
||||
print(f"Dot texture saved to {abs_path}")
|
||||
return image
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Main
|
||||
# ----------------------------------------------------------------------
|
||||
def main():
|
||||
# ==================== CONFIGURATION ====================
|
||||
MODE = "patches"
|
||||
OBJ_NAME = "FingerMesh"
|
||||
CAM_NAME = "Camera"
|
||||
TEXTURE_WIDTH = 4096
|
||||
TEXTURE_HEIGHT = 4096
|
||||
OUTPUT_PATH = "//" + ("patch_texture.png" if MODE=="patches" else "dot_texture.png")
|
||||
|
||||
# Patch mode
|
||||
PATCH_NX = 40; PATCH_NY = 40; COLOR_R = 0.05
|
||||
# Dot mode
|
||||
DOT_DIAMETER_PIX = 12; DOT_SPACING_PIX = 40; DOT_COLOR = (0,0,0,1)
|
||||
# Cross
|
||||
CROSS_LENGTH_PX = 40
|
||||
CROSS_THICKNESS_PX = 3
|
||||
CROSS_COLOR = (1, 0, 0, 1) # red cross
|
||||
# ======================================================
|
||||
|
||||
start_time = time.time()
|
||||
scene = bpy.context.scene
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
|
||||
if OBJ_NAME:
|
||||
obj = bpy.data.objects.get(OBJ_NAME)
|
||||
else:
|
||||
obj = bpy.context.active_object
|
||||
if obj is None or obj.type != 'MESH':
|
||||
raise ValueError("No valid mesh object found.")
|
||||
|
||||
cam = bpy.data.objects.get(CAM_NAME) if CAM_NAME else scene.camera
|
||||
if cam is None or cam.type != 'CAMERA':
|
||||
raise ValueError("No camera found.")
|
||||
|
||||
if not obj.data.uv_layers.active:
|
||||
obj.data.uv_layers.new(name="UVMap")
|
||||
|
||||
# Compute UV centre
|
||||
uv_layer = obj.data.uv_layers.active
|
||||
uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
|
||||
uv_min = uv_coords.min(axis=0)
|
||||
uv_max = uv_coords.max(axis=0)
|
||||
uv_centre = (uv_min + uv_max) / 2.0
|
||||
print(f"UV centre: ({uv_centre[0]:.4f}, {uv_centre[1]:.4f})")
|
||||
|
||||
|
||||
# Compute UV bounding box centre
|
||||
me = obj.data
|
||||
uv_layer = me.uv_layers.active
|
||||
uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
|
||||
uv_min = uv_coords.min(axis=0)
|
||||
uv_max = uv_coords.max(axis=0)
|
||||
uv_centre = (uv_min + uv_max) / 2.0
|
||||
print(f"UV centre: {uv_centre[0]:.4f}, {uv_centre[1]:.4f}")
|
||||
|
||||
if MODE == "patches":
|
||||
generate_patch_texture(
|
||||
obj, cam, scene, depsgraph,
|
||||
nx=PATCH_NX, ny=PATCH_NY, r=COLOR_R,
|
||||
texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
|
||||
output_path=OUTPUT_PATH,
|
||||
uv_centre=uv_centre,
|
||||
cross_length=CROSS_LENGTH_PX,
|
||||
cross_thickness=CROSS_THICKNESS_PX,
|
||||
cross_color=CROSS_COLOR
|
||||
)
|
||||
elif MODE == "dots":
|
||||
generate_dot_texture(
|
||||
obj, cam, scene, depsgraph,
|
||||
dot_diameter_pixels=DOT_DIAMETER_PIX,
|
||||
spacing_pixels=DOT_SPACING_PIX,
|
||||
dot_color=DOT_COLOR,
|
||||
texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
|
||||
output_path=OUTPUT_PATH,
|
||||
uv_centre=uv_centre,
|
||||
cross_length=CROSS_LENGTH_PX,
|
||||
cross_thickness=CROSS_THICKNESS_PX,
|
||||
cross_color=CROSS_COLOR
|
||||
)
|
||||
|
||||
print(f"Completed in {time.time()-start_time:.2f} seconds.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,177 @@
|
||||
import numpy as np
|
||||
import cv2
|
||||
import os
|
||||
|
||||
def load_exported_data(data_dir):
|
||||
"""Load all exported files."""
|
||||
# Mesh
|
||||
mesh = np.load(os.path.join(data_dir, "mesh.npz"))
|
||||
vertices = mesh['vertices']
|
||||
faces = mesh['faces']
|
||||
uv_data = mesh['uv_data']
|
||||
loop_to_vertex = mesh['loop_to_vertex']
|
||||
|
||||
# Camera intrinsics
|
||||
cam_data = np.load(os.path.join(data_dir, "camera_intrinsics.npz"))
|
||||
K = cam_data['K']
|
||||
width = int(cam_data['width'])
|
||||
height = int(cam_data['height'])
|
||||
|
||||
# Camera pose (world-to-camera)
|
||||
world_to_cam = np.load(os.path.join(data_dir, "world_to_cam.npy"))
|
||||
|
||||
# Precomputed pixel-to-3D map (optional)
|
||||
xyz_map_path = os.path.join(data_dir, "pixel_to_xyz.npz")
|
||||
if os.path.exists(xyz_map_path):
|
||||
xyz_map = np.load(xyz_map_path)['xyz_map']
|
||||
else:
|
||||
xyz_map = None
|
||||
|
||||
return {
|
||||
'vertices': vertices,
|
||||
'faces': faces,
|
||||
'uv_data': uv_data,
|
||||
'loop_to_vertex': loop_to_vertex,
|
||||
'K': K,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'world_to_cam': world_to_cam,
|
||||
'xyz_map': xyz_map
|
||||
}
|
||||
|
||||
def ray_intersect_triangle(ray_origin, ray_dir, v0, v1, v2):
|
||||
"""Möller–Trumbore intersection algorithm. Returns t (distance) or None."""
|
||||
epsilon = 1e-8
|
||||
edge1 = v1 - v0
|
||||
edge2 = v2 - v0
|
||||
h = np.cross(ray_dir, edge2)
|
||||
a = np.dot(edge1, h)
|
||||
if abs(a) < epsilon:
|
||||
return None
|
||||
f = 1.0 / a
|
||||
s = ray_origin - v0
|
||||
u = f * np.dot(s, h)
|
||||
if u < 0.0 or u > 1.0:
|
||||
return None
|
||||
q = np.cross(s, edge1)
|
||||
v = f * np.dot(ray_dir, q)
|
||||
if v < 0.0 or u + v > 1.0:
|
||||
return None
|
||||
t = f * np.dot(edge2, q)
|
||||
if t > epsilon:
|
||||
return t
|
||||
return None
|
||||
|
||||
def pixel_to_3d_ray(u, v, K, world_to_cam):
|
||||
"""
|
||||
Given pixel coordinates (u,v) and camera intrinsics K,
|
||||
return a ray in world coordinates: origin and direction.
|
||||
"""
|
||||
# Invert K to get normalized image coordinates
|
||||
fx = K[0,0]; fy = K[1,1]; cx = K[0,2]; cy = K[1,2]
|
||||
x_norm = (u - cx) / fx
|
||||
y_norm = (v - cy) / fy
|
||||
|
||||
# Ray in camera space: (x_norm, y_norm, 1.0)
|
||||
ray_cam = np.array([x_norm, y_norm, 1.0], dtype=np.float64)
|
||||
|
||||
# Transform to world
|
||||
cam_to_world = np.linalg.inv(world_to_cam)
|
||||
R = cam_to_world[:3, :3]
|
||||
t = cam_to_world[:3, 3]
|
||||
ray_world_dir = R @ ray_cam
|
||||
ray_world_dir /= np.linalg.norm(ray_world_dir)
|
||||
ray_world_origin = t
|
||||
|
||||
return ray_world_origin, ray_world_dir
|
||||
|
||||
def map_image_to_3d(image_path, data, use_precomputed=True):
|
||||
"""
|
||||
Map each pixel of the image to a 3D world point.
|
||||
If use_precomputed and xyz_map exists, return that directly.
|
||||
Otherwise, perform raycasting on the mesh.
|
||||
Returns:
|
||||
points: (H, W, 3) array of 3D points (NaN for misses).
|
||||
colors: (H, W, 3) array of RGB colors from image.
|
||||
"""
|
||||
img = cv2.imread(image_path)
|
||||
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # to RGB
|
||||
h, w = img.shape[:2]
|
||||
|
||||
if use_precomputed and data['xyz_map'] is not None:
|
||||
# Use precomputed map (must match resolution)
|
||||
if h != data['height'] or w != data['width']:
|
||||
raise ValueError("Image resolution does not match precomputed map.")
|
||||
points = data['xyz_map'].copy()
|
||||
colors = img.astype(np.float32) / 255.0
|
||||
return points, colors
|
||||
|
||||
# Otherwise, perform raycasting on the mesh
|
||||
vertices = data['vertices']
|
||||
faces = data['faces']
|
||||
K = data['K']
|
||||
world_to_cam = data['world_to_cam']
|
||||
|
||||
points = np.full((h, w, 3), np.nan, dtype=np.float32)
|
||||
colors = img.astype(np.float32) / 255.0
|
||||
|
||||
# For speed, we can use a bounding volume hierarchy (BVH).
|
||||
# Here we implement a simple brute-force over all faces (slow for large meshes).
|
||||
# In practice, precomputing the xyz_map is strongly recommended.
|
||||
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
origin, direction = pixel_to_3d_ray(x, y, K, world_to_cam)
|
||||
best_t = np.inf
|
||||
best_hit = None
|
||||
for face in faces:
|
||||
v0 = vertices[face[0]]
|
||||
v1 = vertices[face[1]]
|
||||
v2 = vertices[face[2]]
|
||||
t = ray_intersect_triangle(origin, direction, v0, v1, v2)
|
||||
if t is not None and t < best_t:
|
||||
best_t = t
|
||||
best_hit = origin + direction * t
|
||||
if best_hit is not None:
|
||||
points[y, x] = best_hit
|
||||
|
||||
return points, colors
|
||||
|
||||
def save_colored_pointcloud(points, colors, output_path):
|
||||
"""Save as PLY file (colored point cloud)."""
|
||||
# Flatten valid points
|
||||
mask = ~np.isnan(points[:,:,0])
|
||||
valid_points = points[mask].reshape(-1, 3)
|
||||
valid_colors = (colors[mask].reshape(-1, 3) * 255).astype(np.uint8)
|
||||
|
||||
with open(output_path, 'w') as f:
|
||||
f.write("ply\n")
|
||||
f.write("format ascii 1.0\n")
|
||||
f.write(f"element vertex {len(valid_points)}\n")
|
||||
f.write("property float x\n")
|
||||
f.write("property float y\n")
|
||||
f.write("property float z\n")
|
||||
f.write("property uchar red\n")
|
||||
f.write("property uchar green\n")
|
||||
f.write("property uchar blue\n")
|
||||
f.write("end_header\n")
|
||||
for pt, col in zip(valid_points, valid_colors):
|
||||
f.write(f"{pt[0]} {pt[1]} {pt[2]} {col[0]} {col[1]} {col[2]}\n")
|
||||
print(f"Saved point cloud to {output_path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
data_dir = "./exported_data" # Path to exported files
|
||||
image_path = "captured_image.png" # Input camera image
|
||||
output_ply = "mapped_points.ply"
|
||||
|
||||
data = load_exported_data(data_dir)
|
||||
points, colors = map_image_to_3d(image_path, data, use_precomputed=True)
|
||||
save_colored_pointcloud(points, colors, output_ply)
|
||||
|
||||
# Optional: Visualize using Open3D
|
||||
import open3d as o3d
|
||||
pcd = o3d.geometry.PointCloud()
|
||||
mask = ~np.isnan(points[:,:,0])
|
||||
pcd.points = o3d.utility.Vector3dVector(points[mask].reshape(-1,3))
|
||||
pcd.colors = o3d.utility.Vector3dVector(colors[mask].reshape(-1,3))
|
||||
o3d.visualization.draw_geometries([pcd])
|
||||
@@ -0,0 +1,528 @@
|
||||
"""
|
||||
Texture Generator for Arbitrary 3D Surfaces (Blender Script)
|
||||
- Mode: "patches" (colored patches) or "dots" (circular markers)
|
||||
- Mesh UVs are filled white so that non‑visible parts become white.
|
||||
- Texture background (no‑mesh areas) stays black.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import os
|
||||
import time
|
||||
from mathutils import Vector
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Common Utilities
|
||||
# ----------------------------------------------------------------------
|
||||
def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color):
|
||||
"""
|
||||
Draw a cross (plus sign) in pixel space.
|
||||
cx_uv, cy_uv: centre of cross in UV coordinates (0‑1), origin at bottom‑left.
|
||||
length_px: total length of each arm (pixels).
|
||||
thickness_px: line thickness (odd number gives symmetric result).
|
||||
"""
|
||||
# Convert UV centre to pixel coordinates (y from bottom)
|
||||
cx = int(round(cx_uv * (width - 1)))
|
||||
cy = int(round(cy_uv * (height - 1)))
|
||||
|
||||
half_len = length_px // 2
|
||||
half_thick = thickness_px // 2
|
||||
|
||||
# Horizontal arm
|
||||
x0 = max(0, cx - half_len)
|
||||
x1 = min(width - 1, cx + half_len)
|
||||
y0 = max(0, cy - half_thick)
|
||||
y1 = min(height - 1, cy + half_thick)
|
||||
for y in range(y0, y1 + 1):
|
||||
for x in range(x0, x1 + 1):
|
||||
idx = (y * width + x) * 4
|
||||
pixels[idx:idx+4] = color
|
||||
|
||||
# Vertical arm
|
||||
x0 = max(0, cx - half_thick)
|
||||
x1 = min(width - 1, cx + half_thick)
|
||||
y0 = max(0, cy - half_len)
|
||||
y1 = min(height - 1, cy + half_len)
|
||||
for y in range(y0, y1 + 1):
|
||||
for x in range(x0, x1 + 1):
|
||||
idx = (y * width + x) * 4
|
||||
pixels[idx:idx+4] = color
|
||||
|
||||
def fill_background(pixels, width, height, color):
|
||||
"""Fill the entire image with a solid RGBA color (0-1 floats)."""
|
||||
r, g, b, a = color
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
idx = (y * width + x) * 4
|
||||
pixels[idx] = r
|
||||
pixels[idx+1] = g
|
||||
pixels[idx+2] = b
|
||||
pixels[idx+3] = a
|
||||
|
||||
def fill_polygon(image, pixels, width, height, polygon, color):
|
||||
"""Fill a polygon in the pixel array (flattened, RGBA)."""
|
||||
poly = [(int(round(x)), int(round(y))) for x, y in polygon]
|
||||
if len(poly) < 3:
|
||||
return
|
||||
min_x = max(0, min(p[0] for p in poly))
|
||||
max_x = min(width-1, max(p[0] for p in poly))
|
||||
min_y = max(0, min(p[1] for p in poly))
|
||||
max_y = min(height-1, max(p[1] for p in poly))
|
||||
for y in range(min_y, max_y + 1):
|
||||
intersections = []
|
||||
for i in range(len(poly)):
|
||||
x1, y1 = poly[i]
|
||||
x2, y2 = poly[(i+1) % len(poly)]
|
||||
if y1 == y2:
|
||||
continue
|
||||
if (y1 <= y < y2) or (y2 <= y < y1):
|
||||
t = (y - y1) / (y2 - y1)
|
||||
x = x1 + t * (x2 - x1)
|
||||
intersections.append(x)
|
||||
intersections.sort()
|
||||
for k in range(0, len(intersections), 2):
|
||||
if k+1 >= len(intersections):
|
||||
break
|
||||
x_start = int(round(intersections[k]))
|
||||
x_end = int(round(intersections[k+1]))
|
||||
x_start = max(0, min(width-1, x_start))
|
||||
x_end = max(0, min(width-1, x_end))
|
||||
for x in range(x_start, x_end + 1):
|
||||
idx = (y * width + x) * 4
|
||||
pixels[idx:idx+4] = color
|
||||
|
||||
def draw_ellipse(pixels, width, height, cx, cy, a, b, angle, color):
|
||||
"""Filled ellipse with semi‑axes a,b (pixels), rotation angle (rad)."""
|
||||
cos_a = np.cos(angle)
|
||||
sin_a = np.sin(angle)
|
||||
rx = abs(a * cos_a) + abs(b * sin_a)
|
||||
ry = abs(a * sin_a) + abs(b * cos_a)
|
||||
x_min = int(max(0, cx - rx))
|
||||
x_max = int(min(width-1, cx + rx))
|
||||
y_min = int(max(0, cy - ry))
|
||||
y_max = int(min(height-1, cy + ry))
|
||||
for y in range(y_min, y_max + 1):
|
||||
for x in range(x_min, x_max + 1):
|
||||
dx = x - cx
|
||||
dy = y - cy
|
||||
u = dx * cos_a + dy * sin_a
|
||||
v = -dx * sin_a + dy * cos_a
|
||||
if (u/a)**2 + (v/b)**2 <= 1.0:
|
||||
idx = (y * width + x) * 4
|
||||
pixels[idx:idx+4] = color
|
||||
|
||||
def get_uv_from_face(obj, face_index, location):
|
||||
"""World‑space location → UV coordinate on the mesh face."""
|
||||
me = obj.data
|
||||
local_loc = obj.matrix_world.inverted() @ location
|
||||
poly = me.polygons[face_index]
|
||||
vert_indices = poly.vertices
|
||||
loop_indices = poly.loop_indices
|
||||
if len(vert_indices) < 3:
|
||||
return None
|
||||
v0 = me.vertices[vert_indices[0]].co
|
||||
v1 = me.vertices[vert_indices[1]].co
|
||||
v2 = me.vertices[vert_indices[2]].co
|
||||
uv_layer = me.uv_layers.active.data
|
||||
uv0 = uv_layer[loop_indices[0]].uv
|
||||
uv1 = uv_layer[loop_indices[1]].uv
|
||||
uv2 = uv_layer[loop_indices[2]].uv
|
||||
def tri_area(a, b, c):
|
||||
return 0.5 * ((b - a).cross(c - a)).length
|
||||
total_area = tri_area(v0, v1, v2)
|
||||
if total_area == 0:
|
||||
return None
|
||||
w0 = tri_area(local_loc, v1, v2) / total_area
|
||||
w1 = tri_area(local_loc, v2, v0) / total_area
|
||||
w2 = 1.0 - w0 - w1
|
||||
uv = w0 * uv0 + w1 * uv1 + w2 * uv2
|
||||
return Vector((uv.x, uv.y))
|
||||
|
||||
def raycast_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height):
|
||||
"""Camera raycast through normalised sensor coordinates (0‑1)."""
|
||||
sx = (u_ndc - 0.5) * frame_width
|
||||
sy = (v_ndc - 0.5) * frame_height
|
||||
direction_cam = Vector((sx, sy, -1.0))
|
||||
direction_world = cam.matrix_world.to_quaternion() @ direction_cam
|
||||
direction_world.normalize()
|
||||
origin = cam.matrix_world.translation
|
||||
hit, location, normal, face_index, hit_obj, matrix = scene.ray_cast(
|
||||
depsgraph, origin, direction_world)
|
||||
if hit and hit_obj == obj:
|
||||
return location, face_index
|
||||
return None, None
|
||||
|
||||
def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1)):
|
||||
"""
|
||||
Fill all triangles of the active UV map with 'color' (default white).
|
||||
This marks every part of the mesh, visible or not, as white.
|
||||
"""
|
||||
me = obj.data
|
||||
uv_layer = me.uv_layers.active
|
||||
if not uv_layer:
|
||||
print("Warning: No active UV layer – mesh rasterization skipped.")
|
||||
return
|
||||
uv_data = uv_layer.data
|
||||
for poly in me.polygons:
|
||||
# Get loop indices for this polygon
|
||||
loops = poly.loop_indices
|
||||
# Build UV polygon
|
||||
uv_poly = []
|
||||
for loop_idx in loops:
|
||||
uv = uv_data[loop_idx].uv
|
||||
uv_poly.append((uv.x, uv.y))
|
||||
# Convert UV (0‑1) to pixel coordinates
|
||||
pixel_poly = [(u * (tex_width - 1), v * (tex_height - 1)) for u, v in uv_poly]
|
||||
fill_polygon(None, pixels, tex_width, tex_height, pixel_poly, color)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Color generation (for patch mode)
|
||||
# ----------------------------------------------------------------------
|
||||
def merge_intervals(intervals):
|
||||
if not intervals: return []
|
||||
intervals.sort(key=lambda x: x[0])
|
||||
merged = [intervals[0]]
|
||||
for current in intervals[1:]:
|
||||
last = merged[-1]
|
||||
if current[0] <= last[1]:
|
||||
merged[-1] = (last[0], max(last[1], current[1]))
|
||||
else:
|
||||
merged.append(current)
|
||||
return merged
|
||||
|
||||
def get_allowed_intervals(neighbor_values, delta):
|
||||
forbidden = []
|
||||
for c in neighbor_values:
|
||||
low = max(0, c - delta)
|
||||
high = min(1, c + delta)
|
||||
forbidden.append((low, high))
|
||||
merged = merge_intervals(forbidden)
|
||||
allowed = []
|
||||
if merged:
|
||||
if merged[0][0] > 0:
|
||||
allowed.append((0, merged[0][0]))
|
||||
for i in range(1, len(merged)):
|
||||
allowed.append((merged[i-1][1], merged[i][0]))
|
||||
if merged[-1][1] < 1:
|
||||
allowed.append((merged[-1][1], 1))
|
||||
else:
|
||||
allowed.append((0, 1))
|
||||
return allowed
|
||||
|
||||
def sample_from_allowed(allowed):
|
||||
lengths = [end - start for start, end in allowed]
|
||||
total_length = sum(lengths)
|
||||
if total_length <= 0: return None
|
||||
point = np.random.rand() * total_length
|
||||
cumulative = 0
|
||||
for (start, end), length in zip(allowed, lengths):
|
||||
cumulative += length
|
||||
if point <= cumulative:
|
||||
return np.random.uniform(start, end)
|
||||
return allowed[-1][0]
|
||||
|
||||
def sample_channel(neighbor_values, delta):
|
||||
allowed = get_allowed_intervals(neighbor_values, delta)
|
||||
if not allowed: return None
|
||||
return sample_from_allowed(allowed)
|
||||
|
||||
def generate_valid_color(neighbors, r):
|
||||
delta = np.sqrt(r)
|
||||
color = np.zeros(3)
|
||||
for channel in range(3):
|
||||
neighbor_channel = [n[channel] for n in neighbors]
|
||||
sampled = sample_channel(neighbor_channel, delta)
|
||||
if sampled is None:
|
||||
sampled = np.random.rand()
|
||||
color[channel] = sampled
|
||||
return color
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Mode 1: Colored Patches
|
||||
# ----------------------------------------------------------------------
|
||||
def generate_patch_texture(
|
||||
obj, cam, scene, depsgraph,
|
||||
nx=40, ny=30, r=0.05,
|
||||
texture_width=2048, texture_height=2048,
|
||||
output_path="//patch_texture.png",uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1)
|
||||
):
|
||||
cam_data = cam.data
|
||||
render = scene.render
|
||||
res_x = render.resolution_x
|
||||
res_y = render.resolution_y
|
||||
if cam_data.sensor_fit == 'HORIZONTAL':
|
||||
sensor_width = cam_data.sensor_width
|
||||
sensor_height = sensor_width / (res_x / res_y)
|
||||
else:
|
||||
sensor_height = cam_data.sensor_height
|
||||
sensor_width = sensor_height * (res_x / res_y)
|
||||
focal = cam_data.lens
|
||||
frame_width = sensor_width / focal
|
||||
frame_height = sensor_height / focal
|
||||
|
||||
patch_uv_polygons = []
|
||||
patch_colors = np.zeros((ny, nx, 3), dtype=np.float32)
|
||||
|
||||
print(f"Raycasting {nx*ny} patches...")
|
||||
for j in range(ny):
|
||||
v_start = j / ny
|
||||
v_end = (j+1) / ny
|
||||
for i in range(nx):
|
||||
u_start = i / nx
|
||||
u_end = (i+1) / nx
|
||||
corners_ndc = [(u_start, v_start), (u_end, v_start),
|
||||
(u_end, v_end), (u_start, v_end)]
|
||||
uv_poly = []
|
||||
for (u_ndc, v_ndc) in corners_ndc:
|
||||
loc, face_idx = raycast_from_camera(scene, depsgraph, cam, obj,
|
||||
u_ndc, v_ndc, frame_width, frame_height)
|
||||
if loc is not None:
|
||||
uv = get_uv_from_face(obj, face_idx, loc)
|
||||
if uv is not None:
|
||||
uv_poly.append(uv)
|
||||
if len(uv_poly) < 3:
|
||||
patch_uv_polygons.append(None)
|
||||
continue
|
||||
patch_uv_polygons.append(uv_poly)
|
||||
|
||||
neighbors = []
|
||||
if j > 0: neighbors.append(patch_colors[j-1, i])
|
||||
if i > 0: neighbors.append(patch_colors[j, i-1])
|
||||
color = np.random.rand(3) if len(neighbors)==0 else generate_valid_color(neighbors, r)
|
||||
patch_colors[j, i] = color
|
||||
|
||||
# Create image – start with black background
|
||||
img_name = "PatchTexture"
|
||||
if img_name in bpy.data.images:
|
||||
bpy.data.images.remove(bpy.data.images[img_name])
|
||||
image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
|
||||
pixels = list(image.pixels)
|
||||
|
||||
# Fill entire image with black (no mesh areas)
|
||||
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
|
||||
|
||||
# Rasterize all mesh UVs in white (visible + non‑visible parts)
|
||||
rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1))
|
||||
|
||||
# Now draw the visible patches on top
|
||||
idx = 0
|
||||
for j in range(ny):
|
||||
for i in range(nx):
|
||||
uv_poly = patch_uv_polygons[idx]; idx += 1
|
||||
if uv_poly is None: continue
|
||||
pixel_poly = [(u*(texture_width-1), v*(texture_height-1)) for u,v in uv_poly]
|
||||
color = patch_colors[j,i]
|
||||
rgba = (color[0], color[1], color[2], 1.0)
|
||||
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
|
||||
|
||||
# (after drawing all patches)
|
||||
if uv_centre is not None:
|
||||
draw_cross(pixels, texture_width, texture_height,
|
||||
uv_centre[0], uv_centre[1],
|
||||
cross_length, cross_thickness, cross_color)
|
||||
|
||||
image.pixels[:] = pixels
|
||||
image.update()
|
||||
abs_path = bpy.path.abspath(output_path)
|
||||
image.filepath_raw = abs_path
|
||||
image.file_format = 'PNG'
|
||||
image.save()
|
||||
print(f"Patch texture saved to {abs_path}")
|
||||
return image
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Mode 2: Circular Dot Markers
|
||||
# ----------------------------------------------------------------------
|
||||
def generate_dot_texture(
|
||||
obj, cam, scene, depsgraph,
|
||||
dot_diameter_pixels=12,
|
||||
spacing_pixels=40,
|
||||
dot_color=(0,0,0,1), # RGBA 0-1
|
||||
texture_width=2048, texture_height=2048,
|
||||
output_path="//dot_texture.png",uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1)
|
||||
):
|
||||
render = scene.render
|
||||
res_x = render.resolution_x
|
||||
res_y = render.resolution_y
|
||||
cam_data = cam.data
|
||||
if cam_data.sensor_fit == 'HORIZONTAL':
|
||||
sensor_width = cam_data.sensor_width
|
||||
sensor_height = sensor_width / (res_x / res_y)
|
||||
else:
|
||||
sensor_height = cam_data.sensor_height
|
||||
sensor_width = sensor_height * (res_x / res_y)
|
||||
focal = cam_data.lens
|
||||
frame_width = sensor_width / focal
|
||||
frame_height = sensor_height / focal
|
||||
|
||||
margin = spacing_pixels // 2
|
||||
x_centers = np.arange(margin, res_x, spacing_pixels)
|
||||
y_centers = np.arange(margin, res_y, spacing_pixels)
|
||||
offset_pix = max(2, dot_diameter_pixels // 4)
|
||||
du_ndc = offset_pix / res_x
|
||||
dv_ndc = offset_pix / res_y
|
||||
|
||||
dots = [] # (cx_uv, cy_uv, a_uv, b_uv, angle_uv)
|
||||
print(f"Raycasting {len(x_centers)*len(y_centers)} dot centers...")
|
||||
for y_pix in y_centers:
|
||||
v_ndc = y_pix / res_y
|
||||
for x_pix in x_centers:
|
||||
u_ndc = x_pix / res_x
|
||||
center_loc, face_idx = raycast_from_camera(scene, depsgraph, cam, obj,
|
||||
u_ndc, v_ndc, frame_width, frame_height)
|
||||
if center_loc is None: continue
|
||||
center_uv = get_uv_from_face(obj, face_idx, center_loc)
|
||||
if center_uv is None: continue
|
||||
|
||||
offsets = [
|
||||
( u_ndc + du_ndc, v_ndc),
|
||||
( u_ndc - du_ndc, v_ndc),
|
||||
( u_ndc, v_ndc + dv_ndc),
|
||||
( u_ndc, v_ndc - dv_ndc)
|
||||
]
|
||||
uv_offsets = []
|
||||
valid = True
|
||||
for (u_off, v_off) in offsets:
|
||||
loc_off, f_idx_off = raycast_from_camera(scene, depsgraph, cam, obj,
|
||||
u_off, v_off, frame_width, frame_height)
|
||||
if loc_off is None:
|
||||
valid = False; break
|
||||
uv_off = get_uv_from_face(obj, f_idx_off, loc_off)
|
||||
if uv_off is None:
|
||||
valid = False; break
|
||||
uv_offsets.append(uv_off)
|
||||
if not valid: continue
|
||||
|
||||
scale_u = 1.0 / res_x
|
||||
scale_v = 1.0 / res_y
|
||||
du_dx = (uv_offsets[0] - uv_offsets[1]) / (2 * offset_pix * scale_u)
|
||||
dv_dx = (uv_offsets[2] - uv_offsets[3]) / (2 * offset_pix * scale_v)
|
||||
r_pix = dot_diameter_pixels / 2.0
|
||||
a_uv = r_pix * du_dx.length * scale_u
|
||||
b_uv = r_pix * dv_dx.length * scale_v
|
||||
angle = np.arctan2(du_dx.y, du_dx.x) if du_dx.length > 0 else 0.0
|
||||
dots.append((center_uv.x, center_uv.y, a_uv, b_uv, angle))
|
||||
|
||||
print(f"Found {len(dots)} valid dots.")
|
||||
|
||||
if uv_centre is not None:
|
||||
draw_cross(pixels, texture_width, texture_height,
|
||||
uv_centre[0], uv_centre[1],
|
||||
cross_length, cross_thickness, cross_color)
|
||||
|
||||
# Create image
|
||||
img_name = "DotTexture"
|
||||
if img_name in bpy.data.images:
|
||||
bpy.data.images.remove(bpy.data.images[img_name])
|
||||
image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
|
||||
pixels = list(image.pixels)
|
||||
|
||||
# Black background (no mesh)
|
||||
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
|
||||
# White for all mesh areas
|
||||
rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1))
|
||||
# Draw dots
|
||||
for (cx_uv, cy_uv, a_uv, b_uv, angle_uv) in dots:
|
||||
cx_px = cx_uv * (texture_width - 1)
|
||||
cy_px = cy_uv * (texture_height - 1)
|
||||
a_px = a_uv * texture_width
|
||||
b_px = b_uv * texture_height
|
||||
draw_ellipse(pixels, texture_width, texture_height,
|
||||
cx_px, cy_px, a_px, b_px, angle_uv, dot_color)
|
||||
|
||||
image.pixels[:] = pixels
|
||||
image.update()
|
||||
abs_path = bpy.path.abspath(output_path)
|
||||
image.filepath_raw = abs_path
|
||||
image.file_format = 'PNG'
|
||||
image.save()
|
||||
print(f"Dot texture saved to {abs_path}")
|
||||
return image
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Main
|
||||
# ----------------------------------------------------------------------
|
||||
def main():
|
||||
# ==================== CONFIGURATION ====================
|
||||
MODE = "patches"
|
||||
OBJ_NAME = "FingerMesh"
|
||||
CAM_NAME = "Camera"
|
||||
TEXTURE_WIDTH = 4096
|
||||
TEXTURE_HEIGHT = 4096
|
||||
OUTPUT_PATH = "//" + ("patch_texture.png" if MODE=="patches" else "dot_texture.png")
|
||||
|
||||
# Patch mode
|
||||
PATCH_NX = 60; PATCH_NY = 50; COLOR_R = 0.05
|
||||
# Dot mode
|
||||
DOT_DIAMETER_PIX = 12; DOT_SPACING_PIX = 40; DOT_COLOR = (0,0,0,1)
|
||||
# Cross
|
||||
CROSS_LENGTH_PX = 40
|
||||
CROSS_THICKNESS_PX = 3
|
||||
CROSS_COLOR = (1, 0, 0, 1) # red cross
|
||||
# ======================================================
|
||||
|
||||
start_time = time.time()
|
||||
scene = bpy.context.scene
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
|
||||
if OBJ_NAME:
|
||||
obj = bpy.data.objects.get(OBJ_NAME)
|
||||
else:
|
||||
obj = bpy.context.active_object
|
||||
if obj is None or obj.type != 'MESH':
|
||||
raise ValueError("No valid mesh object found.")
|
||||
|
||||
cam = bpy.data.objects.get(CAM_NAME) if CAM_NAME else scene.camera
|
||||
if cam is None or cam.type != 'CAMERA':
|
||||
raise ValueError("No camera found.")
|
||||
|
||||
if not obj.data.uv_layers.active:
|
||||
obj.data.uv_layers.new(name="UVMap")
|
||||
|
||||
# Compute UV centre
|
||||
uv_layer = obj.data.uv_layers.active
|
||||
uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
|
||||
uv_min = uv_coords.min(axis=0)
|
||||
uv_max = uv_coords.max(axis=0)
|
||||
uv_centre = (uv_min + uv_max) / 2.0
|
||||
print(f"UV centre: ({uv_centre[0]:.4f}, {uv_centre[1]:.4f})")
|
||||
|
||||
|
||||
# Compute UV bounding box centre
|
||||
me = obj.data
|
||||
uv_layer = me.uv_layers.active
|
||||
uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
|
||||
uv_min = uv_coords.min(axis=0)
|
||||
uv_max = uv_coords.max(axis=0)
|
||||
uv_centre = (uv_min + uv_max) / 2.0
|
||||
print(f"UV centre: {uv_centre[0]:.4f}, {uv_centre[1]:.4f}")
|
||||
|
||||
if MODE == "patches":
|
||||
generate_patch_texture(
|
||||
obj, cam, scene, depsgraph,
|
||||
nx=PATCH_NX, ny=PATCH_NY, r=COLOR_R,
|
||||
texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
|
||||
output_path=OUTPUT_PATH,
|
||||
uv_centre=uv_centre,
|
||||
cross_length=CROSS_LENGTH_PX,
|
||||
cross_thickness=CROSS_THICKNESS_PX,
|
||||
cross_color=CROSS_COLOR
|
||||
)
|
||||
elif MODE == "dots":
|
||||
generate_dot_texture(
|
||||
obj, cam, scene, depsgraph,
|
||||
dot_diameter_pixels=DOT_DIAMETER_PIX,
|
||||
spacing_pixels=DOT_SPACING_PIX,
|
||||
dot_color=DOT_COLOR,
|
||||
texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
|
||||
output_path=OUTPUT_PATH,
|
||||
uv_centre=uv_centre,
|
||||
cross_length=CROSS_LENGTH_PX,
|
||||
cross_thickness=CROSS_THICKNESS_PX,
|
||||
cross_color=CROSS_COLOR
|
||||
)
|
||||
|
||||
print(f"Completed in {time.time()-start_time:.2f} seconds.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Convert a UV texture map to a physically scaled image for UV printing.
|
||||
Usage: python uv_to_printer.py --model model.obj --texture texture.png --resolution 100 --output print_ready.png
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import numpy as np
|
||||
import cv2
|
||||
import trimesh
|
||||
import os
|
||||
|
||||
def compute_uv_physical_scale(mesh, uv_layer_name=None):
|
||||
"""
|
||||
Compute the physical dimensions (mm) of the UV space.
|
||||
Assumes the UV map is a reasonably isometric unwrapping (e.g., cylindrical).
|
||||
Returns: (width_mm, height_mm, scale_factor_u, scale_factor_v)
|
||||
where scale_factor = physical length per UV unit.
|
||||
"""
|
||||
# Get UV coordinates and corresponding 3D positions
|
||||
if not hasattr(mesh.visual, 'uv') or mesh.visual.uv is None:
|
||||
raise ValueError("Mesh has no UV coordinates.")
|
||||
|
||||
uvs = mesh.visual.uv
|
||||
faces = mesh.faces
|
||||
vertices = mesh.vertices
|
||||
|
||||
# For each face, we have 3 UVs and 3 vertex positions
|
||||
# We'll compute the physical edge lengths for all edges in UV space
|
||||
# and compare to 3D edge lengths to estimate scaling.
|
||||
|
||||
total_uv_length = 0.0
|
||||
total_3d_length = 0.0
|
||||
edge_count = 0
|
||||
|
||||
# Also track UV bounding box for overall dimensions
|
||||
uv_min = uvs.min(axis=0)
|
||||
uv_max = uvs.max(axis=0)
|
||||
uv_range = uv_max - uv_min
|
||||
|
||||
# Estimate scale factor by sampling edges
|
||||
for face in faces:
|
||||
for i in range(3):
|
||||
j = (i + 1) % 3
|
||||
# UV edge
|
||||
uv0 = uvs[face[i]]
|
||||
uv1 = uvs[face[j]]
|
||||
uv_len = np.linalg.norm(uv1 - uv0)
|
||||
# 3D edge
|
||||
v0 = vertices[face[i]]
|
||||
v1 = vertices[face[j]]
|
||||
len3d = np.linalg.norm(v1 - v0)
|
||||
|
||||
if uv_len > 1e-6:
|
||||
total_uv_length += uv_len
|
||||
total_3d_length += len3d
|
||||
edge_count += 1
|
||||
|
||||
if edge_count == 0:
|
||||
raise ValueError("No valid edges found for scale estimation.")
|
||||
|
||||
# Average scale factor (physical mm per UV unit)
|
||||
scale_factor = total_3d_length / total_uv_length
|
||||
|
||||
# Physical dimensions of the UV layout
|
||||
width_mm = uv_range[0] * scale_factor
|
||||
height_mm = uv_range[1] * scale_factor
|
||||
|
||||
print(f"UV bounding box: {uv_range[0]:.3f} x {uv_range[1]:.3f} UV units")
|
||||
print(f"Scale factor: {scale_factor:.3f} mm per UV unit")
|
||||
print(f"Physical size: {width_mm:.2f} mm x {height_mm:.2f} mm")
|
||||
|
||||
return width_mm, height_mm, scale_factor
|
||||
|
||||
def generate_print_image(texture_path, width_mm, height_mm, pixels_per_mm, output_path):
|
||||
"""
|
||||
Load the texture image, scale it to match the physical dimensions at the
|
||||
specified resolution, and save the result.
|
||||
"""
|
||||
# Read texture (with alpha if present)
|
||||
texture = cv2.imread(texture_path, cv2.IMREAD_UNCHANGED)
|
||||
if texture is None:
|
||||
raise FileNotFoundError(f"Cannot load texture: {texture_path}")
|
||||
|
||||
# Compute target pixel dimensions
|
||||
target_width_px = int(round(width_mm * pixels_per_mm))
|
||||
target_height_px = int(round(height_mm * pixels_per_mm))
|
||||
|
||||
print(f"Target image size: {target_width_px} x {target_height_px} pixels")
|
||||
|
||||
# Resize texture (using INTER_LINEAR for up/down scaling)
|
||||
resized = cv2.resize(texture, (target_width_px, target_height_px),
|
||||
interpolation=cv2.INTER_LINEAR)
|
||||
|
||||
# Save output (preserve alpha if present)
|
||||
cv2.imwrite(output_path, resized)
|
||||
print(f"Print-ready image saved to: {output_path}")
|
||||
|
||||
def process_exported_npz(npz_path, texture_path, pixels_per_mm, output_path):
|
||||
"""
|
||||
Alternative: load data exported from Blender script (vertices, faces, UVs).
|
||||
"""
|
||||
data = np.load(npz_path)
|
||||
vertices = data['vertices']
|
||||
faces = data['faces']
|
||||
uv_data = data['uv_data']
|
||||
loop_to_vertex = data['loop_to_vertex']
|
||||
|
||||
# Reconstruct per-vertex UVs (average)
|
||||
# For simplicity, we'll compute physical scale using face edges as in trimesh method.
|
||||
# We need to build a temporary mesh-like structure.
|
||||
|
||||
# Count number of faces
|
||||
num_faces = len(faces)
|
||||
# Build face UVs array (3 per face)
|
||||
# The uv_data is per-loop, we need to group by face.
|
||||
# Assuming faces are triangles.
|
||||
face_uvs = []
|
||||
face_vertices = []
|
||||
uv_idx = 0
|
||||
for face in faces:
|
||||
f_uvs = []
|
||||
f_verts = []
|
||||
for _ in range(len(face)):
|
||||
f_uvs.append(uv_data[uv_idx])
|
||||
v_idx = loop_to_vertex[uv_idx]
|
||||
f_verts.append(vertices[v_idx])
|
||||
uv_idx += 1
|
||||
face_uvs.append(np.array(f_uvs))
|
||||
face_vertices.append(np.array(f_verts))
|
||||
|
||||
total_uv_len = 0.0
|
||||
total_3d_len = 0.0
|
||||
count = 0
|
||||
uv_min = np.min(uv_data, axis=0)
|
||||
uv_max = np.max(uv_data, axis=0)
|
||||
uv_range = uv_max - uv_min
|
||||
|
||||
for f_uv, f_v in zip(face_uvs, face_vertices):
|
||||
for i in range(3):
|
||||
j = (i+1)%3
|
||||
uv_len = np.linalg.norm(f_uv[i] - f_uv[j])
|
||||
v_len = np.linalg.norm(f_v[i] - f_v[j])
|
||||
if uv_len > 1e-6:
|
||||
total_uv_len += uv_len
|
||||
total_3d_len += v_len
|
||||
count += 1
|
||||
|
||||
scale = total_3d_len / total_uv_len if count>0 else 1.0
|
||||
width_mm = uv_range[0] * scale
|
||||
height_mm = uv_range[1] * scale
|
||||
|
||||
print(f"Physical size from .npz: {width_mm:.2f} x {height_mm:.2f} mm")
|
||||
generate_print_image(texture_path, width_mm, height_mm, pixels_per_mm, output_path)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Convert UV texture to physically scaled print image.")
|
||||
parser.add_argument("--model", help="3D model file (OBJ, PLY, STL) with UVs")
|
||||
parser.add_argument("--npz", help="Alternatively, use exported .npz from Blender script")
|
||||
parser.add_argument("--texture", required=True, help="Input texture image (PNG, JPG)")
|
||||
parser.add_argument("--resolution", type=float, default=100.0,
|
||||
help="Pixels per millimeter (default: 100)")
|
||||
parser.add_argument("--output", default="print_ready.png", help="Output image path")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.model and not args.npz:
|
||||
parser.error("Either --model or --npz must be provided.")
|
||||
|
||||
if args.npz:
|
||||
process_exported_npz(args.npz, args.texture, args.resolution, args.output)
|
||||
else:
|
||||
# Load mesh with trimesh
|
||||
mesh = trimesh.load(args.model, force='mesh')
|
||||
if not isinstance(mesh, trimesh.Trimesh):
|
||||
raise ValueError("Loaded object is not a single mesh.")
|
||||
|
||||
width_mm, height_mm, scale = compute_uv_physical_scale(mesh)
|
||||
generate_print_image(args.texture, width_mm, height_mm, args.resolution, args.output)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 929 KiB After Width: | Height: | Size: 929 KiB |
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 2.6 MiB |
@@ -0,0 +1,140 @@
|
||||
#%%
|
||||
import numpy as np
|
||||
import cv2
|
||||
|
||||
def merge_intervals(intervals):
|
||||
# Merge overlapping intervals.
|
||||
if not intervals:
|
||||
return []
|
||||
intervals.sort(key=lambda x: x[0])
|
||||
merged = [intervals[0]]
|
||||
for current in intervals[1:]:
|
||||
last = merged[-1]
|
||||
if current[0] <= last[1]:
|
||||
merged[-1] = (last[0], max(last[1], current[1]))
|
||||
else:
|
||||
merged.append(current)
|
||||
return merged
|
||||
|
||||
def get_allowed_intervals(neighbor_values, delta):
|
||||
# Compute forbidden intervals for this channel from neighbor values.
|
||||
forbidden = []
|
||||
for c in neighbor_values:
|
||||
low = max(0, c - delta)
|
||||
high = min(1, c + delta)
|
||||
forbidden.append((low, high))
|
||||
# Merge overlapping forbidden intervals.
|
||||
merged = merge_intervals(forbidden)
|
||||
allowed = []
|
||||
# Allowed region before the first forbidden interval.
|
||||
if merged:
|
||||
if merged[0][0] > 0:
|
||||
allowed.append((0, merged[0][0]))
|
||||
# Allowed regions between forbidden intervals.
|
||||
for i in range(1, len(merged)):
|
||||
allowed.append((merged[i-1][1], merged[i][0]))
|
||||
# Allowed region after the last forbidden interval.
|
||||
if merged[-1][1] < 1:
|
||||
allowed.append((merged[-1][1], 1))
|
||||
else:
|
||||
allowed.append((0, 1))
|
||||
return allowed
|
||||
|
||||
def sample_from_allowed(allowed):
|
||||
# Choose a random value from the allowed intervals with probability proportional to interval length.
|
||||
lengths = [end - start for start, end in allowed]
|
||||
total_length = sum(lengths)
|
||||
if total_length <= 0:
|
||||
return None
|
||||
point = np.random.rand() * total_length
|
||||
cumulative = 0
|
||||
for (start, end), length in zip(allowed, lengths):
|
||||
cumulative += length
|
||||
if point <= cumulative:
|
||||
return np.random.uniform(start, end)
|
||||
return allowed[-1][0]
|
||||
|
||||
def sample_channel(neighbor_values, delta):
|
||||
# Compute allowed intervals and sample a value.
|
||||
allowed = get_allowed_intervals(neighbor_values, delta)
|
||||
if not allowed:
|
||||
return None
|
||||
return sample_from_allowed(allowed)
|
||||
|
||||
def generate_valid_color(neighbors, r):
|
||||
"""
|
||||
Generate a random color that satisfies, for each neighbor,
|
||||
|R - R_neighbor| >= sqrt(r), and similarly for G and B.
|
||||
neighbors: list of neighbor colors (each is an array of shape (3,))
|
||||
r: threshold for squared difference.
|
||||
"""
|
||||
delta = np.sqrt(r)
|
||||
color = np.zeros(3)
|
||||
for channel in range(3):
|
||||
neighbor_channel = [n[channel] for n in neighbors]
|
||||
sampled = sample_channel(neighbor_channel, delta)
|
||||
# If the allowed intervals are empty, use a fallback.
|
||||
if sampled is None:
|
||||
sampled = np.random.rand()
|
||||
color[channel] = sampled
|
||||
return color
|
||||
|
||||
def generate_color_pattern(h, w, d, r):
|
||||
"""
|
||||
Generate a dense color pattern.
|
||||
h, w: height and width of the output image in pixels.
|
||||
d: patch size in pixels (each patch is d x d).
|
||||
r: randomness regulation factor. For each neighbor the condition is:
|
||||
(|channel_new - channel_neighbor|)^2 >= r.
|
||||
"""
|
||||
pattern = np.zeros((h, w, 3), dtype=np.float32)
|
||||
num_patches_x = h // d
|
||||
num_patches_y = w // d
|
||||
|
||||
for i in range(num_patches_x):
|
||||
for j in range(num_patches_y):
|
||||
# Collect neighbor patch colors (using mean because patches have uniform color).
|
||||
neighbors = []
|
||||
if i > 0:
|
||||
patch_above = pattern[(i-1)*d:i*d, j*d:(j+1)*d]
|
||||
neighbors.append(np.mean(patch_above, axis=(0, 1)))
|
||||
if j > 0:
|
||||
patch_left = pattern[i*d:(i+1)*d, (j-1)*d:j*d]
|
||||
neighbors.append(np.mean(patch_left, axis=(0, 1)))
|
||||
|
||||
if len(neighbors) == 0:
|
||||
# For the first patch use a random color.
|
||||
new_color = np.random.rand(3)
|
||||
else:
|
||||
new_color = generate_valid_color(neighbors, r)
|
||||
|
||||
pattern[i*d:(i+1)*d, j*d:(j+1)*d] = new_color
|
||||
print('Patch ({}, {})'.format(i, j))
|
||||
return pattern
|
||||
|
||||
def main():
|
||||
# Parameters for the pattern.
|
||||
# For example, using 100x100 pixels image with patches of 3x3 pixels.
|
||||
# 11111111
|
||||
height = 1200
|
||||
width = 1000
|
||||
patch_size = 12
|
||||
# Set r to a value that gives sufficient difference.
|
||||
# Note: r is the threshold for squared difference per channel, and the condition is
|
||||
# |c - c_neighbor| >= sqrt(r). Very high r may make it impossible to satisfy.
|
||||
r = 1000
|
||||
|
||||
pattern = generate_color_pattern(height, width, patch_size, r)
|
||||
# Scale to 0-255 for display and saving.
|
||||
pattern_display = (pattern * 255).astype(np.uint8)
|
||||
|
||||
# Display the pattern with OpenCV.
|
||||
cv2.imshow('Pattern', pattern_display)
|
||||
cv2.waitKey(0)
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
# Save the pattern as an image file.
|
||||
cv2.imwrite('color_pattern.png', pattern_display)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,139 @@
|
||||
#%%
|
||||
import numpy as np
|
||||
import cv2
|
||||
|
||||
def merge_intervals(intervals):
|
||||
# Merge overlapping intervals.
|
||||
if not intervals:
|
||||
return []
|
||||
intervals.sort(key=lambda x: x[0])
|
||||
merged = [intervals[0]]
|
||||
for current in intervals[1:]:
|
||||
last = merged[-1]
|
||||
if current[0] <= last[1]:
|
||||
merged[-1] = (last[0], max(last[1], current[1]))
|
||||
else:
|
||||
merged.append(current)
|
||||
return merged
|
||||
|
||||
def get_allowed_intervals(neighbor_values, delta):
|
||||
# Compute forbidden intervals for this channel from neighbor values.
|
||||
forbidden = []
|
||||
for c in neighbor_values:
|
||||
low = max(0, c - delta)
|
||||
high = min(1, c + delta)
|
||||
forbidden.append((low, high))
|
||||
# Merge overlapping forbidden intervals.
|
||||
merged = merge_intervals(forbidden)
|
||||
allowed = []
|
||||
# Allowed region before the first forbidden interval.
|
||||
if merged:
|
||||
if merged[0][0] > 0:
|
||||
allowed.append((0, merged[0][0]))
|
||||
# Allowed regions between forbidden intervals.
|
||||
for i in range(1, len(merged)):
|
||||
allowed.append((merged[i-1][1], merged[i][0]))
|
||||
# Allowed region after the last forbidden interval.
|
||||
if merged[-1][1] < 1:
|
||||
allowed.append((merged[-1][1], 1))
|
||||
else:
|
||||
allowed.append((0, 1))
|
||||
return allowed
|
||||
|
||||
def sample_from_allowed(allowed):
|
||||
# Choose a random value from the allowed intervals with probability proportional to interval length.
|
||||
lengths = [end - start for start, end in allowed]
|
||||
total_length = sum(lengths)
|
||||
if total_length <= 0:
|
||||
return None
|
||||
point = np.random.rand() * total_length
|
||||
cumulative = 0
|
||||
for (start, end), length in zip(allowed, lengths):
|
||||
cumulative += length
|
||||
if point <= cumulative:
|
||||
return np.random.uniform(start, end)
|
||||
return allowed[-1][0]
|
||||
|
||||
def sample_channel(neighbor_values, delta):
|
||||
# Compute allowed intervals and sample a value.
|
||||
allowed = get_allowed_intervals(neighbor_values, delta)
|
||||
if not allowed:
|
||||
return None
|
||||
return sample_from_allowed(allowed)
|
||||
|
||||
def generate_valid_color(neighbors, r):
|
||||
"""
|
||||
Generate a random color that satisfies, for each neighbor,
|
||||
|R - R_neighbor| >= sqrt(r), and similarly for G and B.
|
||||
neighbors: list of neighbor colors (each is an array of shape (3,))
|
||||
r: threshold for squared difference.
|
||||
"""
|
||||
delta = np.sqrt(r)
|
||||
color = np.zeros(3)
|
||||
for channel in range(3):
|
||||
neighbor_channel = [n[channel] for n in neighbors]
|
||||
sampled = sample_channel(neighbor_channel, delta)
|
||||
# If the allowed intervals are empty, use a fallback.
|
||||
if sampled is None:
|
||||
sampled = np.random.rand()
|
||||
color[channel] = sampled
|
||||
return color
|
||||
|
||||
def generate_color_pattern(h, w, d, r):
|
||||
"""
|
||||
Generate a dense color pattern.
|
||||
h, w: height and width of the output image in pixels.
|
||||
d: patch size in pixels (each patch is d x d).
|
||||
r: randomness regulation factor. For each neighbor the condition is:
|
||||
(|channel_new - channel_neighbor|)^2 >= r.
|
||||
"""
|
||||
pattern = np.zeros((h, w, 3), dtype=np.float32)
|
||||
num_patches_x = h // d
|
||||
num_patches_y = w // d
|
||||
|
||||
for i in range(num_patches_x):
|
||||
for j in range(num_patches_y):
|
||||
# Collect neighbor patch colors (using mean because patches have uniform color).
|
||||
neighbors = []
|
||||
if i > 0:
|
||||
patch_above = pattern[(i-1)*d:i*d, j*d:(j+1)*d]
|
||||
neighbors.append(np.mean(patch_above, axis=(0, 1)))
|
||||
if j > 0:
|
||||
patch_left = pattern[i*d:(i+1)*d, (j-1)*d:j*d]
|
||||
neighbors.append(np.mean(patch_left, axis=(0, 1)))
|
||||
|
||||
if len(neighbors) == 0:
|
||||
# For the first patch use a random color.
|
||||
new_color = np.random.rand(3)
|
||||
else:
|
||||
new_color = generate_valid_color(neighbors, r)
|
||||
|
||||
pattern[i*d:(i+1)*d, j*d:(j+1)*d] = new_color
|
||||
print('Patch ({}, {})'.format(i, j))
|
||||
return pattern
|
||||
|
||||
def main():
|
||||
# Parameters for the pattern.
|
||||
# For example, using 100x100 pixels image with patches of 3x3 pixels.
|
||||
height = 1200
|
||||
width = 1000
|
||||
patch_size = 12
|
||||
# Set r to a value that gives sufficient difference.
|
||||
# Note: r is the threshold for squared difference per channel, and the condition is
|
||||
# |c - c_neighbor| >= sqrt(r). Very high r may make it impossible to satisfy.
|
||||
r = 1000
|
||||
|
||||
pattern = generate_color_pattern(height, width, patch_size, r)
|
||||
# Scale to 0-255 for display and saving.
|
||||
pattern_display = (pattern * 255).astype(np.uint8)
|
||||
|
||||
# Display the pattern with OpenCV.
|
||||
cv2.imshow('Pattern', pattern_display)
|
||||
cv2.waitKey(0)
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
# Save the pattern as an image file.
|
||||
cv2.imwrite('color_pattern.png', pattern_display)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,44 @@
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# Parameters
|
||||
R = 9.0 # mm
|
||||
H = 20.0 # mm
|
||||
fov_half = np.radians(75) # 75 degrees half-angle
|
||||
pixels_per_mm = 100
|
||||
|
||||
# Texture dimensions
|
||||
width = int(np.pi * 2 * R * pixels_per_mm) # circumference
|
||||
height = int(H * pixels_per_mm)
|
||||
|
||||
# Desired dot pattern in image space (normalized)
|
||||
delta_theta = np.radians(10) # 10° azimuth spacing
|
||||
delta_r = 0.1 # radial spacing in normalized units
|
||||
rho = 0.08 # normalized dot radius (from reference)
|
||||
|
||||
# Generate dot centers in image space
|
||||
r_vals = np.arange(R/H, np.tan(fov_half), delta_r)
|
||||
theta_vals = np.arange(0, 2*np.pi, delta_theta)
|
||||
|
||||
# Create empty texture (white background)
|
||||
img = Image.new('L', (width, height), color=255)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
for r_img in r_vals:
|
||||
z = R / r_img
|
||||
v = z / H
|
||||
# Ellipse axes in texture coordinates
|
||||
a_u = (rho * z) / (2 * np.pi * R)
|
||||
a_v = (rho * z**2) / (R * H)
|
||||
# Convert to pixels
|
||||
a_u_px = a_u * width
|
||||
a_v_px = a_v * height
|
||||
for theta in theta_vals:
|
||||
u = theta / (2 * np.pi)
|
||||
cx = u * width
|
||||
cy = v * height
|
||||
# Draw ellipse (bounding box)
|
||||
bbox = [cx - a_u_px, cy - a_v_px, cx + a_u_px, cy + a_v_px]
|
||||
draw.ellipse(bbox, fill=0) # black dot
|
||||
|
||||
img.save('cylinder_marker_texture.png')
|
||||
@@ -1,102 +0,0 @@
|
||||
"""
|
||||
Verify that the OpenCL FFT convolution in solve_opencl / solve_opencl_batch2
|
||||
produces the same result as scipy.signal.fftconvolve(f, G, mode='same').
|
||||
|
||||
This script simulates the OpenCL logic with numpy FFTs so we can compare
|
||||
directly without needing a GPU.
|
||||
"""
|
||||
import numpy as np
|
||||
from scipy.signal import fftconvolve
|
||||
|
||||
def make_green_2d(H, W, dx=1.0):
|
||||
"""Reproduce PoissonSolver._create_radialGrid for 2D."""
|
||||
rdims = (2*H - 1, 2*W - 1)
|
||||
R = np.indices(rdims, dtype=np.float64)
|
||||
R[0] += 1 - H
|
||||
R[1] += 1 - W
|
||||
R[0] *= dx
|
||||
R[1] *= dx
|
||||
np.hypot(R[1], R[0], R[0])
|
||||
R = R[0]
|
||||
zval = 0.5 * (dx + dx) / 2.0
|
||||
R[H-1, W-1] = zval
|
||||
# Apply 2D Green's function: log(r)/(2*pi)
|
||||
G = np.log(R) * (0.5 / np.pi)
|
||||
return G
|
||||
|
||||
def test_equivalence(H, W, dx=1.0, seed=42):
|
||||
np.random.seed(seed)
|
||||
f = np.random.randn(H, W).astype(np.float64)
|
||||
G = make_green_2d(H, W, dx)
|
||||
gdx_prod = dx ** 2
|
||||
|
||||
# --- CPU reference: scipy fftconvolve ---
|
||||
p_cpu = fftconvolve(f, G, mode='same') * gdx_prod
|
||||
|
||||
# --- Simulate what OpenCL solve_opencl does ---
|
||||
f32 = f.astype(np.float32)
|
||||
G32 = G.astype(np.float32)
|
||||
full_shape = (2*H - 1, 2*W - 1)
|
||||
|
||||
# G already has shape full_shape, no padding needed
|
||||
# f is zero-padded to full_shape at [0:H, 0:W]
|
||||
f_pad = np.zeros(full_shape, dtype=np.float32)
|
||||
f_pad[:H, :W] = f32
|
||||
|
||||
# FFT at full_shape, multiply, iFFT
|
||||
F_f = np.fft.rfftn(f_pad)
|
||||
F_g = np.fft.rfftn(G32)
|
||||
conv_circ = np.fft.irfftn(F_f * F_g, s=full_shape)
|
||||
|
||||
# OLD OpenCL extraction (BUGGY): same_starts = [(full_shape[i] - fshape[i]) // 2]
|
||||
same_starts_old = [(full_shape[0] - H) // 2, (full_shape[1] - W) // 2]
|
||||
p_old = conv_circ[
|
||||
same_starts_old[0]:same_starts_old[0]+H,
|
||||
same_starts_old[1]:same_starts_old[1]+W
|
||||
].astype(np.float32) * np.float32(gdx_prod)
|
||||
|
||||
# FIXED OpenCL extraction: same_starts = [s - 1 for s in fshape]
|
||||
same_starts_fixed = [H - 1, W - 1]
|
||||
p_fixed = conv_circ[
|
||||
same_starts_fixed[0]:same_starts_fixed[0]+H,
|
||||
same_starts_fixed[1]:same_starts_fixed[1]+W
|
||||
].astype(np.float32) * np.float32(gdx_prod)
|
||||
|
||||
# --- Also test with correct FFT size (3H-2) ---
|
||||
correct_fft_shape = (H + (2*H-1) - 1, W + (2*W-1) - 1) # (3H-2, 3W-2)
|
||||
F_f_full = np.fft.rfftn(f32, s=correct_fft_shape)
|
||||
F_g_full = np.fft.rfftn(G32, s=correct_fft_shape)
|
||||
conv_full = np.fft.irfftn(F_f_full * F_g_full, s=correct_fft_shape)
|
||||
# scipy-style 'same' extraction from full linear conv
|
||||
start_full = [(correct_fft_shape[0] - H) // 2, (correct_fft_shape[1] - W) // 2]
|
||||
p_full_linear = conv_full[
|
||||
start_full[0]:start_full[0]+H,
|
||||
start_full[1]:start_full[1]+W
|
||||
].astype(np.float32) * np.float32(gdx_prod)
|
||||
|
||||
# --- Compare ---
|
||||
p_cpu32 = p_cpu.astype(np.float32)
|
||||
scale = max(np.abs(p_cpu32).max(), 1e-10)
|
||||
|
||||
diff_old = np.abs(p_cpu32 - p_old)
|
||||
diff_fixed = np.abs(p_cpu32 - p_fixed)
|
||||
diff_full = np.abs(p_cpu32 - p_full_linear)
|
||||
|
||||
print(f"=== Test: f({H},{W}), G({2*H-1},{2*W-1}), full_shape={full_shape} ===")
|
||||
print(f" scipy fftconvolve full linear conv shape: {correct_fft_shape}")
|
||||
print(f" p_cpu range: [{p_cpu32.min():.4f}, {p_cpu32.max():.4f}]")
|
||||
print()
|
||||
print(f" [OLD BUG] same_starts={same_starts_old}:")
|
||||
print(f" max_abs_diff = {diff_old.max():.6e} rel_max = {diff_old.max()/scale:.6e}")
|
||||
print(f" [FIXED] same_starts={same_starts_fixed}:")
|
||||
print(f" max_abs_diff = {diff_fixed.max():.6e} rel_max = {diff_fixed.max()/scale:.6e}")
|
||||
print(f" [REF] Full linear conv (FFT size {correct_fft_shape}):")
|
||||
print(f" max_abs_diff = {diff_full.max():.6e} rel_max = {diff_full.max()/scale:.6e}")
|
||||
print()
|
||||
|
||||
return diff_old.max(), diff_fixed.max(), diff_full.max()
|
||||
|
||||
if __name__ == '__main__':
|
||||
for size in [16, 32, 64, 128, 256, 384]:
|
||||
d_ocl, d_cor, d_full = test_equivalence(size, size)
|
||||
print()
|
||||