Initial commit: Speckle-Scanner 3D pipeline with setup README
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
# 02 Calibration
|
||||
|
||||
Two-step calibration pipeline:
|
||||
|
||||
| Step | Script | What it does |
|
||||
|------|--------|--------------|
|
||||
| **1. Detection** | `detect_features.py` | Chessboard corners / IR ellipses → **JSON next to each image** |
|
||||
| **2. Calibration** | `calibrate.py` | Mono intrinsics per camera + stereo **lc vs rc/rg/ir** |
|
||||
|
||||
`main.py` runs both steps by default (`--step all`).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting flag
|
||||
|
||||
All calibration scripts accept `--troubleshooting` (default: **off**).
|
||||
|
||||
| `--troubleshooting` | Logs | Disk output |
|
||||
|---------------------|------|-------------|
|
||||
| **False** (default) | Minimal summary per camera / stereo pair | Step 1: `*.json` only (required for step 2). Step 2: **`params/` only** |
|
||||
| **True** | Detailed per-image / per-pair logs, progress bars | Step 1: + `corners/<camera>/` overlays. Step 2: + `pairing_reports/`, `rectified/` |
|
||||
|
||||
```bash
|
||||
# Default — minimal logs, only params/ from step 2
|
||||
python main.py --project Olsen_wings --date 2026-05-12 --calib_name calib1
|
||||
|
||||
# Debug — verbose logs + intermediate folders
|
||||
python main.py --project Olsen_wings --date 2026-05-12 --calib_name calib1 --troubleshooting
|
||||
```
|
||||
|
||||
Legacy mode (`--legacy`) also respects `--troubleshooting` (corners, local_coords, images_ncb, rectified).
|
||||
|
||||
---
|
||||
|
||||
## All CLI parameters (reference)
|
||||
|
||||
| Parameter | Default | Used in |
|
||||
|-----------|---------|---------|
|
||||
| `--project` | required | all |
|
||||
| `--date` | required | all |
|
||||
| `--calib_name` | `calib1` | all |
|
||||
| `--chessboard_size` | `8,7` | all |
|
||||
| `--square_size` | `0.045` | all |
|
||||
| `--left_chessboard_size` | = `--chessboard_size` | all |
|
||||
| `--right_chessboard_size` | = `--chessboard_size` | all |
|
||||
| `--left_square_size` | = `--square_size` | all |
|
||||
| `--right_square_size` | = `--square_size` | all |
|
||||
| `--preprocessing` | `None` | step 1 (`G`, `C`, `T` chain) |
|
||||
| `--cameras` | all present | `detect_features.py` |
|
||||
| `--ir_mode` | `auto` | step 1 (`auto` / `chessboard` / `ellipse`) |
|
||||
| `--step` | `all` | `main.py` (`detect`/`calibrate`/`all`); `calibrate.py` (`mono`/`stereo`/`all`) |
|
||||
| `--left_camera` | `lc` | step 2 stereo (`lc` / `lc-ir`) |
|
||||
| `--time_window` | `0.1` | step 2 stereo (seconds) |
|
||||
| `--partners` | `rc,rg,ir` | step 2 stereo |
|
||||
| `--legacy` | off | `main.py` only |
|
||||
| `--right_camera` | `rc` | `main.py --legacy` only |
|
||||
| `--troubleshooting` | off | all (`False` = minimal; `True` = debug output) |
|
||||
|
||||
---
|
||||
|
||||
## Folder structure
|
||||
|
||||
```
|
||||
~/Calib-data/<project>/<date>/<calib_name>/
|
||||
├── lc/
|
||||
│ ├── lc_1778599872850705.bmp
|
||||
│ └── lc_1778599872850705.json ← step 1 (always)
|
||||
├── rc/
|
||||
├── rg/ (or rgb/)
|
||||
├── ir/ (or IR/)
|
||||
├── corners/ ← step 1, only with --troubleshooting
|
||||
├── pairing_reports/ ← step 2, only with --troubleshooting
|
||||
├── rectified/ ← step 2, only with --troubleshooting
|
||||
└── params/ ← step 2 (always)
|
||||
├── lc_intrinsics.npz
|
||||
├── rc_intrinsics.npz
|
||||
├── lc-rc_parameters.npz
|
||||
├── lc-rc_stereo_cam_model.yaml
|
||||
├── lc-rc_Q.cvstore
|
||||
├── lc-rg_*
|
||||
└── lc-ir_*
|
||||
```
|
||||
|
||||
Nested layout (`<calib_name>/images/lc/`, …) is also supported.
|
||||
|
||||
---
|
||||
|
||||
## Quick start (full pipeline)
|
||||
|
||||
```bash
|
||||
cd ~/Speckle-Scanner/02_Calibration
|
||||
|
||||
python main.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--chessboard_size 8,7 --square_size 0.045
|
||||
```
|
||||
|
||||
Or run steps separately:
|
||||
|
||||
```bash
|
||||
# Step 1 — detect features, write JSON
|
||||
python detect_features.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--chessboard_size 8,7 --square_size 0.045
|
||||
|
||||
# Step 2 — calibrate from JSON (writes params/ only)
|
||||
python calibrate.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--chessboard_size 8,7 --square_size 0.045 \
|
||||
--time_window 0.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Feature detection (per camera)
|
||||
|
||||
For every image in each camera folder (`lc`, `rc`, `rg`, `ir`, `lc-ir`):
|
||||
|
||||
- Detects **chessboard corners** (default for lc/rc/rg)
|
||||
- For **IR**: tries chessboard first (`--ir_mode auto`), falls back to **ellipse center**
|
||||
- Writes `<image>.json` in the **same folder** as the image (always, even without `--troubleshooting`)
|
||||
|
||||
### LC only
|
||||
|
||||
```bash
|
||||
python detect_features.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--cameras lc \
|
||||
--chessboard_size 8,7 --square_size 0.045 \
|
||||
--left_chessboard_size 8,7 --left_square_size 0.045 \
|
||||
--preprocessing None
|
||||
```
|
||||
|
||||
### RC only
|
||||
|
||||
```bash
|
||||
python detect_features.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--cameras rc \
|
||||
--chessboard_size 8,7 --square_size 0.045 \
|
||||
--right_chessboard_size 8,7 --right_square_size 0.045 \
|
||||
--preprocessing None
|
||||
```
|
||||
|
||||
### RG only
|
||||
|
||||
```bash
|
||||
python detect_features.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--cameras rg \
|
||||
--chessboard_size 8,7 --square_size 0.045 \
|
||||
--right_chessboard_size 8,7 --right_square_size 0.045 \
|
||||
--preprocessing None
|
||||
```
|
||||
|
||||
### IR only
|
||||
|
||||
```bash
|
||||
python detect_features.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--cameras ir \
|
||||
--chessboard_size 8,7 --square_size 0.045 \
|
||||
--right_chessboard_size 8,7 --right_square_size 0.045 \
|
||||
--preprocessing C \
|
||||
--ir_mode auto
|
||||
```
|
||||
|
||||
### LC-IR folder only
|
||||
|
||||
```bash
|
||||
python detect_features.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--cameras lc-ir \
|
||||
--chessboard_size 8,7 --square_size 0.045 \
|
||||
--left_chessboard_size 8,7 --left_square_size 0.045 \
|
||||
--preprocessing None
|
||||
```
|
||||
|
||||
### All cameras
|
||||
|
||||
```bash
|
||||
python detect_features.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--chessboard_size 8,7 --square_size 0.045 \
|
||||
--left_chessboard_size 8,7 --left_square_size 0.045 \
|
||||
--right_chessboard_size 8,7 --right_square_size 0.045 \
|
||||
--preprocessing None \
|
||||
--ir_mode auto
|
||||
```
|
||||
|
||||
### Step 1 with troubleshooting
|
||||
|
||||
```bash
|
||||
python detect_features.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--cameras lc,rc,ir \
|
||||
--chessboard_size 8,7 --square_size 0.045 \
|
||||
--preprocessing C \
|
||||
--ir_mode auto \
|
||||
--troubleshooting
|
||||
```
|
||||
|
||||
### JSON contents (chessboard example)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"image": "lc_1778599872850705.bmp",
|
||||
"camera_folder": "lc",
|
||||
"feature_type": "chessboard",
|
||||
"success": true,
|
||||
"board_size": [8, 7],
|
||||
"square_size": 0.045,
|
||||
"timestamp_sec": 1778599872.850705,
|
||||
"pair_key": "1778599872850705",
|
||||
"corners": [[412.3, 287.1], ...]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Calibration
|
||||
|
||||
### 2a. Mono intrinsics
|
||||
|
||||
Reads chessboard JSONs from each camera folder, runs `cv2.calibrateCamera`, saves:
|
||||
|
||||
- `params/<camera>_intrinsics.npz`
|
||||
- `params/<camera>_intrinsics.yaml`
|
||||
|
||||
Requires **≥ 3** successful chessboard detections per camera.
|
||||
|
||||
### 2b. Stereo calibration
|
||||
|
||||
- **Left camera:** `lc` by default (`--left_camera`)
|
||||
- **Partners:** `rc`, `rg`, `ir` — each available folder is calibrated against lc
|
||||
- **Pairing:** time-window match (`--time_window`, default **0.1 s**), then filename `pair_key` fallback for IR scan ids
|
||||
- Uses mono intrinsics with `CALIB_FIX_INTRINSIC`
|
||||
- Saves `lc-rc_*`, `lc-rg_*`, `lc-ir_*` under `params/`
|
||||
|
||||
### Full step 2 (mono + all stereo pairs)
|
||||
|
||||
```bash
|
||||
python calibrate.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--step all \
|
||||
--left_camera lc \
|
||||
--partners rc,rg,ir \
|
||||
--time_window 0.1 \
|
||||
--chessboard_size 8,7 --square_size 0.045 \
|
||||
--left_chessboard_size 8,7 --left_square_size 0.045 \
|
||||
--right_chessboard_size 8,7 --right_square_size 0.045
|
||||
```
|
||||
|
||||
### Stereo: LC ↔ RC only
|
||||
|
||||
```bash
|
||||
python calibrate.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--step stereo \
|
||||
--left_camera lc \
|
||||
--partners rc \
|
||||
--time_window 0.1 \
|
||||
--chessboard_size 8,7 --square_size 0.045
|
||||
```
|
||||
|
||||
### Stereo: LC ↔ RG only
|
||||
|
||||
```bash
|
||||
python calibrate.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--step stereo \
|
||||
--left_camera lc \
|
||||
--partners rg \
|
||||
--time_window 0.1 \
|
||||
--chessboard_size 8,7 --square_size 0.045
|
||||
```
|
||||
|
||||
### Stereo: LC ↔ IR only
|
||||
|
||||
```bash
|
||||
python calibrate.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--step stereo \
|
||||
--left_camera lc \
|
||||
--partners ir \
|
||||
--time_window 0.1 \
|
||||
--chessboard_size 8,7 --square_size 0.045
|
||||
```
|
||||
|
||||
### Stereo: left = LC-IR folder
|
||||
|
||||
```bash
|
||||
python calibrate.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--step stereo \
|
||||
--left_camera lc-ir \
|
||||
--partners rc,rg,ir \
|
||||
--time_window 0.1 \
|
||||
--chessboard_size 8,7 --square_size 0.045 \
|
||||
--left_chessboard_size 8,7 --left_square_size 0.045 \
|
||||
--right_chessboard_size 8,7 --right_square_size 0.045
|
||||
```
|
||||
|
||||
### Mono intrinsics only
|
||||
|
||||
```bash
|
||||
python calibrate.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--step mono \
|
||||
--chessboard_size 8,7 --square_size 0.045 \
|
||||
--left_chessboard_size 8,7 --left_square_size 0.045 \
|
||||
--right_chessboard_size 8,7 --right_square_size 0.045
|
||||
```
|
||||
|
||||
### Step 2 with troubleshooting
|
||||
|
||||
```bash
|
||||
python calibrate.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--step all \
|
||||
--time_window 0.1 \
|
||||
--chessboard_size 8,7 --square_size 0.045 \
|
||||
--troubleshooting
|
||||
```
|
||||
|
||||
Writes `params/` plus `pairing_reports/<pair>.txt` and `rectified/<pair>/`.
|
||||
|
||||
---
|
||||
|
||||
## Full pipeline (`main.py`)
|
||||
|
||||
```bash
|
||||
python main.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--step all \
|
||||
--left_camera lc \
|
||||
--partners rc,rg,ir \
|
||||
--time_window 0.1 \
|
||||
--chessboard_size 8,7 --square_size 0.045 \
|
||||
--left_chessboard_size 8,7 --left_square_size 0.045 \
|
||||
--right_chessboard_size 8,7 --right_square_size 0.045 \
|
||||
--preprocessing None \
|
||||
--ir_mode auto
|
||||
```
|
||||
|
||||
With troubleshooting:
|
||||
|
||||
```bash
|
||||
python main.py \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--troubleshooting \
|
||||
--chessboard_size 8,7 --square_size 0.045
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Legacy one-shot mode
|
||||
|
||||
The old in-memory flow (single `--right_camera`, filename pairing) still works:
|
||||
|
||||
```bash
|
||||
# LC-RC
|
||||
python main.py --legacy \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--left_camera lc --right_camera rc \
|
||||
--chessboard_size 8,7 --square_size 0.045
|
||||
|
||||
# LC-RG
|
||||
python main.py --legacy \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--left_camera lc --right_camera rg \
|
||||
--chessboard_size 8,7 --square_size 0.045
|
||||
|
||||
# LC-IR
|
||||
python main.py --legacy \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--left_camera lc --right_camera ir \
|
||||
--chessboard_size 8,7 --square_size 0.045 \
|
||||
--preprocessing C
|
||||
|
||||
# LC-IR folder + IR partner (with debug output)
|
||||
python main.py --legacy \
|
||||
--project Olsen_wings --date 2026-05-12 --calib_name calib1 \
|
||||
--left_camera lc-ir --right_camera ir \
|
||||
--chessboard_size 8,7 --square_size 0.045 \
|
||||
--preprocessing C --troubleshooting
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
```bash
|
||||
pip install -r ~/Speckle-Scanner/02_Calibration/requirements.txt
|
||||
# or full pipeline:
|
||||
pip install -r ~/Speckle-Scanner/requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Stereo pairing uses **timestamps** parsed from filenames (`ts…` tokens or long numeric ids); `ck…` suffixes are ignored.
|
||||
- **Ellipse-only** IR JSONs are stored but cannot produce mono intrinsics (need full chessboard grids). Use chessboard IR images for calibration.
|
||||
- Per-camera board overrides apply to detection and calibration (`--left_chessboard_size`, etc.).
|
||||
- Re-run **step 1** if images change; re-run **step 2** freely when tuning `time_window` or partners.
|
||||
- With `--troubleshooting` off, step 2 writes **only** `params/` (no `pairing_reports/`, no `rectified/`).
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Step 2 — Calibration from per-image JSON feature files.
|
||||
|
||||
2a. Mono intrinsics per camera folder
|
||||
2b. Stereo calibration: left camera vs each available partner (rc, rg, ir)
|
||||
with time-window pairing (default 0.1 s)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path.home() / "Speckle-Scanner"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import argparse
|
||||
|
||||
from calibrationclasses.calibration_engine import (
|
||||
run_mono_calibration,
|
||||
run_stereo_calibration,
|
||||
)
|
||||
from calibrationclasses.cli_common import (
|
||||
add_board_args,
|
||||
add_session_args,
|
||||
add_troubleshooting_arg,
|
||||
build_board_config,
|
||||
resolve_input_path,
|
||||
)
|
||||
from calibrationclasses.session import STEREO_PARTNERS
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Calibration step 2: mono + stereo calibration from JSON"
|
||||
)
|
||||
add_session_args(parser)
|
||||
add_board_args(parser)
|
||||
parser.add_argument(
|
||||
"--step",
|
||||
choices=("mono", "stereo", "all"),
|
||||
default="all",
|
||||
help="Run mono intrinsics, stereo pairs, or both (default: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--left_camera",
|
||||
default="lc",
|
||||
choices=("lc", "lc-ir", "lc_ir"),
|
||||
help="Left camera for stereo calibration (default: lc)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--time_window",
|
||||
type=float,
|
||||
default=0.1,
|
||||
help="Max |t_left - t_right| in seconds for stereo pairing (default: 0.1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--partners",
|
||||
type=str,
|
||||
default="rc,rg,ir",
|
||||
help="Comma-separated right cameras for stereo (default: rc,rg,ir)",
|
||||
)
|
||||
add_troubleshooting_arg(parser)
|
||||
args = parser.parse_args()
|
||||
|
||||
left_camera = args.left_camera.lower().replace("_", "-")
|
||||
partners = tuple(p.strip() for p in args.partners.split(",") if p.strip())
|
||||
board_sizes, square_sizes = build_board_config(args)
|
||||
input_path = resolve_input_path(args)
|
||||
|
||||
print(f"[calibrate] session: {input_path}")
|
||||
|
||||
mono_results = {}
|
||||
if args.step in ("mono", "all"):
|
||||
print("\n=== Step 2a: Mono intrinsics ===")
|
||||
mono_results = run_mono_calibration(
|
||||
input_path,
|
||||
board_sizes,
|
||||
square_sizes,
|
||||
troubleshooting=args.troubleshooting,
|
||||
)
|
||||
|
||||
if args.step in ("stereo", "all"):
|
||||
print("\n=== Step 2b: Stereo calibration ===")
|
||||
if not mono_results and args.step == "stereo":
|
||||
mono_results = run_mono_calibration(
|
||||
input_path,
|
||||
board_sizes,
|
||||
square_sizes,
|
||||
troubleshooting=args.troubleshooting,
|
||||
)
|
||||
run_stereo_calibration(
|
||||
input_path,
|
||||
left_camera=left_camera,
|
||||
mono_results=mono_results,
|
||||
board_sizes=board_sizes,
|
||||
square_sizes=square_sizes,
|
||||
time_window_sec=args.time_window,
|
||||
partners=partners or STEREO_PARTNERS,
|
||||
troubleshooting=args.troubleshooting,
|
||||
)
|
||||
|
||||
print("[calibrate] done")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,424 @@
|
||||
"""Step 2: mono and stereo calibration from per-image JSON feature files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from calibrationclasses.feature_json import FeatureRecord, load_folder_features
|
||||
from calibrationclasses.pairing import StereoPair, build_stereo_pairs
|
||||
from calibrationclasses.session import (
|
||||
CameraFolder,
|
||||
discover_camera_folder,
|
||||
resolve_session_root,
|
||||
STEREO_PARTNERS,
|
||||
)
|
||||
|
||||
|
||||
def create_3d_board_points(board_size: Tuple[int, int], square_size: float) -> np.ndarray:
|
||||
pts = np.zeros((np.prod(board_size), 3), np.float32)
|
||||
pts[:, :2] = np.indices(board_size).T.reshape(-1, 2)
|
||||
pts *= square_size
|
||||
return pts
|
||||
|
||||
|
||||
def _image_size_from_records(records: List[FeatureRecord]) -> Tuple[int, int]:
|
||||
for record in records:
|
||||
img = cv2.imread(str(record.image_path))
|
||||
if img is not None:
|
||||
return img.shape[1], img.shape[0]
|
||||
raise RuntimeError("Could not determine image size from feature JSONs")
|
||||
|
||||
|
||||
def calibrate_camera_intrinsics(
|
||||
records: List[FeatureRecord],
|
||||
board_size: Tuple[int, int],
|
||||
square_size: float,
|
||||
) -> Dict:
|
||||
chess_records = [r for r in records if r.is_chessboard]
|
||||
if len(chess_records) < 3:
|
||||
raise RuntimeError(
|
||||
f"Need at least 3 chessboard detections for mono calibration, got {len(chess_records)}"
|
||||
)
|
||||
|
||||
image_size = _image_size_from_records(chess_records)
|
||||
objp = create_3d_board_points(board_size, square_size)
|
||||
obj_points = [objp for _ in chess_records]
|
||||
img_points = [r.corners for r in chess_records]
|
||||
|
||||
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
|
||||
obj_points, img_points, image_size, None, None, flags=0
|
||||
)
|
||||
|
||||
rmtx = []
|
||||
tmtx = []
|
||||
for k, r in enumerate(rvecs):
|
||||
rmtx.append(cv2.Rodrigues(r)[0])
|
||||
tmtx.append(np.vstack((np.hstack((rmtx[k], tvecs[k])), np.array([0, 0, 0, 1]))))
|
||||
|
||||
newmtx, roi = cv2.getOptimalNewCameraMatrix(mtx, dist, image_size, 1, image_size)
|
||||
if np.sum(roi) == 0:
|
||||
roi = (0, 0, image_size[0] - 1, image_size[1] - 1)
|
||||
|
||||
return {
|
||||
"Intrinsic": mtx,
|
||||
"Distortion": dist,
|
||||
"DistortionROI": roi,
|
||||
"DistortionIntrinsic": newmtx,
|
||||
"RotVektor": rvecs,
|
||||
"RotMatrix": rmtx,
|
||||
"Extrinsics": tmtx,
|
||||
"TransVektor": tvecs,
|
||||
"MeanError": float(ret),
|
||||
"image_size": image_size,
|
||||
"num_views": len(chess_records),
|
||||
}
|
||||
|
||||
|
||||
def save_mono_intrinsics(
|
||||
params_dir: Path,
|
||||
camera_name: str,
|
||||
intrinsics: Dict,
|
||||
*,
|
||||
troubleshooting: bool = False,
|
||||
) -> None:
|
||||
params_dir.mkdir(parents=True, exist_ok=True)
|
||||
tag = camera_name.replace("/", "-")
|
||||
npz_path = params_dir / f"{tag}_intrinsics.npz"
|
||||
np.savez(
|
||||
npz_path,
|
||||
Intrinsic=intrinsics["Intrinsic"],
|
||||
Distortion=intrinsics["Distortion"],
|
||||
DistortionIntrinsic=intrinsics["DistortionIntrinsic"],
|
||||
DistortionROI=intrinsics["DistortionROI"],
|
||||
MeanError=intrinsics["MeanError"],
|
||||
image_size=np.array(intrinsics["image_size"]),
|
||||
num_views=intrinsics["num_views"],
|
||||
)
|
||||
yaml_path = params_dir / f"{tag}_intrinsics.yaml"
|
||||
fs = cv2.FileStorage(str(yaml_path), cv2.FILE_STORAGE_WRITE)
|
||||
fs.write("Intrinsic", intrinsics["Intrinsic"])
|
||||
fs.write("Distortion", intrinsics["Distortion"])
|
||||
fs.write("DistortionIntrinsic", intrinsics["DistortionIntrinsic"])
|
||||
fs.release()
|
||||
if troubleshooting:
|
||||
print(f"[INFO] Saved mono intrinsics → {npz_path} and {yaml_path}")
|
||||
|
||||
|
||||
def run_mono_calibration(
|
||||
input_path: str | Path,
|
||||
board_sizes: Dict[str, Tuple[int, int]],
|
||||
square_sizes: Dict[str, float],
|
||||
cameras: Optional[List[str]] = None,
|
||||
troubleshooting: bool = False,
|
||||
) -> Dict[str, Dict]:
|
||||
session_root = resolve_session_root(input_path)
|
||||
params_dir = Path(input_path) / "params"
|
||||
results = {}
|
||||
|
||||
for logical_name, board_size in board_sizes.items():
|
||||
if cameras and logical_name not in cameras:
|
||||
continue
|
||||
cam = discover_camera_folder(session_root, logical_name)
|
||||
if cam is None:
|
||||
continue
|
||||
records = load_folder_features(cam.path)
|
||||
square_size = square_sizes[logical_name]
|
||||
try:
|
||||
intrinsics = calibrate_camera_intrinsics(records, board_size, square_size)
|
||||
save_mono_intrinsics(
|
||||
params_dir, logical_name, intrinsics, troubleshooting=troubleshooting
|
||||
)
|
||||
results[logical_name] = intrinsics
|
||||
print(
|
||||
f"[mono:{logical_name}] views={intrinsics['num_views']} "
|
||||
f"reproj_err={intrinsics['MeanError']:.4f}"
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
if troubleshooting:
|
||||
print(f"[SKIP mono:{logical_name}] {exc}")
|
||||
else:
|
||||
print(f"[mono:{logical_name}] skipped")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def calibrate_stereo_pair(
|
||||
pairs: List[StereoPair],
|
||||
left_intrinsics: Dict,
|
||||
right_intrinsics: Dict,
|
||||
board_size: Tuple[int, int],
|
||||
square_size: float,
|
||||
image_size: Tuple[int, int],
|
||||
) -> Dict:
|
||||
if not pairs:
|
||||
raise RuntimeError("No stereo pairs available")
|
||||
|
||||
objp = create_3d_board_points(board_size, square_size)
|
||||
obj_points = [objp for _ in pairs]
|
||||
left_img_points = [p.left.corners for p in pairs]
|
||||
right_img_points = [p.right.corners for p in pairs]
|
||||
|
||||
flags = cv2.CALIB_FIX_INTRINSIC
|
||||
criteria = (cv2.TERM_CRITERIA_MAX_ITER + cv2.TERM_CRITERIA_EPS, 30, 0.001)
|
||||
|
||||
ret_stereo, _, _, _, _, rot, trans, essential, fundamental = cv2.stereoCalibrate(
|
||||
obj_points,
|
||||
left_img_points,
|
||||
right_img_points,
|
||||
left_intrinsics["Intrinsic"],
|
||||
left_intrinsics["Distortion"],
|
||||
right_intrinsics["Intrinsic"],
|
||||
right_intrinsics["Distortion"],
|
||||
image_size,
|
||||
criteria=criteria,
|
||||
flags=flags,
|
||||
)
|
||||
|
||||
R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify(
|
||||
left_intrinsics["Intrinsic"],
|
||||
left_intrinsics["Distortion"],
|
||||
right_intrinsics["Intrinsic"],
|
||||
right_intrinsics["Distortion"],
|
||||
image_size,
|
||||
rot,
|
||||
trans,
|
||||
flags=0,
|
||||
alpha=1,
|
||||
)
|
||||
|
||||
T = np.vstack((np.hstack((rot, trans)), np.array([0, 0, 0, 1])))
|
||||
Q_clean = np.array(Q, dtype=np.float64)
|
||||
|
||||
parameters = {
|
||||
"Translation": trans,
|
||||
"Rotation": rot,
|
||||
"Transformation": T,
|
||||
"Essential": essential,
|
||||
"Fundamental": fundamental,
|
||||
"MeanError": float(ret_stereo),
|
||||
"SquareSize": square_size,
|
||||
"BoardSize": board_size,
|
||||
"Objpoints": objp,
|
||||
"Q": Q_clean,
|
||||
"num_pairs": len(pairs),
|
||||
"L_Intrinsic": left_intrinsics["Intrinsic"],
|
||||
"L_Distortion": left_intrinsics["Distortion"],
|
||||
"L_DistortionIntrinsic": left_intrinsics["DistortionIntrinsic"],
|
||||
"R_Intrinsic": right_intrinsics["Intrinsic"],
|
||||
"R_Distortion": right_intrinsics["Distortion"],
|
||||
"R_DistortionIntrinsic": right_intrinsics["DistortionIntrinsic"],
|
||||
"L_Imgpoints": left_img_points,
|
||||
"R_Imgpoints": right_img_points,
|
||||
"R1": R1,
|
||||
"R2": R2,
|
||||
"P1": P1,
|
||||
"P2": P2,
|
||||
"image_size": image_size,
|
||||
}
|
||||
return parameters
|
||||
|
||||
|
||||
def save_stereo_calibration(
|
||||
input_path: str | Path,
|
||||
pair_tag: str,
|
||||
parameters: Dict,
|
||||
*,
|
||||
troubleshooting: bool = False,
|
||||
) -> None:
|
||||
params_dir = Path(input_path) / "params"
|
||||
params_dir.mkdir(parents=True, exist_ok=True)
|
||||
Q_clean = np.array(parameters["Q"], dtype=np.float64)
|
||||
|
||||
npz_path = params_dir / f"{pair_tag}_parameters.npz"
|
||||
save_kwargs = {k: v for k, v in parameters.items() if k not in ("R1", "R2", "P1", "P2")}
|
||||
np.savez(npz_path, **save_kwargs)
|
||||
if troubleshooting:
|
||||
print(f"[INFO] Saved NPZ → {npz_path}")
|
||||
|
||||
yaml_path = params_dir / f"{pair_tag}_stereo_cam_model.yaml"
|
||||
fs = cv2.FileStorage(str(yaml_path), cv2.FILE_STORAGE_WRITE)
|
||||
fs.write("L_DistortionIntrinsic", parameters["L_DistortionIntrinsic"])
|
||||
fs.write("L_Intrinsic", parameters["L_Intrinsic"])
|
||||
fs.write("L_Distortion", parameters["L_Distortion"])
|
||||
fs.write("R_DistortionIntrinsic", parameters["R_DistortionIntrinsic"])
|
||||
fs.write("R_Intrinsic", parameters["R_Intrinsic"])
|
||||
fs.write("R_Distortion", parameters["R_Distortion"])
|
||||
fs.write("Rotation", parameters["Transformation"][:3, :3])
|
||||
fs.write("Translation", parameters["Transformation"][:3, 3:])
|
||||
fs.write("Q", Q_clean)
|
||||
fs.release()
|
||||
if troubleshooting:
|
||||
print(f"[INFO] Saved YAML → {yaml_path}")
|
||||
|
||||
cvstore_path = params_dir / f"{pair_tag}_Q.cvstore"
|
||||
fs2 = cv2.FileStorage(str(cvstore_path), cv2.FILE_STORAGE_WRITE)
|
||||
fs2.write("Q", Q_clean)
|
||||
fs2.release()
|
||||
if troubleshooting:
|
||||
print(f"[INFO] Saved Q → {cvstore_path}")
|
||||
|
||||
|
||||
def save_pairing_report(
|
||||
input_path: str | Path,
|
||||
pair_tag: str,
|
||||
pairs: List[StereoPair],
|
||||
) -> Path:
|
||||
report_dir = Path(input_path) / "pairing_reports"
|
||||
report_dir.mkdir(parents=True, exist_ok=True)
|
||||
report_path = report_dir / f"{pair_tag}.txt"
|
||||
lines = [
|
||||
f"# stereo pairs for {pair_tag}",
|
||||
f"# total={len(pairs)}",
|
||||
"left_image\tright_image\tdelta_sec\tmethod",
|
||||
]
|
||||
for pair in pairs:
|
||||
lines.append(
|
||||
f"{pair.left.image_path.name}\t{pair.right.image_path.name}\t"
|
||||
f"{pair.delta_sec:.6f}\t{pair.method}"
|
||||
)
|
||||
report_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(f"[INFO] Pairing report → {report_path}")
|
||||
return report_path
|
||||
|
||||
|
||||
def save_rectified_pairs(
|
||||
input_path: str | Path,
|
||||
pair_tag: str,
|
||||
pairs: List[StereoPair],
|
||||
parameters: Dict,
|
||||
left_folder: str,
|
||||
right_folder: str,
|
||||
) -> None:
|
||||
image_size = parameters["image_size"]
|
||||
R1, R2, P1, P2 = parameters["R1"], parameters["R2"], parameters["P1"], parameters["P2"]
|
||||
map_left = cv2.initUndistortRectifyMap(
|
||||
parameters["L_Intrinsic"],
|
||||
parameters["L_Distortion"],
|
||||
R1,
|
||||
P1,
|
||||
image_size,
|
||||
cv2.CV_32FC1,
|
||||
)
|
||||
map_right = cv2.initUndistortRectifyMap(
|
||||
parameters["R_Intrinsic"],
|
||||
parameters["R_Distortion"],
|
||||
R2,
|
||||
P2,
|
||||
image_size,
|
||||
cv2.CV_32FC1,
|
||||
)
|
||||
|
||||
out_left = Path(input_path) / "rectified" / pair_tag / left_folder
|
||||
out_right = Path(input_path) / "rectified" / pair_tag / right_folder
|
||||
out_left.mkdir(parents=True, exist_ok=True)
|
||||
out_right.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
saved = 0
|
||||
for pair in pairs:
|
||||
left_img = cv2.imread(str(pair.left.image_path))
|
||||
right_img = cv2.imread(str(pair.right.image_path))
|
||||
if left_img is None or right_img is None:
|
||||
continue
|
||||
left_rect = cv2.remap(left_img, map_left[0], map_left[1], cv2.INTER_LINEAR)
|
||||
right_rect = cv2.remap(right_img, map_right[0], map_right[1], cv2.INTER_LINEAR)
|
||||
cv2.imwrite(str(out_left / pair.left.image_path.name), left_rect)
|
||||
cv2.imwrite(str(out_right / pair.right.image_path.name), right_rect)
|
||||
saved += 1
|
||||
print(f"[INFO] Rectified {saved}/{len(pairs)} pairs → {out_left.parent}")
|
||||
|
||||
|
||||
def run_stereo_calibration(
|
||||
input_path: str | Path,
|
||||
left_camera: str,
|
||||
mono_results: Dict[str, Dict],
|
||||
board_sizes: Dict[str, Tuple[int, int]],
|
||||
square_sizes: Dict[str, float],
|
||||
time_window_sec: float = 0.1,
|
||||
partners: Tuple[str, ...] = STEREO_PARTNERS,
|
||||
troubleshooting: bool = False,
|
||||
) -> None:
|
||||
session_root = resolve_session_root(input_path)
|
||||
left_cam = discover_camera_folder(session_root, left_camera)
|
||||
if left_cam is None:
|
||||
raise FileNotFoundError(f"Left camera folder {left_camera!r} not found")
|
||||
|
||||
if left_camera not in mono_results:
|
||||
raise RuntimeError(
|
||||
f"No mono intrinsics for {left_camera}. Run mono calibration first."
|
||||
)
|
||||
|
||||
left_records = load_folder_features(left_cam.path)
|
||||
left_board = board_sizes[left_camera]
|
||||
left_square = square_sizes[left_camera]
|
||||
image_size = mono_results[left_camera]["image_size"]
|
||||
|
||||
for partner in partners:
|
||||
right_cam = discover_camera_folder(session_root, partner)
|
||||
if right_cam is None:
|
||||
if troubleshooting:
|
||||
print(f"[SKIP stereo:{left_camera}-{partner}] folder not found")
|
||||
continue
|
||||
if partner not in mono_results:
|
||||
if troubleshooting:
|
||||
print(
|
||||
f"[SKIP stereo:{left_camera}-{partner}] "
|
||||
f"no mono intrinsics for {partner}"
|
||||
)
|
||||
continue
|
||||
|
||||
right_records = load_folder_features(right_cam.path)
|
||||
pairs = build_stereo_pairs(left_records, right_records, time_window_sec)
|
||||
pair_tag = f"{left_camera}-{partner}"
|
||||
|
||||
if not pairs:
|
||||
if troubleshooting:
|
||||
print(
|
||||
f"[SKIP stereo:{pair_tag}] no valid pairs "
|
||||
f"(time_window={time_window_sec}s)"
|
||||
)
|
||||
continue
|
||||
|
||||
time_n = sum(1 for p in pairs if p.method == "time_window")
|
||||
key_n = sum(1 for p in pairs if p.method == "pair_key")
|
||||
if troubleshooting:
|
||||
print(
|
||||
f"[stereo:{pair_tag}] {len(pairs)} pairs "
|
||||
f"(time_window={time_n}, pair_key={key_n})"
|
||||
)
|
||||
save_pairing_report(input_path, pair_tag, pairs)
|
||||
|
||||
try:
|
||||
params = calibrate_stereo_pair(
|
||||
pairs,
|
||||
mono_results[left_camera],
|
||||
mono_results[partner],
|
||||
left_board,
|
||||
left_square,
|
||||
image_size,
|
||||
)
|
||||
save_stereo_calibration(
|
||||
input_path, pair_tag, params, troubleshooting=troubleshooting
|
||||
)
|
||||
print(
|
||||
f"[stereo:{pair_tag}] pairs={params['num_pairs']} "
|
||||
f"reproj_err={params['MeanError']:.4f}"
|
||||
)
|
||||
if troubleshooting:
|
||||
save_rectified_pairs(
|
||||
input_path,
|
||||
pair_tag,
|
||||
pairs,
|
||||
params,
|
||||
left_cam.folder_name,
|
||||
right_cam.folder_name,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
if troubleshooting:
|
||||
print(f"[FAIL stereo:{pair_tag}] {exc}")
|
||||
else:
|
||||
print(f"[stereo:{pair_tag}] failed")
|
||||
@@ -0,0 +1,50 @@
|
||||
import numpy as np
|
||||
import matplotlib as mpl
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.patches import Patch
|
||||
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
||||
|
||||
class CameraPoseVisualizer:
|
||||
def __init__(self, xlim, ylim, zlim):
|
||||
self.fig = plt.figure(figsize=(18, 7))
|
||||
self.ax = self.fig.add_subplot(projection='3d')
|
||||
self.ax.set_aspect("auto")
|
||||
self.ax.set_xlim(xlim)
|
||||
self.ax.set_ylim(ylim)
|
||||
self.ax.set_zlim(zlim)
|
||||
self.ax.set_xlabel('x')
|
||||
self.ax.set_ylabel('y')
|
||||
self.ax.set_zlabel('z')
|
||||
print('initialize camera pose visualizer')
|
||||
|
||||
def extrinsic2pyramid(self, extrinsic, color='r', focal_len_scaled=5, aspect_ratio=0.3):
|
||||
vertex_std = np.array([[0, 0, 0, 1],
|
||||
[focal_len_scaled * aspect_ratio, -focal_len_scaled * aspect_ratio, focal_len_scaled, 1],
|
||||
[focal_len_scaled * aspect_ratio, focal_len_scaled * aspect_ratio, focal_len_scaled, 1],
|
||||
[-focal_len_scaled * aspect_ratio, focal_len_scaled * aspect_ratio, focal_len_scaled, 1],
|
||||
[-focal_len_scaled * aspect_ratio, -focal_len_scaled * aspect_ratio, focal_len_scaled, 1]])
|
||||
vertex_transformed = vertex_std @ extrinsic.T
|
||||
meshes = [[vertex_transformed[0, :-1], vertex_transformed[1][:-1], vertex_transformed[2, :-1]],
|
||||
[vertex_transformed[0, :-1], vertex_transformed[2, :-1], vertex_transformed[3, :-1]],
|
||||
[vertex_transformed[0, :-1], vertex_transformed[3, :-1], vertex_transformed[4, :-1]],
|
||||
[vertex_transformed[0, :-1], vertex_transformed[4, :-1], vertex_transformed[1, :-1]],
|
||||
[vertex_transformed[1, :-1], vertex_transformed[2, :-1], vertex_transformed[3, :-1], vertex_transformed[4, :-1]]]
|
||||
self.ax.add_collection3d(
|
||||
Poly3DCollection(meshes, facecolors=color, linewidths=0.3, edgecolors=color, alpha=0.35))
|
||||
|
||||
def customize_legend(self, list_label):
|
||||
list_handle = []
|
||||
for idx, label in enumerate(list_label):
|
||||
color = plt.cm.rainbow(idx / len(list_label))
|
||||
patch = Patch(color=color, label=label)
|
||||
list_handle.append(patch)
|
||||
plt.legend(loc='right', bbox_to_anchor=(1.8, 0.5), handles=list_handle)
|
||||
|
||||
def colorbar(self, max_frame_length):
|
||||
cmap = mpl.cm.rainbow
|
||||
norm = mpl.colors.Normalize(vmin=0, vmax=max_frame_length)
|
||||
self.fig.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap), orientation='vertical', label='Frame Number')
|
||||
|
||||
def show(self):
|
||||
plt.title('Extrinsic Parameters')
|
||||
plt.show()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Shared CLI helpers for calibration scripts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import config
|
||||
|
||||
|
||||
def parse_chessboard_size(value: str) -> Tuple[int, int]:
|
||||
parts = value.split(",")
|
||||
if len(parts) != 2:
|
||||
raise argparse.ArgumentTypeError(
|
||||
"chessboard size must be width,height (e.g. 8,7)"
|
||||
)
|
||||
return tuple(map(int, parts))
|
||||
|
||||
|
||||
def add_session_args(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--project", required=True, help="Project name (e.g. Olsen_wings)")
|
||||
parser.add_argument("--date", required=True, help="Date string (e.g. 2026-05-12)")
|
||||
parser.add_argument(
|
||||
"--calib_name", default="calib1", help="Calibration folder name (default: calib1)"
|
||||
)
|
||||
|
||||
|
||||
def add_board_args(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--chessboard_size",
|
||||
type=parse_chessboard_size,
|
||||
default="8,7",
|
||||
help="Default inner corner grid width,height",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--square_size",
|
||||
type=float,
|
||||
default=0.045,
|
||||
help="Default chessboard square size in metres",
|
||||
)
|
||||
parser.add_argument("--left_chessboard_size", type=parse_chessboard_size, default=None)
|
||||
parser.add_argument("--right_chessboard_size", type=parse_chessboard_size, default=None)
|
||||
parser.add_argument("--left_square_size", type=float, default=None)
|
||||
parser.add_argument("--right_square_size", type=float, default=None)
|
||||
parser.add_argument(
|
||||
"--preprocessing",
|
||||
type=str,
|
||||
default="None",
|
||||
help="Pre-detection chain: G=gray, C=CLAHE, T=threshold (e.g. C, GC)",
|
||||
)
|
||||
|
||||
|
||||
def add_troubleshooting_arg(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--troubleshooting",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Verbose logs and intermediate debug files (corners/, pairing_reports/, "
|
||||
"rectified/). Default: minimal logs; step 2 writes only params/"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def resolve_input_path(args) -> Path:
|
||||
return config.CALIB_DATA_DIR / args.project / args.date / args.calib_name
|
||||
|
||||
|
||||
def build_board_config(args) -> Tuple[Dict[str, Tuple[int, int]], Dict[str, float]]:
|
||||
default_board = args.chessboard_size
|
||||
default_square = args.square_size
|
||||
left_board = args.left_chessboard_size or default_board
|
||||
right_board = args.right_chessboard_size or default_board
|
||||
left_square = args.left_square_size if args.left_square_size is not None else default_square
|
||||
right_square = (
|
||||
args.right_square_size if args.right_square_size is not None else default_square
|
||||
)
|
||||
|
||||
board_sizes = {
|
||||
"lc": left_board,
|
||||
"lc-ir": left_board,
|
||||
"rc": right_board,
|
||||
"rg": right_board,
|
||||
"ir": right_board,
|
||||
}
|
||||
square_sizes = {
|
||||
"lc": left_square,
|
||||
"lc-ir": left_square,
|
||||
"rc": right_square,
|
||||
"rg": right_square,
|
||||
"ir": right_square,
|
||||
}
|
||||
return board_sizes, square_sizes
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Step 1: detect chessboard corners / ellipse centers and write per-image JSON."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from tqdm import tqdm
|
||||
|
||||
from calibrationclasses.feature_json import FeatureRecord, save_feature_json
|
||||
from calibrationclasses.preprocessing import Preprocessing
|
||||
from calibrationclasses.session import (
|
||||
CameraFolder,
|
||||
json_path_for_image,
|
||||
list_cameras_present,
|
||||
list_image_paths,
|
||||
resolve_session_root,
|
||||
)
|
||||
from calibrationclasses.timestamp import parse_pair_key, parse_timestamp_sec
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectionConfig:
|
||||
chessboard_size: Tuple[int, int] = (8, 7)
|
||||
square_size: float = 0.045
|
||||
preprocessing: str = "None"
|
||||
ir_mode: str = "auto" # auto | chessboard | ellipse
|
||||
troubleshooting: bool = False
|
||||
|
||||
|
||||
class FeatureDetector:
|
||||
def __init__(self, config: DetectionConfig, corners_root: Optional[Path] = None):
|
||||
self.config = config
|
||||
self._preprocessor = Preprocessing()
|
||||
self.corners_root = corners_root
|
||||
|
||||
def _preprocessing_enabled(self) -> bool:
|
||||
spec = (self.config.preprocessing or "").strip().lower()
|
||||
return bool(spec) and spec not in ("none", "off", "false", "0")
|
||||
|
||||
def _preprocess(self, image: np.ndarray) -> np.ndarray:
|
||||
if image is None or not self._preprocessing_enabled():
|
||||
return image
|
||||
spec = (
|
||||
(self.config.preprocessing or "")
|
||||
.strip()
|
||||
.lower()
|
||||
.replace("none", "")
|
||||
.replace(",", "")
|
||||
.replace(" ", "")
|
||||
)
|
||||
out = image
|
||||
pp = self._preprocessor
|
||||
for ch in spec:
|
||||
if ch == "g":
|
||||
g = pp.gray(out)
|
||||
out = cv2.cvtColor(g, cv2.COLOR_GRAY2BGR)
|
||||
elif ch == "c":
|
||||
c = pp.clahe(out)
|
||||
out = cv2.cvtColor(c, cv2.COLOR_GRAY2BGR)
|
||||
elif ch == "t":
|
||||
t = pp.threshold(out)
|
||||
out = cv2.cvtColor(t, cv2.COLOR_GRAY2BGR)
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _to_gray(image: np.ndarray) -> np.ndarray:
|
||||
if len(image.shape) == 2:
|
||||
return image
|
||||
return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
def detect_chessboard(
|
||||
self, image: np.ndarray, board_size: Tuple[int, int]
|
||||
) -> Optional[np.ndarray]:
|
||||
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
|
||||
found, corners = cv2.findChessboardCorners(image, board_size, None)
|
||||
if not found:
|
||||
return None
|
||||
corners = cv2.cornerSubPix(
|
||||
self._to_gray(image), corners, (11, 11), (-1, -1), criteria
|
||||
)
|
||||
return corners
|
||||
|
||||
def detect_ellipse(self, image: np.ndarray):
|
||||
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
||||
enhanced = clahe.apply(gray)
|
||||
blurred = cv2.GaussianBlur(enhanced, (5, 5), 0)
|
||||
_, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||||
if np.sum(binary == 255) / binary.size > 0.5:
|
||||
binary = cv2.bitwise_not(binary)
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9))
|
||||
closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
|
||||
contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
if not contours:
|
||||
return None
|
||||
valid = []
|
||||
for cnt in contours:
|
||||
area = cv2.contourArea(cnt)
|
||||
if area < 100:
|
||||
continue
|
||||
x, y, w, h = cv2.boundingRect(cnt)
|
||||
if 0.75 < (w / h) < 1.25:
|
||||
valid.append(cnt)
|
||||
if not valid:
|
||||
return None
|
||||
best = max(valid, key=cv2.contourArea)
|
||||
if len(best) < 5:
|
||||
return None
|
||||
ellipse = cv2.fitEllipse(best)
|
||||
(cx, cy), (major, minor), angle = ellipse
|
||||
return (cx, cy), {
|
||||
"center": [float(cx), float(cy)],
|
||||
"axes": [float(major), float(minor)],
|
||||
"angle": float(angle),
|
||||
}
|
||||
|
||||
def _save_corner_overlay(
|
||||
self,
|
||||
image: np.ndarray,
|
||||
record: FeatureRecord,
|
||||
board_size: Tuple[int, int],
|
||||
) -> None:
|
||||
if self.corners_root is None:
|
||||
return
|
||||
out_dir = self.corners_root / record.camera_folder
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
vis = image.copy()
|
||||
if record.feature_type == "chessboard" and record.corners is not None:
|
||||
vis = cv2.drawChessboardCorners(vis, board_size, record.corners, True)
|
||||
elif record.feature_type == "ellipse" and record.center is not None:
|
||||
cx, cy = record.center
|
||||
cv2.circle(vis, (int(cx), int(cy)), 12, (0, 255, 0), 2)
|
||||
out_path = out_dir / record.image_path.name
|
||||
cv2.imwrite(str(out_path), vis)
|
||||
|
||||
def process_image(
|
||||
self,
|
||||
image_path: Path,
|
||||
camera: CameraFolder,
|
||||
board_size: Optional[Tuple[int, int]] = None,
|
||||
square_size: Optional[float] = None,
|
||||
) -> FeatureRecord:
|
||||
board_size = board_size or self.config.chessboard_size
|
||||
square_size = square_size if square_size is not None else self.config.square_size
|
||||
json_path = json_path_for_image(image_path)
|
||||
|
||||
base = FeatureRecord(
|
||||
image_path=image_path,
|
||||
json_path=json_path,
|
||||
camera_folder=camera.folder_name,
|
||||
feature_type="unknown",
|
||||
success=False,
|
||||
preprocessing=self.config.preprocessing,
|
||||
timestamp_sec=parse_timestamp_sec(image_path.name),
|
||||
pair_key=parse_pair_key(image_path.name),
|
||||
)
|
||||
|
||||
image = cv2.imread(str(image_path))
|
||||
if image is None:
|
||||
base.error = "failed to load image"
|
||||
save_feature_json(base)
|
||||
return base
|
||||
|
||||
proc = self._preprocess(image)
|
||||
use_ellipse = camera.logical_name == "ir" and self.config.ir_mode in (
|
||||
"ellipse",
|
||||
"auto",
|
||||
)
|
||||
|
||||
if not use_ellipse or self.config.ir_mode == "auto":
|
||||
corners = self.detect_chessboard(proc, board_size)
|
||||
if corners is not None:
|
||||
record = FeatureRecord(
|
||||
image_path=image_path,
|
||||
json_path=json_path,
|
||||
camera_folder=camera.folder_name,
|
||||
feature_type="chessboard",
|
||||
success=True,
|
||||
board_size=board_size,
|
||||
square_size=square_size,
|
||||
corners=corners,
|
||||
preprocessing=self.config.preprocessing,
|
||||
timestamp_sec=base.timestamp_sec,
|
||||
pair_key=base.pair_key,
|
||||
)
|
||||
save_feature_json(record)
|
||||
if self.config.troubleshooting:
|
||||
self._save_corner_overlay(image, record, board_size)
|
||||
return record
|
||||
|
||||
if use_ellipse:
|
||||
result = self.detect_ellipse(image)
|
||||
if result is not None:
|
||||
(cx, cy), ellipse = result
|
||||
record = FeatureRecord(
|
||||
image_path=image_path,
|
||||
json_path=json_path,
|
||||
camera_folder=camera.folder_name,
|
||||
feature_type="ellipse",
|
||||
success=True,
|
||||
center=(cx, cy),
|
||||
ellipse=ellipse,
|
||||
preprocessing=self.config.preprocessing,
|
||||
timestamp_sec=base.timestamp_sec,
|
||||
pair_key=base.pair_key,
|
||||
)
|
||||
save_feature_json(record)
|
||||
if self.config.troubleshooting:
|
||||
self._save_corner_overlay(image, record, board_size)
|
||||
return record
|
||||
|
||||
base.feature_type = "chessboard" if not use_ellipse else "ellipse"
|
||||
base.error = "no features detected"
|
||||
save_feature_json(base)
|
||||
if self.config.troubleshooting:
|
||||
print(f"[detect] FAIL {image_path.name}: {base.error}")
|
||||
return base
|
||||
|
||||
def process_camera(
|
||||
self,
|
||||
camera: CameraFolder,
|
||||
board_size: Optional[Tuple[int, int]] = None,
|
||||
square_size: Optional[float] = None,
|
||||
) -> Tuple[int, int]:
|
||||
images = list_image_paths(camera.path)
|
||||
if not images:
|
||||
print(f"[WARN] No images in {camera.path}")
|
||||
return 0, 0
|
||||
|
||||
detected = 0
|
||||
iterator = (
|
||||
tqdm(images, unit="img", dynamic_ncols=True)
|
||||
if self.config.troubleshooting
|
||||
else images
|
||||
)
|
||||
for image_path in iterator:
|
||||
record = self.process_image(
|
||||
image_path, camera, board_size=board_size, square_size=square_size
|
||||
)
|
||||
if record.success:
|
||||
detected += 1
|
||||
if self.config.troubleshooting and hasattr(iterator, "set_description"):
|
||||
iterator.set_description(
|
||||
f"{camera.logical_name} | detected {detected}/{len(images)}"
|
||||
)
|
||||
print(f"[{camera.logical_name}] {detected}/{len(images)} features detected")
|
||||
return detected, len(images)
|
||||
|
||||
|
||||
def run_detection(
|
||||
input_path: str | Path,
|
||||
config: DetectionConfig,
|
||||
cameras: Optional[list[str]] = None,
|
||||
per_camera_board: Optional[dict] = None,
|
||||
) -> None:
|
||||
session_root = resolve_session_root(input_path)
|
||||
present = list_cameras_present(session_root)
|
||||
if cameras:
|
||||
wanted = set(cameras)
|
||||
present = [c for c in present if c.logical_name in wanted]
|
||||
|
||||
if not present:
|
||||
raise FileNotFoundError(f"No camera folders found under {session_root}")
|
||||
|
||||
corners_root = None
|
||||
if config.troubleshooting:
|
||||
corners_root = Path(input_path) / "corners"
|
||||
print(f"[detect] troubleshooting: corner overlays → {corners_root}")
|
||||
|
||||
detector = FeatureDetector(config, corners_root=corners_root)
|
||||
per_camera_board = per_camera_board or {}
|
||||
|
||||
for camera in present:
|
||||
board = per_camera_board.get(camera.logical_name, {}).get("board_size")
|
||||
square = per_camera_board.get(camera.logical_name, {}).get("square_size")
|
||||
detector.process_camera(camera, board_size=board, square_size=square)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""JSON schema for per-image feature detection results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
FEATURE_JSON_VERSION = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureRecord:
|
||||
image_path: Path
|
||||
json_path: Path
|
||||
camera_folder: str
|
||||
feature_type: str
|
||||
success: bool
|
||||
board_size: Optional[Tuple[int, int]] = None
|
||||
square_size: Optional[float] = None
|
||||
corners: Optional[np.ndarray] = None # Nx1x2 float32
|
||||
center: Optional[Tuple[float, float]] = None
|
||||
ellipse: Optional[Dict[str, Any]] = None
|
||||
timestamp_sec: Optional[float] = None
|
||||
pair_key: Optional[str] = None
|
||||
preprocessing: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_chessboard(self) -> bool:
|
||||
return self.success and self.feature_type == "chessboard" and self.corners is not None
|
||||
|
||||
@property
|
||||
def corner_count(self) -> int:
|
||||
if self.corners is None:
|
||||
return 0
|
||||
return int(self.corners.shape[0])
|
||||
|
||||
|
||||
def corners_to_list(corners: np.ndarray) -> List[List[float]]:
|
||||
flat = corners.reshape(-1, 2)
|
||||
return [[float(x), float(y)] for x, y in flat]
|
||||
|
||||
|
||||
def corners_from_list(data: List[List[float]]) -> np.ndarray:
|
||||
arr = np.array(data, dtype=np.float32).reshape(-1, 1, 2)
|
||||
return arr
|
||||
|
||||
|
||||
def save_feature_json(record: FeatureRecord) -> None:
|
||||
payload: Dict[str, Any] = {
|
||||
"version": FEATURE_JSON_VERSION,
|
||||
"image": record.image_path.name,
|
||||
"camera_folder": record.camera_folder,
|
||||
"feature_type": record.feature_type,
|
||||
"success": record.success,
|
||||
"preprocessing": record.preprocessing,
|
||||
"timestamp_sec": record.timestamp_sec,
|
||||
"pair_key": record.pair_key,
|
||||
}
|
||||
if record.board_size is not None:
|
||||
payload["board_size"] = [int(record.board_size[0]), int(record.board_size[1])]
|
||||
if record.square_size is not None:
|
||||
payload["square_size"] = float(record.square_size)
|
||||
if record.corners is not None:
|
||||
payload["corners"] = corners_to_list(record.corners)
|
||||
if record.center is not None:
|
||||
payload["center"] = [float(record.center[0]), float(record.center[1])]
|
||||
if record.ellipse is not None:
|
||||
payload["ellipse"] = record.ellipse
|
||||
if record.error:
|
||||
payload["error"] = record.error
|
||||
|
||||
record.json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(record.json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
|
||||
|
||||
def load_feature_json(json_path: Path, image_path: Optional[Path] = None) -> FeatureRecord:
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if image_path is not None:
|
||||
img = Path(image_path)
|
||||
else:
|
||||
stem = json_path.stem
|
||||
parent = json_path.parent
|
||||
img = parent / data.get("image", stem)
|
||||
if not img.exists():
|
||||
for ext in (".bmp", ".png", ".jpg", ".jpeg"):
|
||||
candidate = parent / f"{stem}{ext}"
|
||||
if candidate.exists():
|
||||
img = candidate
|
||||
break
|
||||
|
||||
board_size = None
|
||||
if "board_size" in data and data["board_size"]:
|
||||
board_size = (int(data["board_size"][0]), int(data["board_size"][1]))
|
||||
|
||||
corners = None
|
||||
if data.get("corners"):
|
||||
corners = corners_from_list(data["corners"])
|
||||
|
||||
center = None
|
||||
if data.get("center"):
|
||||
center = (float(data["center"][0]), float(data["center"][1]))
|
||||
|
||||
return FeatureRecord(
|
||||
image_path=Path(img),
|
||||
json_path=Path(json_path),
|
||||
camera_folder=data.get("camera_folder", ""),
|
||||
feature_type=data.get("feature_type", "unknown"),
|
||||
success=bool(data.get("success", False)),
|
||||
board_size=board_size,
|
||||
square_size=data.get("square_size"),
|
||||
corners=corners,
|
||||
center=center,
|
||||
ellipse=data.get("ellipse"),
|
||||
timestamp_sec=data.get("timestamp_sec"),
|
||||
pair_key=data.get("pair_key"),
|
||||
preprocessing=data.get("preprocessing"),
|
||||
error=data.get("error"),
|
||||
)
|
||||
|
||||
|
||||
def load_folder_features(camera_dir: Path) -> List[FeatureRecord]:
|
||||
records = []
|
||||
for json_path in sorted(camera_dir.glob("*.json")):
|
||||
try:
|
||||
records.append(load_feature_json(json_path))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
print(f"[WARN] Skipping invalid JSON {json_path}: {exc}")
|
||||
return records
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Stereo pair building: time-window matching with filename-key fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from calibrationclasses.feature_json import FeatureRecord
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StereoPair:
|
||||
left: FeatureRecord
|
||||
right: FeatureRecord
|
||||
delta_sec: float
|
||||
method: str # "time_window" | "pair_key"
|
||||
|
||||
|
||||
def _chessboard_compatible(left: FeatureRecord, right: FeatureRecord) -> bool:
|
||||
if not left.is_chessboard or not right.is_chessboard:
|
||||
return False
|
||||
return left.corner_count == right.corner_count
|
||||
|
||||
|
||||
def pair_by_time_window(
|
||||
left_records: List[FeatureRecord],
|
||||
right_records: List[FeatureRecord],
|
||||
window_sec: float,
|
||||
) -> List[StereoPair]:
|
||||
"""Match each left image to the closest unused right image within window_sec."""
|
||||
pairs: List[StereoPair] = []
|
||||
used_right: set[int] = set()
|
||||
|
||||
left_sorted = sorted(
|
||||
[r for r in left_records if r.is_chessboard and r.timestamp_sec is not None],
|
||||
key=lambda r: r.timestamp_sec,
|
||||
)
|
||||
right_candidates = [
|
||||
(i, r)
|
||||
for i, r in enumerate(right_records)
|
||||
if r.is_chessboard and r.timestamp_sec is not None
|
||||
]
|
||||
|
||||
for left in left_sorted:
|
||||
best_idx = None
|
||||
best_dt = None
|
||||
for idx, right in right_candidates:
|
||||
if idx in used_right:
|
||||
continue
|
||||
if not _chessboard_compatible(left, right):
|
||||
continue
|
||||
dt = abs(left.timestamp_sec - right.timestamp_sec)
|
||||
if dt <= window_sec and (best_dt is None or dt < best_dt):
|
||||
best_idx = idx
|
||||
best_dt = dt
|
||||
if best_idx is not None:
|
||||
used_right.add(best_idx)
|
||||
right = right_candidates[best_idx][1]
|
||||
pairs.append(StereoPair(left, right, best_dt, "time_window"))
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
def pair_by_key(
|
||||
left_records: List[FeatureRecord],
|
||||
right_records: List[FeatureRecord],
|
||||
) -> List[StereoPair]:
|
||||
"""Legacy exact pair_key matching (IR scan ids, shared numeric suffix)."""
|
||||
right_lookup: Dict[str, FeatureRecord] = {}
|
||||
for right in right_records:
|
||||
if right.is_chessboard and right.pair_key:
|
||||
right_lookup[right.pair_key] = right
|
||||
|
||||
pairs: List[StereoPair] = []
|
||||
used_right: set[str] = set()
|
||||
for left in left_records:
|
||||
if not left.is_chessboard or not left.pair_key:
|
||||
continue
|
||||
right = right_lookup.get(left.pair_key)
|
||||
if right is None or left.pair_key in used_right:
|
||||
continue
|
||||
if not _chessboard_compatible(left, right):
|
||||
continue
|
||||
used_right.add(left.pair_key)
|
||||
pairs.append(StereoPair(left, right, 0.0, "pair_key"))
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
def build_stereo_pairs(
|
||||
left_records: List[FeatureRecord],
|
||||
right_records: List[FeatureRecord],
|
||||
time_window_sec: float = 0.1,
|
||||
) -> List[StereoPair]:
|
||||
"""
|
||||
Prefer time-window pairs; fill remaining with pair_key matches not already paired.
|
||||
"""
|
||||
time_pairs = pair_by_time_window(left_records, right_records, time_window_sec)
|
||||
paired_left = {p.left.image_path for p in time_pairs}
|
||||
paired_right = {p.right.image_path for p in time_pairs}
|
||||
|
||||
remaining_left = [r for r in left_records if r.image_path not in paired_left]
|
||||
remaining_right = [r for r in right_records if r.image_path not in paired_right]
|
||||
key_pairs = pair_by_key(remaining_left, remaining_right)
|
||||
|
||||
return time_pairs + key_pairs
|
||||
@@ -0,0 +1,82 @@
|
||||
from typing import List, Tuple
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
class Preprocessing:
|
||||
"""Preprocessing class.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
clipLimit: float
|
||||
default = 5.0
|
||||
tileGridSize: Tuple[int, int]
|
||||
default = (15, 15)
|
||||
thresh1: int
|
||||
default = 0
|
||||
thresh2: int
|
||||
default = 255
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tileGridSize: Tuple[int, int] = (15, 15),
|
||||
clipLimit: float = 5.0,
|
||||
thresh1: int = 0,
|
||||
thresh2: int = 255,
|
||||
) -> None:
|
||||
self.tileGridSize = tileGridSize
|
||||
self.clipLimit = clipLimit
|
||||
self.thresh1 = thresh1
|
||||
self.thresh2 = thresh2
|
||||
|
||||
|
||||
def gray(self, image: np.ndarray) -> np.ndarray:
|
||||
"""Convert to GRAY for a given image.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
image : np.ndarray
|
||||
image of chessboard
|
||||
|
||||
Returns
|
||||
-------
|
||||
np.ndarray
|
||||
image of chessboard converted to GRAY
|
||||
"""
|
||||
gray = cv2.cvtColor(image,cv2.COLOR_BGR2GRAY)
|
||||
return gray
|
||||
|
||||
def clahe(self, image: np.ndarray) -> np.ndarray:
|
||||
"""Apply Clahe to GRAY Shimage.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
image : np.ndarray
|
||||
image of chessboard
|
||||
|
||||
Returns
|
||||
-------
|
||||
np.ndarray
|
||||
image of chessboard converted to GRAY and applied CLAHE
|
||||
"""
|
||||
clahe = cv2.createCLAHE(clipLimit = self.clipLimit, tileGridSize = self.tileGridSize)
|
||||
clahed = clahe.apply(self.gray(image))
|
||||
return clahed
|
||||
|
||||
def threshold(self, image: np.ndarray) -> np.ndarray:
|
||||
"""Apply Clahe to GRAY Shimage.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
image : np.ndarray
|
||||
image of chessboard
|
||||
|
||||
Returns
|
||||
-------
|
||||
np.ndarray
|
||||
image of chessboard converted to GRAY applied CLAHE and applied THRESHOLD
|
||||
"""
|
||||
criteria = cv2.THRESH_BINARY + cv2.THRESH_OTSU+1
|
||||
ret, threshold = cv2.threshold(self.clahe(image), self.thresh1, self.thresh2, criteria)
|
||||
return threshold
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Calibration session path resolution and camera folder discovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
IMAGE_EXTENSIONS = (".bmp", ".png", ".jpg", ".jpeg")
|
||||
|
||||
# Logical camera name -> folder aliases on disk
|
||||
CAMERA_FOLDER_ALIASES: Dict[str, Tuple[str, ...]] = {
|
||||
"lc": ("lc",),
|
||||
"lc-ir": ("lc-ir", "lc_ir", "LC-IR"),
|
||||
"rc": ("rc",),
|
||||
"rg": ("rg", "rgb"),
|
||||
"ir": ("ir", "IR"),
|
||||
}
|
||||
|
||||
STEREO_PARTNERS = ("rc", "rg", "ir")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CameraFolder:
|
||||
logical_name: str
|
||||
path: Path
|
||||
folder_name: str
|
||||
|
||||
|
||||
def resolve_session_root(input_path: str | Path) -> Path:
|
||||
"""Return flat or nested `images/` root containing camera folders."""
|
||||
input_path = Path(input_path)
|
||||
images_dir = input_path / "images"
|
||||
if images_dir.is_dir():
|
||||
return images_dir
|
||||
return input_path
|
||||
|
||||
|
||||
def discover_camera_folder(
|
||||
session_root: Path, logical_name: str
|
||||
) -> Optional[CameraFolder]:
|
||||
aliases = CAMERA_FOLDER_ALIASES.get(logical_name)
|
||||
if not aliases:
|
||||
return None
|
||||
for folder in aliases:
|
||||
path = session_root / folder
|
||||
if path.is_dir():
|
||||
return CameraFolder(logical_name, path, folder)
|
||||
return None
|
||||
|
||||
|
||||
def list_image_paths(camera_dir: Path) -> List[Path]:
|
||||
paths = [
|
||||
camera_dir / name
|
||||
for name in os.listdir(camera_dir)
|
||||
if name.lower().endswith(IMAGE_EXTENSIONS)
|
||||
]
|
||||
return sorted(paths)
|
||||
|
||||
|
||||
def json_path_for_image(image_path: Path) -> Path:
|
||||
return image_path.with_suffix(".json")
|
||||
|
||||
|
||||
def list_cameras_present(session_root: Path) -> List[CameraFolder]:
|
||||
found = []
|
||||
for logical in CAMERA_FOLDER_ALIASES:
|
||||
cam = discover_camera_folder(session_root, logical)
|
||||
if cam is not None:
|
||||
found.append(cam)
|
||||
return found
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Parse timestamps and pairing keys from calibration image filenames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
_TS_TOKEN = re.compile(r"ts(\d+)", re.IGNORECASE)
|
||||
_SCAN_TOKEN = re.compile(r"scan(\d{6})", re.IGNORECASE)
|
||||
_IR_SCAN = re.compile(r"^ir_scan_(\d+)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _digits_after_prefix(name: str, prefixes: Tuple[str, ...]) -> Optional[str]:
|
||||
lower = name.lower()
|
||||
for prefix in sorted(prefixes, key=len, reverse=True):
|
||||
if lower.startswith(prefix):
|
||||
remainder = lower[len(prefix) :].lstrip("_-.")
|
||||
m = re.match(r"(\d+)", remainder)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def parse_timestamp_sec(filename: str) -> Optional[float]:
|
||||
"""
|
||||
Normalize filename timestamps to seconds for time-window pairing.
|
||||
|
||||
Supports:
|
||||
- lc_ts1634840093_ck.... -> ms since epoch
|
||||
- lc_1778599872850705.bmp -> µs since epoch (16+ digits)
|
||||
- lc_1778599872850.bmp -> ms (13 digits)
|
||||
"""
|
||||
name = Path(filename).name
|
||||
m = _TS_TOKEN.search(name)
|
||||
if m:
|
||||
digits = m.group(1)
|
||||
if len(digits) >= 16:
|
||||
return int(digits) / 1_000_000.0
|
||||
if len(digits) >= 13:
|
||||
return int(digits) / 1_000.0
|
||||
return int(digits) / 1_000.0
|
||||
|
||||
prefixes = ("lc-ir", "lcir", "lc_ir", "lc", "rc", "rg", "rgb", "ir")
|
||||
digits = _digits_after_prefix(name, prefixes)
|
||||
if digits is None:
|
||||
return None
|
||||
|
||||
if len(digits) >= 16:
|
||||
return int(digits) / 1_000_000.0
|
||||
if len(digits) >= 13:
|
||||
return int(digits) / 1_000.0
|
||||
return float(digits)
|
||||
|
||||
|
||||
def parse_pair_key(filename: str) -> Optional[str]:
|
||||
"""
|
||||
Filename key for legacy exact matching (IR scan ids, shared numeric tails).
|
||||
"""
|
||||
name = Path(filename).name
|
||||
lower = name.lower()
|
||||
|
||||
m = _IR_SCAN.match(lower)
|
||||
if m:
|
||||
return f"scan{int(m.group(1)):06d}"
|
||||
|
||||
m = _SCAN_TOKEN.search(lower)
|
||||
if m:
|
||||
return m.group(0).lower()
|
||||
|
||||
m = _TS_TOKEN.search(lower)
|
||||
if m:
|
||||
return f"ts{m.group(1)}"
|
||||
|
||||
prefixes = ("lc-ir", "lcir", "lc_ir", "lc", "rc", "rg", "rgb", "ir")
|
||||
digits = _digits_after_prefix(lower, prefixes)
|
||||
if digits:
|
||||
return digits
|
||||
|
||||
return Path(lower).stem
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Step 1 — Feature detection for calibration.
|
||||
|
||||
Detects chessboard corners (and ellipse centers for IR when needed) and writes
|
||||
one JSON per image next to the source file in the same camera folder.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path.home() / "Speckle-Scanner"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import argparse
|
||||
|
||||
from calibrationclasses.cli_common import (
|
||||
add_board_args,
|
||||
add_session_args,
|
||||
add_troubleshooting_arg,
|
||||
build_board_config,
|
||||
resolve_input_path,
|
||||
)
|
||||
from calibrationclasses.feature_detection import DetectionConfig, run_detection
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Calibration step 1: detect features and save per-image JSON"
|
||||
)
|
||||
add_session_args(parser)
|
||||
add_board_args(parser)
|
||||
parser.add_argument(
|
||||
"--cameras",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Comma-separated camera folders to process (default: all present)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ir_mode",
|
||||
choices=("auto", "chessboard", "ellipse"),
|
||||
default="auto",
|
||||
help="IR detection: try chessboard first (auto), or force one mode",
|
||||
)
|
||||
add_troubleshooting_arg(parser)
|
||||
args = parser.parse_args()
|
||||
|
||||
board_sizes, square_sizes = build_board_config(args)
|
||||
per_camera_board = {
|
||||
name: {"board_size": board_sizes[name], "square_size": square_sizes[name]}
|
||||
for name in board_sizes
|
||||
}
|
||||
|
||||
cameras = None
|
||||
if args.cameras:
|
||||
cameras = [c.strip() for c in args.cameras.split(",") if c.strip()]
|
||||
|
||||
config = DetectionConfig(
|
||||
chessboard_size=args.chessboard_size,
|
||||
square_size=args.square_size,
|
||||
preprocessing=args.preprocessing,
|
||||
ir_mode=args.ir_mode,
|
||||
troubleshooting=args.troubleshooting,
|
||||
)
|
||||
|
||||
input_path = resolve_input_path(args)
|
||||
print(f"[detect] session: {input_path}")
|
||||
run_detection(input_path, config, cameras=cameras, per_camera_board=per_camera_board)
|
||||
print("[detect] done")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Calibration entry point.
|
||||
|
||||
Default (2-step pipeline):
|
||||
1. detect_features.py — corners/ellipses → per-image JSON
|
||||
2. calibrate.py — mono intrinsics + stereo (lc vs rc/rg/ir)
|
||||
|
||||
Legacy one-shot mode: --legacy (detect + calibrate in memory, single partner)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path.home() / "Speckle-Scanner"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import argparse
|
||||
import threading
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import config
|
||||
from calibrationclasses.calibration import StereoCalibration
|
||||
from calibrationclasses.calibration_engine import (
|
||||
run_mono_calibration,
|
||||
run_stereo_calibration,
|
||||
)
|
||||
from calibrationclasses.cli_common import (
|
||||
add_board_args,
|
||||
add_session_args,
|
||||
add_troubleshooting_arg,
|
||||
build_board_config,
|
||||
parse_chessboard_size,
|
||||
resolve_input_path,
|
||||
)
|
||||
from calibrationclasses.feature_detection import DetectionConfig, run_detection
|
||||
from calibrationclasses.session import STEREO_PARTNERS
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Stereo camera calibration (2-step pipeline by default)"
|
||||
)
|
||||
add_session_args(parser)
|
||||
add_board_args(parser)
|
||||
parser.add_argument(
|
||||
"--step",
|
||||
choices=("detect", "calibrate", "all"),
|
||||
default="all",
|
||||
help="Pipeline step: detect JSONs, calibrate from JSONs, or both (default)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--legacy",
|
||||
action="store_true",
|
||||
help="Old one-shot flow: detect in memory, one stereo partner only",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--left_camera",
|
||||
type=str,
|
||||
default="lc",
|
||||
choices=("lc", "lc-ir", "lc_ir"),
|
||||
help="Left camera folder for stereo (default: lc)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--right_camera",
|
||||
type=str,
|
||||
default="rc",
|
||||
choices=("rc", "rgb", "rg", "ir"),
|
||||
help="Stereo partner (legacy mode only; 2-step uses lc vs all partners)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--time_window",
|
||||
type=float,
|
||||
default=0.1,
|
||||
help="Stereo pair time window in seconds (default: 0.1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--partners",
|
||||
type=str,
|
||||
default="rc,rg,ir",
|
||||
help="Stereo partners in 2-step mode (default: rc,rg,ir)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ir_mode",
|
||||
choices=("auto", "chessboard", "ellipse"),
|
||||
default="auto",
|
||||
help="IR feature detection mode for step 1",
|
||||
)
|
||||
add_troubleshooting_arg(parser)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def run_legacy(
|
||||
input_path,
|
||||
chessboard_size=(8, 7),
|
||||
square_size=0.045,
|
||||
chessboard_size_left: Optional[Tuple[int, int]] = None,
|
||||
chessboard_size_right: Optional[Tuple[int, int]] = None,
|
||||
square_size_left: Optional[float] = None,
|
||||
square_size_right: Optional[float] = None,
|
||||
preprocessing="None",
|
||||
left_camera="lc",
|
||||
right_camera="rc",
|
||||
troubleshooting=False,
|
||||
):
|
||||
chessboard_size_left = chessboard_size_left or chessboard_size
|
||||
chessboard_size_right = chessboard_size_right or chessboard_size
|
||||
square_size_left = square_size if square_size_left is None else square_size_left
|
||||
square_size_right = (
|
||||
square_size if square_size_right is None else square_size_right
|
||||
)
|
||||
|
||||
stereo_calibrator = StereoCalibration(
|
||||
input_path,
|
||||
chessboard_size,
|
||||
square_size,
|
||||
preprocessing,
|
||||
chessboard_size_left=chessboard_size_left,
|
||||
chessboard_size_right=chessboard_size_right,
|
||||
square_size_left=square_size_left,
|
||||
square_size_right=square_size_right,
|
||||
left_camera=left_camera,
|
||||
right_camera=right_camera,
|
||||
troubleshooting=troubleshooting,
|
||||
)
|
||||
if stereo_calibrator._preprocessing_enabled():
|
||||
print(f"[INFO] Preprocessing for corner detection enabled: {preprocessing!r}")
|
||||
|
||||
t1 = threading.Thread(target=stereo_calibrator.create_chessboard_points_left)
|
||||
t2 = threading.Thread(target=stereo_calibrator.create_chessboard_points_right)
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
stereo_calibrator.build_pairs_cal()
|
||||
stereo_calibrator.calibrate()
|
||||
stereo_calibrator.save_stereo_calibration()
|
||||
|
||||
if troubleshooting:
|
||||
stereo_calibrator.rectify_calibration_images()
|
||||
|
||||
|
||||
def run_two_step(args):
|
||||
input_path = resolve_input_path(args)
|
||||
board_sizes, square_sizes = build_board_config(args)
|
||||
per_camera_board = {
|
||||
name: {"board_size": board_sizes[name], "square_size": square_sizes[name]}
|
||||
for name in board_sizes
|
||||
}
|
||||
left_camera = args.left_camera.lower().replace("_", "-")
|
||||
partners = tuple(p.strip() for p in args.partners.split(",") if p.strip())
|
||||
|
||||
if args.step in ("detect", "all"):
|
||||
print("\n=== Step 1: Feature detection → JSON ===")
|
||||
config_det = DetectionConfig(
|
||||
chessboard_size=args.chessboard_size,
|
||||
square_size=args.square_size,
|
||||
preprocessing=args.preprocessing,
|
||||
ir_mode=args.ir_mode,
|
||||
troubleshooting=args.troubleshooting,
|
||||
)
|
||||
run_detection(input_path, config_det, per_camera_board=per_camera_board)
|
||||
|
||||
if args.step in ("calibrate", "all"):
|
||||
print("\n=== Step 2a: Mono intrinsics ===")
|
||||
mono_results = run_mono_calibration(
|
||||
input_path,
|
||||
board_sizes,
|
||||
square_sizes,
|
||||
troubleshooting=args.troubleshooting,
|
||||
)
|
||||
print("\n=== Step 2b: Stereo (lc vs partners) ===")
|
||||
run_stereo_calibration(
|
||||
input_path,
|
||||
left_camera=left_camera,
|
||||
mono_results=mono_results,
|
||||
board_sizes=board_sizes,
|
||||
square_sizes=square_sizes,
|
||||
time_window_sec=args.time_window,
|
||||
partners=partners or STEREO_PARTNERS,
|
||||
troubleshooting=args.troubleshooting,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
input_path = str(resolve_input_path(args))
|
||||
|
||||
if args.legacy:
|
||||
run_legacy(
|
||||
input_path=input_path,
|
||||
chessboard_size=args.chessboard_size,
|
||||
square_size=args.square_size,
|
||||
chessboard_size_left=args.left_chessboard_size,
|
||||
chessboard_size_right=args.right_chessboard_size,
|
||||
square_size_left=args.left_square_size,
|
||||
square_size_right=args.right_square_size,
|
||||
preprocessing=args.preprocessing,
|
||||
left_camera=args.left_camera,
|
||||
right_camera=args.right_camera,
|
||||
troubleshooting=args.troubleshooting,
|
||||
)
|
||||
else:
|
||||
run_two_step(args)
|
||||
@@ -0,0 +1,8 @@
|
||||
# 02_Calibration — Python dependencies
|
||||
# Install: pip install -r requirements.txt
|
||||
# Full pipeline (all steps): pip install -r ~/Speckle-Scanner/requirements.txt
|
||||
|
||||
numpy>=1.21
|
||||
opencv-python>=4.8
|
||||
tqdm>=4.0
|
||||
matplotlib>=3.5
|
||||
Reference in New Issue
Block a user