Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,5 @@ dev = [
"pre-commit",
"openpyxl",
"pywinctl",
"pandas-stubs>=2.3.3.251219",
]
8 changes: 5 additions & 3 deletions scripts/demo/demo_gui_capture_volume.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@
# After filtering - log filtered point counts

logger.info("Point counts loaded into Capture Volume Widget:")
logger.info(f" 3D points (obj.shape[0]): {controller.capture_volume.point_estimates.obj.shape[0]}")
logger.info(f" 2D observations (img.shape[0]): {controller.capture_volume.point_estimates.img.shape[0]}")
logger.info(f" Camera indices length: {len(controller.capture_volume.point_estimates.camera_indices)}")
capture_volume = controller.capture_volume
assert capture_volume is not None # Widget requires capture volume to exist
logger.info(f" 3D points (obj.shape[0]): {capture_volume.point_estimates.obj.shape[0]}")
logger.info(f" 2D observations (img.shape[0]): {capture_volume.point_estimates.img.shape[0]}")
logger.info(f" Camera indices length: {len(capture_volume.point_estimates.camera_indices)}")

window.show()

Expand Down
9 changes: 7 additions & 2 deletions scripts/demo/demo_gui_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@
import sys
from pathlib import Path
from caliscope.gui.main_widget import MainWindow
import qdarktheme

try:
import qdarktheme # type: ignore[import-not-found]
except ImportError:
qdarktheme = None

app = QApplication(sys.argv)
qdarktheme.setup_theme("auto")
if qdarktheme is not None:
qdarktheme.setup_theme("auto")

window = MainWindow()
workspace_dir = Path(r"C:\Users\Mac Prible\OneDrive\caliscope\4_cam_prerecorded_practice_working")
Expand Down
2 changes: 1 addition & 1 deletion scripts/demo/demo_synched_frames_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from caliscope.gui.synched_frames_display import SyncedFramesDisplay
from caliscope.controller import Controller
from caliscope.synchronized_stream_manager import SynchronizedStreamManager
from caliscope.managers.synchronized_stream_manager import SynchronizedStreamManager

from pathlib import Path

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,11 @@ def generate_keypoints(camera_array: CameraArray, raw_video_dir: Path, tracker:
# After filtering - log filtered point counts

logger.info("Point counts loaded into Capture Volume Widget:")
logger.info(f" 3D points (obj.shape[0]): {controller.capture_volume.point_estimates.obj.shape[0]}")
logger.info(f" 2D observations (img.shape[0]): {controller.capture_volume.point_estimates.img.shape[0]}")
logger.info(f" Camera indices length: {len(controller.capture_volume.point_estimates.camera_indices)}")
capture_volume = controller.capture_volume
assert capture_volume is not None # Widget requires capture volume to exist
logger.info(f" 3D points (obj.shape[0]): {capture_volume.point_estimates.obj.shape[0]}")
logger.info(f" 2D observations (img.shape[0]): {capture_volume.point_estimates.img.shape[0]}")
logger.info(f" Camera indices length: {len(capture_volume.point_estimates.camera_indices)}")

window.show()

Expand Down
5 changes: 3 additions & 2 deletions src/caliscope/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
import PySide6
from PySide6 import QtCore

PySide6.__version__ = QtCore.__version__
PySide6.__version_info__ = QtCore.__version_info__
# Monkey-patch for pyqtgraph compatibility - stubs don't know about this
PySide6.__version__ = QtCore.__version__ # type: ignore[attr-defined]
PySide6.__version_info__ = QtCore.__version_info__ # type: ignore[attr-defined]

from pathlib import Path # noqa: E402

Expand Down
3 changes: 3 additions & 0 deletions src/caliscope/cameras/synchronizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ def set_tracking_on_streams(self, track: bool):
stream.set_tracking_on(track)

def update_dropped_frame_history(self):
if self.current_sync_packet is None:
return

current_dropped: dict = self.current_sync_packet.dropped

for port, dropped in current_dropped.items():
Expand Down
18 changes: 14 additions & 4 deletions src/caliscope/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,8 +368,12 @@ def load_estimated_capture_volume(self):

# Load metadata and apply to capture volume
metadata = self.capture_volume_repository.load_metadata()
self.capture_volume.stage = metadata.get("stage")
self.capture_volume.origin_sync_index = metadata.get("origin_sync_index")
stage = metadata.get("stage")
if stage is not None:
self.capture_volume.stage = stage
origin_sync_index = metadata.get("origin_sync_index")
if origin_sync_index is not None:
self.capture_volume.origin_sync_index = origin_sync_index

logger.info("Load of capture volume complete")

Expand Down Expand Up @@ -487,23 +491,29 @@ def worker(token, _handle):

def rotate_capture_volume(self, direction: str):
"""Rotate capture volume and persist in background thread."""
assert self.capture_volume is not None
self.capture_volume.rotate(direction)
self.capture_volume_shifted.emit()

capture_volume = self.capture_volume # capture for closure

def worker(_token, _handle):
self.camera_repository.save(self.camera_array)
self.capture_volume_repository.save_capture_volume(self.capture_volume)
self.capture_volume_repository.save_capture_volume(capture_volume)

self.task_manager.submit(worker, name="rotate_capture_volume")

def set_capture_volume_origin_to_board(self, origin_index):
"""Set world origin and persist in background thread."""
assert self.capture_volume is not None
self.capture_volume.set_origin_to_board(origin_index, self.charuco)
self.capture_volume_shifted.emit()

capture_volume = self.capture_volume # capture for closure

def worker(_token, _handle):
self.camera_repository.save(self.camera_array)
self.capture_volume_repository.save_capture_volume(self.capture_volume)
self.capture_volume_repository.save_capture_volume(capture_volume)

self.task_manager.submit(worker, name="set_capture_volume_origin")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@ def _estimate_single_pair(
D_perfect = np.zeros(5)

try:
# Using normalized coordinates (K=identity, D=zeros) rather than pixel coordinates.
# This allows a unified pipeline for fisheye and rectilinear lenses, and improves
# numerical stability for bundle adjustment. In this reference frame, imageSize
# is meaningless - there are no "pixels", just normalized ray directions.
# OpenCV's type stubs don't account for this valid use case.
ret, _, _, _, _, R, T, _, _ = cv2.stereoCalibrate(
obj_locs_a,
norm_locs_a,
Expand All @@ -182,7 +187,7 @@ def _estimate_single_pair(
D_perfect,
K_perfect,
D_perfect,
imageSize=None,
imageSize=None, # type: ignore[arg-type]
criteria=criteria,
flags=stereocal_flags,
)
Expand Down Expand Up @@ -214,7 +219,7 @@ def _select_diverse_boards(valid_boards_a: pd.DataFrame, sample_size: int) -> pd
return boards_sorted

# For temporal diversity, select boards spread across time range
sync_indices = boards_sorted["sync_index"].values
sync_indices = boards_sorted["sync_index"].to_numpy()
min_sync, max_sync = sync_indices.min(), sync_indices.max()

if max_sync > min_sync and sample_size > 1:
Expand Down
2 changes: 2 additions & 0 deletions src/caliscope/core/bootstrap_pose/paired_pose_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ def get_best_anchored_camera_array(
if best_anchor == -1:
return None, camera_array.cameras
else:
# best_cameras_config is guaranteed to be set if best_anchor != -1
assert best_cameras_config is not None
return best_anchor, best_cameras_config

def apply_to(self, camera_array: CameraArray, anchor_cam: int | None = None) -> None:
Expand Down
6 changes: 3 additions & 3 deletions src/caliscope/core/bootstrap_pose/pose_network_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ def translation_error(t1: NDArray[np.float64], t2: NDArray[np.float64]) -> dict[
dot_product = np.clip(np.dot(t1 / mag1, t2 / mag2), -1.0, 1.0)
direction_error = np.degrees(np.arccos(dot_product))

return {"magnitude_delta_pct": magnitude_error, "direction_delta_deg": direction_error}
return {"magnitude_delta_pct": float(magnitude_error), "direction_delta_deg": float(direction_error)}


def compute_relative_poses(
Expand Down Expand Up @@ -490,7 +490,7 @@ def compute_relative_poses(
relative_poses[(pair_key, sync_index)] = StereoPair(
primary_port=port_a,
secondary_port=port_b,
error_score=None, # Will be filled after RMSE calculation
error_score=float("nan"), # Placeholder until RMSE calculation
translation=t_rel,
rotation=R_rel,
)
Expand Down Expand Up @@ -533,7 +533,7 @@ def aggregate_poses(filtered_poses: dict[tuple[int, int], list[StereoPair]]) ->
aggregated[pair] = StereoPair(
primary_port=pair[0],
secondary_port=pair[1],
error_score=None, # Will be filled by RMSE calculation
error_score=float("nan"), # Placeholder until RMSE calculation
rotation=avg_R,
translation=avg_t,
)
Expand Down
12 changes: 8 additions & 4 deletions src/caliscope/core/capture_volume/capture_volume.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ class CaptureVolume:
_point_estimates: PointEstimates # Internal storage for point data
stage: int = 0
_normalized_points: np.ndarray | None = None
origin_sync_index: int = None
origin_sync_index: int | None = None

def __post__init__(self):
logger.info("Creating capture volume from estimated camera array and stereotriangulated points...")

def _save(self, directory: Path, descriptor: str = None):
def _save(self, directory: Path, descriptor: str | None = None):
"""
Convenience function to facilitate visual checks through the stages of processing using the GUI
"""
Expand All @@ -49,6 +49,8 @@ def get_vectorized_params(self):
This is the required data format of the least squares optimization
"""
camera_params = self.camera_array.get_extrinsic_params()
if camera_params is None:
raise ValueError("No posed cameras available for optimization")
combined = np.hstack((camera_params.ravel(), self._point_estimates.obj.ravel()))

return combined
Expand Down Expand Up @@ -272,11 +274,13 @@ def xy_reprojection_error(current_param_estimates, capture_volume: CaptureVolume

# --- Select camera intrinsics based on the mode ---
if use_normalized:
# OPTIMIZED PATH: Use the 'perfect' camera model. Much faster.
# OPTIMIZED PATH: Use the 'perfect' camera model. Computational benefits.
cam_matrix = np.identity(3)
dist_coeffs = None
dist_coeffs = np.zeros(5) # No distortion in normalized space
else:
# SLOW PATH: Use the camera's true intrinsics. Needed for final pixel error.
if cam.matrix is None or cam.distortions is None:
raise ValueError(f"Camera {port} missing intrinsics for pixel-mode reprojection")
cam_matrix = cam.matrix
dist_coeffs = cam.distortions

Expand Down
15 changes: 11 additions & 4 deletions src/caliscope/core/capture_volume/quality_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def data_2d(self) -> pd.DataFrame:
{"sync_index": "int32", "charuco_id": "int32", "obj_id": "int32"},
)

reproj_errors = summarized_data["reproj_error"].values
reproj_errors = summarized_data["reproj_error"].to_numpy()
sorted_indices = np.argsort(reproj_errors)
ranks = np.empty_like(sorted_indices)
ranks[sorted_indices] = np.linspace(0, 100, len(reproj_errors))
Expand Down Expand Up @@ -102,7 +102,7 @@ def paired_obj_indices(self) -> np.ndarray:
obj_id = self.corners_world_xyz["obj_id"].to_numpy(dtype=np.int32)

# for a given sync index (i.e. one board snapshot) get all pairs of object ids
paired_obj_indices = None
paired_obj_indices: np.ndarray | None = None
for x in unique_sync_indices:
sync_obj = obj_id[sync_indices == x] # 3d objects (corners) at a specific sync_index
all_pairs = cartesian_product(sync_obj, sync_obj)
Expand All @@ -111,6 +111,10 @@ def paired_obj_indices(self) -> np.ndarray:
else:
paired_obj_indices = np.vstack([paired_obj_indices, all_pairs])

# Handle empty case - return empty array with correct shape
if paired_obj_indices is None:
return np.zeros((0, 2), dtype=np.int32)

# paired_corner_indices will contain duplicates (i.e. [0,1] and [1,0]) as well as self-pairs ([0,0], [1,1])
# this need to get filtered out
reformatted_paired_obj_indices = np.zeros(
Expand All @@ -137,8 +141,11 @@ def paired_obj_indices(self) -> np.ndarray:

@property
def corners_board_xyz(self) -> np.ndarray:
corner_ids = self.corners_world_xyz["charuco_id"]
corners_board_xyz = self.charuco.board.getChessboardCorners()[corner_ids]
if self.charuco is None:
raise ValueError("Charuco board required for board corner positions")
corner_ids = self.corners_world_xyz["charuco_id"].to_numpy()
all_corners = np.asarray(self.charuco.board.getChessboardCorners())
corners_board_xyz = all_corners[corner_ids]

return corners_board_xyz

Expand Down
26 changes: 14 additions & 12 deletions src/caliscope/core/capture_volume/set_origin_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,12 @@ def get_board_corners_xyz(point_estimates: PointEstimates, sync_index: int, char
"""
sync_indices = point_estimates.sync_indices # convienent shortening
charuco_ids = point_estimates.point_id[sync_indices == sync_index]
unique_charuco_id = np.unique(charuco_ids)
unique_charuco_id = np.unique(charuco_ids).astype(np.intp) # ensure integer index type
unique_charuco_id.sort()

board_corners_xyz = charuco.board.getChessboardCorners()[unique_charuco_id]
# OpenCV's getChessboardCorners returns MatLike which supports array indexing at runtime
all_corners = np.asarray(charuco.board.getChessboardCorners())
board_corners_xyz = all_corners[unique_charuco_id]
return board_corners_xyz


Expand Down Expand Up @@ -135,19 +137,19 @@ def get_rvec_tvec_from_board_pose(
return mean_rvec, mean_tvec


def mean_vec(vecs):
hstacked_vec = None
def mean_vec(vecs: list[np.ndarray]) -> np.ndarray:
"""Compute mean of a list of vectors by stacking and averaging along axis 1."""
if not vecs:
raise ValueError("Cannot compute mean of empty vector list")

for vec in vecs:
if hstacked_vec is None:
hstacked_vec = vec
else:
hstacked_vec = np.hstack([hstacked_vec, vec])
hstacked_vec = vecs[0]
for vec in vecs[1:]:
hstacked_vec = np.hstack([hstacked_vec, vec])

mean_vec = np.mean(hstacked_vec, axis=1)
mean_vec = np.expand_dims(mean_vec, axis=1)
result = np.mean(hstacked_vec, axis=1)
result = np.expand_dims(result, axis=1)

return mean_vec
return result


def transform_to_rvec_tvec(transformation: np.ndarray):
Expand Down
10 changes: 6 additions & 4 deletions src/caliscope/core/charuco.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from itertools import combinations

import cv2
import numpy as np
from PySide6.QtCore import Qt
from PySide6.QtGui import QImage, QPixmap

Expand Down Expand Up @@ -128,7 +129,7 @@ def board_img(self, pixmap_scale=1000):
(self.board_width_scaled(pixmap_scale=pixmap_scale), self.board_height_scaled(pixmap_scale=pixmap_scale))
)
if self.inverted:
img = ~img
img = cv2.bitwise_not(img)

return img

Expand Down Expand Up @@ -171,7 +172,8 @@ def get_connected_points(self) -> set[tuple[int, int]]:
The return value is a *set* not a list
"""
# create sets of the vertical and horizontal line positions
corners = self.board.getChessboardCorners()
# getChessboardCorners returns MatLike; convert to ndarray for indexing
corners = np.asarray(self.board.getChessboardCorners())
corners_x = corners[:, 0]
corners_y = corners[:, 1]
x_set = set(corners_x)
Expand Down Expand Up @@ -204,8 +206,8 @@ def get_object_corners(self, corner_ids):
Given an array of corner IDs, provide an array of their relative
position in a board frame of reference, originating from a corner position.
"""

return self.board.chessboardCorners()[corner_ids, :]
corners = np.asarray(self.board.getChessboardCorners())
return corners[corner_ids, :]

def summary(self):
text = f"Columns: {self.columns}\n"
Expand Down
2 changes: 1 addition & 1 deletion src/caliscope/core/intrinsic_calibrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def add_frame_packet(self, frame_packet: FramePacket):
"""
index = frame_packet.frame_index

if index != -1: # indicates end of stream
if index != -1 and frame_packet.points is not None:
self.all_ids[index] = frame_packet.points.point_id
self.all_img_loc[index] = frame_packet.points.img_loc
self.all_obj_loc[index] = frame_packet.points.obj_loc
Expand Down
Loading
Loading