Initial commit: Speckle-Scanner 3D pipeline with setup README

This commit is contained in:
2026-06-10 03:09:05 +05:00
commit 1765934846
375 changed files with 123081 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
# 04 Rectification
Stereo rectification for multi-camera scan sessions. Reads raw images from `3D-Scans`, applies calibration from `Calib-data`, and writes results into `Speckle-Scanner_Processing_data`.
Supported stereo pairs (per scan):
| Pair | Left | Right | Params file |
|------|------|-------|-------------|
| `lc-rc` | `lc_*` | `rc_*` | `lc-rc_parameters.npz` |
| `lc-rg` | `lc_*` | `rg_*` | `lc-rg_parameters.npz` |
| `lc-ir` | `lc_*` | `ir_*` | `lc-ir_parameters.npz` |
Rectified LC frames are taken from the `lc-rc` run only (one LC set in `02_rect_images`). Partner cameras (`rc`, `rg`, `ir`) are saved from their own pair calibration.
---
## Folder layout (general paths)
All paths use `$HOME` — replace with your home directory on any machine.
| Role | Path pattern |
|------|----------------|
| Source scans (RAW) | `$HOME/3D-Scans/<raw_project>/<date>/sessionN/Scan00000X/01_raw_images/` |
| Calibration params | `$HOME/Calib-data/<project>/<date>/<calib_name>/params/` |
| Processing output | `$HOME/Speckle-Scanner_Processing_data/<project>/<date>/` |
Example naming:
- **project** (Calib + processing): `Olsen_wings` (underscore)
- **raw_project** (3D-Scans): `Olsen-wings` (often hyphen; default = project with `_``-`)
- **date**: `2026-05-12`
- **calib_name**: `calib1`
Per session under processing output:
```text
$HOME/Speckle-Scanner_Processing_data/<project>/<date>/
session53/
params_link/ # copied lc-rc, lc-rg, lc-ir params
Scan000001/
01_raw_images/ # copy of source images
02_rect_images/ # rectified lc_*, rc_*, rg_*, ir_* (single folder)
Scan000002/
...
```
Source side (same session/scan names):
```text
$HOME/3D-Scans/<raw_project>/<date>/session53/Scan000001/01_raw_images/
```
Calibration params (once per project/date):
```text
$HOME/Calib-data/<project>/<date>/<calib_name>/params/
lc-rc_parameters.npz
lc-rc_stereo_cam_model.yaml
lc-rc_Q.cvstore
lc-rg_*
lc-ir_*
```
---
## Requirements
```bash
pip install numpy opencv-python tqdm
```
Use a Python environment where `import cv2` works.
---
## How to run
From anywhere:
```bash
cd "$HOME/Speckle-Scanner/04_Rectification"
python main.py [options]
```
### All sessions under one date (full batch)
Processes every `session*/Scan*/01_raw_images` under the date folder.
```bash
python main.py \
--project Olsen_wings \
--raw_project Olsen-wings \
--date 2026-05-12 \
--calib_name calib1
```
One line:
```bash
python main.py --project Olsen_wings --raw_project Olsen-wings --date 2026-05-12 --calib_name calib1
```
### One session only
```bash
python main.py \
--project Olsen_wings \
--raw_project Olsen-wings \
--date 2026-05-12 \
--calib_name calib1 \
--session session53
```
### Custom pairs
Default: `lc-rc,lc-rg,lc-ir`. Example — RC and IR only:
```bash
python main.py --project Olsen_wings --date 2026-05-12 --pairs lc-rc,lc-ir
```
### Override paths (any project/machine)
```bash
python main.py \
--source_date_root "$HOME/3D-Scans/MyProject/2026-05-12" \
--calib_params_dir "$HOME/Calib-data/MyProject/2026-05-12/calib1/params" \
--processing_date_root "$HOME/Speckle-Scanner_Processing_data/MyProject/2026-05-12"
```
---
## CLI reference
| Option | Default | Meaning |
|--------|---------|---------|
| `--project` | **required** | Project name in Calib-data and Processing_data (e.g. `Olsen_wings`) |
| `--date` | **required** | Date subfolder (e.g. `2026-05-12`) |
| `--raw_project` | `<project>` with `_``-` | Project folder name under 3D-Scans |
| `--session` | (all) | Only this session, e.g. `session53` |
| `--calib_name` | `calib1` | Calibration run folder |
| `--pairs` | `lc-rc,lc-rg,lc-ir` | Comma-separated stereo pairs |
| `--keep_lc_from_pair` | `lc-rc` | Which pair defines rectified LC in `02_rect_images` |
| `--source_date_root` | auto | Override RAW scan root |
| `--calib_params_dir` | auto | Override params folder |
| `--processing_date_root` | auto | Override output root |
---
## Pairing notes
Images are matched by filename key (in order):
1. `_ts<number>` in both names (e.g. `lc_ts254303092_...``rc_ts254303092_...`)
2. `scan000001` style / `IR_scan_000001`
3. Prefix + suffix (`lc_123``ir_123`)
If no key match for `lc-rg` or `lc-ir`, the script may use **index fallback** (first LC with first RG/IR). Check logs for:
```text
[WARN] No key match for lc-rg; using index fallback with N pairs.
```
`lc-rc` usually matches on `_ts` when both cameras captured the same timestamps.
---
## What gets created
For each processed scan:
- Copies `01_raw_images` into processing tree (does not delete source RAW data)
- Writes rectified images to `02_rect_images/`
- Creates `params_link/` once per session with all calibration files
Does **not** modify files under `3D-Scans`.
---
## Quick check after a run
```bash
PROJECT=Olsen_wings
DATE=2026-05-12
SESSION=session53
SCAN=Scan000001
ls "$HOME/Speckle-Scanner_Processing_data/$PROJECT/$DATE/$SESSION/params_link"
ls "$HOME/Speckle-Scanner_Processing_data/$PROJECT/$DATE/$SESSION/$SCAN/02_rect_images" | head
```
---
## Dependencies
```bash
# This step only
pip install -r ~/Speckle-Scanner/04_Rectification/requirements.txt
# Or install everything for the full pipeline
pip install -r ~/Speckle-Scanner/requirements.txt
```
Packages: `numpy`, `opencv-python`, `tqdm`.
+85
View File
@@ -0,0 +1,85 @@
import argparse
from pathlib import Path
from rectificationclasses.rectification import Rectification
def parse_args():
parser = argparse.ArgumentParser(description="Batch stereo rectification")
parser.add_argument("--project", type=str, required=True, help="Project name used for Calib-data and processing_data (e.g. Olsen_wings)")
parser.add_argument(
"--raw_project",
type=str,
default=None,
help="Project name used in 3D-Scans (default: project with '_' replaced by '-')",
)
parser.add_argument("--date", type=str, required=True, help="Date folder (e.g. 2026-05-12)")
parser.add_argument(
"--session",
type=str,
default=None,
help="Process only this session folder (e.g. session53). Default: all sessions under the date.",
)
parser.add_argument("--calib_name", type=str, default="calib1", help="Calibration folder under Calib-data/<project>/<date>/")
parser.add_argument(
"--pairs",
type=str,
default="lc-rc,lc-rg,lc-ir",
help="Comma-separated pair list, e.g. lc-rc,lc-rg,lc-ir",
)
parser.add_argument(
"--keep_lc_from_pair",
type=str,
default="lc-rc",
help="Pair whose rectified LC frames are kept in 02_rect_images.",
)
parser.add_argument(
"--source_date_root",
type=str,
default=None,
help="Override source root (default: ~/3D-Scans/<raw_project>/<date>)",
)
parser.add_argument(
"--calib_params_dir",
type=str,
default=None,
help="Override calib params dir (default: ~/Calib-data/<project>/<date>/<calib_name>/params)",
)
parser.add_argument(
"--processing_date_root",
type=str,
default=None,
help="Override processing target root (default: ~/Speckle-Scanner_Processing_data/<project>/<date>)",
)
return parser.parse_args()
def main():
args = parse_args()
home = Path.home()
raw_project = args.raw_project or args.project.replace("_", "-")
pairs = tuple([p.strip() for p in args.pairs.split(",") if p.strip()])
source_date_root = Path(args.source_date_root) if args.source_date_root else (
home / "3D-Scans" / raw_project / args.date
)
calib_params_dir = Path(args.calib_params_dir) if args.calib_params_dir else (
home / "Calib-data" / args.project / args.date / args.calib_name / "params"
)
processing_date_root = Path(args.processing_date_root) if args.processing_date_root else (
home / "Speckle-Scanner_Processing_data" / args.project / args.date
)
rectificator = Rectification(
source_date_root=str(source_date_root),
calib_params_dir=str(calib_params_dir),
processing_date_root=str(processing_date_root),
pairs=pairs,
keep_lc_from_pair=args.keep_lc_from_pair,
session_filter=args.session,
)
rectificator.run_batch()
if __name__ == "__main__":
main()
@@ -0,0 +1,333 @@
from pathlib import Path
import re
import shutil
from typing import Dict, List, Optional, Tuple
import cv2
import numpy as np
from tqdm import tqdm
VALID_EXTS = {".bmp", ".png", ".jpg", ".jpeg"}
class Rectification:
"""Batch rectification for one project/date tree.
Reads source scans from RAW data tree, copies scans into processing tree, and
rectifies lc-rc/lc-rg/lc-ir pairs with pair-specific calibration params.
"""
def __init__(
self,
source_date_root: str,
calib_params_dir: str,
processing_date_root: str,
pairs: Tuple[str, ...] = ("lc-rc", "lc-rg", "lc-ir"),
keep_lc_from_pair: str = "lc-rc",
session_filter: Optional[str] = None,
) -> None:
self.source_date_root = Path(source_date_root)
self.calib_params_dir = Path(calib_params_dir)
self.processing_date_root = Path(processing_date_root)
self.pairs = pairs
self.keep_lc_from_pair = keep_lc_from_pair
self.session_filter = session_filter
if not self.source_date_root.is_dir():
raise FileNotFoundError(f"Source date root not found: {self.source_date_root}")
if not self.calib_params_dir.is_dir():
raise FileNotFoundError(f"Calibration params dir not found: {self.calib_params_dir}")
self.processing_date_root.mkdir(parents=True, exist_ok=True)
self._params_by_pair: Dict[str, Dict[str, np.ndarray]] = {}
self._rect_maps_cache: Dict[Tuple[str, int, int], Tuple[Tuple[np.ndarray, np.ndarray], Tuple[np.ndarray, np.ndarray], np.ndarray]] = {}
self._load_all_pair_params()
@staticmethod
def _extract_ts_key(filename: str) -> Optional[str]:
stem = Path(filename).stem.lower()
m = re.search(r"_ts(\d+)", stem)
return m.group(1) if m else None
@staticmethod
def _extract_scan_key(filename: str) -> Optional[str]:
stem = Path(filename).stem.lower()
m = re.search(r"(scan\d{6})", stem)
if m:
return m.group(1)
m = re.match(r"^ir_scan_(\d+)", stem)
if m:
return f"scan{int(m.group(1)):06d}"
m = re.match(r"^ir_(\d{6})(?:_|$)", stem)
if m:
return f"scan{m.group(1)}"
return None
@staticmethod
def _extract_generic_suffix_key(filename: str, prefix: str) -> Optional[str]:
stem = Path(filename).stem.lower()
if not stem.startswith(prefix):
return None
return stem[len(prefix):].lstrip("_-.")
@staticmethod
def _camera_from_pair(pair_name: str) -> str:
return pair_name.split("-", 1)[1]
def _load_pair_params(self, pair_name: str) -> Dict[str, np.ndarray]:
npz_path = self.calib_params_dir / f"{pair_name}_parameters.npz"
if not npz_path.exists():
raise FileNotFoundError(f"Missing params file for {pair_name}: {npz_path}")
data = np.load(npz_path, allow_pickle=True)
params = dict(data)
required = [
"L_Intrinsic",
"L_Distortion",
"R_Intrinsic",
"R_Distortion",
"Rotation",
"Translation",
]
missing = [k for k in required if k not in params]
if missing:
raise KeyError(f"{pair_name} params missing keys: {missing}")
return params
def _load_all_pair_params(self) -> None:
for pair_name in self.pairs:
self._params_by_pair[pair_name] = self._load_pair_params(pair_name)
print(f"[INFO] Loaded calibration params for pairs: {', '.join(self.pairs)}")
def _copy_params_link_for_session(self, session_name: str) -> None:
target_params = self.processing_date_root / session_name / "params_link"
target_params.mkdir(parents=True, exist_ok=True)
for src in self.calib_params_dir.iterdir():
if src.is_file() and src.suffix.lower() in (".npz", ".yaml", ".cvstore"):
shutil.copy2(src, target_params / src.name)
@staticmethod
def _copy_raw_images(src_raw_dir: Path, dst_raw_dir: Path) -> None:
dst_raw_dir.mkdir(parents=True, exist_ok=True)
for src in src_raw_dir.iterdir():
if src.is_file():
shutil.copy2(src, dst_raw_dir / src.name)
@staticmethod
def _list_images(raw_dir: Path, prefix: str) -> List[Path]:
imgs = [
p for p in raw_dir.iterdir()
if p.is_file()
and p.suffix.lower() in VALID_EXTS
and p.name.lower().startswith(prefix.lower())
]
imgs.sort()
return imgs
def _pair_images(self, left_images: List[Path], right_images: List[Path], right_camera: str) -> List[Tuple[Path, Path]]:
left_by_ts = {self._extract_ts_key(p.name): p for p in left_images if self._extract_ts_key(p.name)}
right_by_ts = {self._extract_ts_key(p.name): p for p in right_images if self._extract_ts_key(p.name)}
pairs: List[Tuple[Path, Path]] = []
common_ts = sorted(set(left_by_ts.keys()) & set(right_by_ts.keys()))
for ts in common_ts:
pairs.append((left_by_ts[ts], right_by_ts[ts]))
if pairs:
return pairs
left_by_scan = {self._extract_scan_key(p.name): p for p in left_images if self._extract_scan_key(p.name)}
right_by_scan = {self._extract_scan_key(p.name): p for p in right_images if self._extract_scan_key(p.name)}
common_scan = sorted(set(left_by_scan.keys()) & set(right_by_scan.keys()))
for skey in common_scan:
pairs.append((left_by_scan[skey], right_by_scan[skey]))
if pairs:
return pairs
left_by_suffix = {
self._extract_generic_suffix_key(p.name, "lc"): p
for p in left_images
if self._extract_generic_suffix_key(p.name, "lc")
}
right_by_suffix = {
self._extract_generic_suffix_key(p.name, right_camera): p
for p in right_images
if self._extract_generic_suffix_key(p.name, right_camera)
}
common_suffix = sorted(set(left_by_suffix.keys()) & set(right_by_suffix.keys()))
for key in common_suffix:
pairs.append((left_by_suffix[key], right_by_suffix[key]))
if pairs:
return pairs
fallback_count = min(len(left_images), len(right_images))
if fallback_count > 0:
print(
f"[WARN] No key match for lc-{right_camera}; "
f"using index fallback with {fallback_count} pairs."
)
return list(zip(left_images[:fallback_count], right_images[:fallback_count]))
return []
def _get_rectification_maps(
self,
pair_name: str,
left_size: Tuple[int, int],
right_size: Tuple[int, int],
) -> Tuple[Tuple[np.ndarray, np.ndarray], Tuple[np.ndarray, np.ndarray], np.ndarray]:
cache_key = (pair_name, left_size[0], left_size[1])
if cache_key in self._rect_maps_cache:
return self._rect_maps_cache[cache_key]
params = self._params_by_pair[pair_name]
rect_left, rect_right, proj_left, proj_right, q_mat, _, _ = cv2.stereoRectify(
params["L_Intrinsic"],
params["L_Distortion"],
params["R_Intrinsic"],
params["R_Distortion"],
left_size,
params["Rotation"],
params["Translation"],
alpha=1,
flags=0,
)
left_maps = cv2.initUndistortRectifyMap(
params["L_Intrinsic"],
params["L_Distortion"],
rect_left,
proj_left,
left_size,
cv2.CV_32FC1,
)
right_maps = cv2.initUndistortRectifyMap(
params["R_Intrinsic"],
params["R_Distortion"],
rect_right,
proj_right,
right_size,
cv2.CV_32FC1,
)
self._rect_maps_cache[cache_key] = (left_maps, right_maps, q_mat)
return left_maps, right_maps, q_mat
def _rectify_pair_image(
self,
pair_name: str,
left_img: np.ndarray,
right_img: np.ndarray,
) -> Tuple[np.ndarray, np.ndarray]:
left_size = (left_img.shape[1], left_img.shape[0])
right_size = (right_img.shape[1], right_img.shape[0])
left_maps, right_maps, _ = self._get_rectification_maps(pair_name, left_size, right_size)
left_rect = cv2.remap(left_img, left_maps[0], left_maps[1], cv2.INTER_AREA)
right_rect = cv2.remap(right_img, right_maps[0], right_maps[1], cv2.INTER_AREA)
return left_rect, right_rect
def _process_scan(self, session_name: str, scan_name: str) -> Dict[str, int]:
src_raw_dir = self.source_date_root / session_name / scan_name / "01_raw_images"
dst_scan_dir = self.processing_date_root / session_name / scan_name
dst_raw_dir = dst_scan_dir / "01_raw_images"
dst_rect_dir = dst_scan_dir / "02_rect_images"
dst_rect_dir.mkdir(parents=True, exist_ok=True)
self._copy_raw_images(src_raw_dir, dst_raw_dir)
stats = {"pairs_total": 0, "saved": 0, "skipped": 0}
lc_written = False
ordered_pairs = list(self.pairs)
if self.keep_lc_from_pair in ordered_pairs:
ordered_pairs.remove(self.keep_lc_from_pair)
ordered_pairs.insert(0, self.keep_lc_from_pair)
for pair_name in ordered_pairs:
right_camera = self._camera_from_pair(pair_name)
left_images = self._list_images(dst_raw_dir, "lc")
right_images = self._list_images(dst_raw_dir, right_camera)
if not left_images or not right_images:
stats["skipped"] += 1
print(
f"[WARN] {session_name}/{scan_name} {pair_name}: "
f"missing images (lc={len(left_images)}, {right_camera}={len(right_images)})."
)
continue
pairs = self._pair_images(left_images, right_images, right_camera)
if not pairs:
stats["skipped"] += 1
print(f"[WARN] {session_name}/{scan_name} {pair_name}: no valid pairs.")
continue
save_lc_this_pair = (
pair_name == self.keep_lc_from_pair
or (not lc_written and pair_name != self.keep_lc_from_pair)
)
stats["pairs_total"] += len(pairs)
for left_path, right_path in tqdm(
pairs,
desc=f"{session_name}/{scan_name} {pair_name}",
unit="pair",
leave=False,
):
left_img = cv2.imread(str(left_path), cv2.IMREAD_COLOR)
right_img = cv2.imread(str(right_path), cv2.IMREAD_COLOR)
if left_img is None or right_img is None:
stats["skipped"] += 1
continue
left_rect, right_rect = self._rectify_pair_image(pair_name, left_img, right_img)
if save_lc_this_pair:
left_out = dst_rect_dir / left_path.name
cv2.imwrite(str(left_out), left_rect)
lc_written = True
right_out = dst_rect_dir / right_path.name
cv2.imwrite(str(right_out), right_rect)
stats["saved"] += 1
return stats
def _discover_session_scan_raw_dirs(self) -> List[Tuple[str, str]]:
found: List[Tuple[str, str]] = []
session_dirs = sorted(
[p for p in self.source_date_root.iterdir() if p.is_dir() and p.name.lower().startswith("session")]
)
for session_dir in session_dirs:
if self.session_filter and session_dir.name != self.session_filter:
continue
scan_dirs = sorted(
[p for p in session_dir.iterdir() if p.is_dir() and p.name.lower().startswith("scan")]
)
for scan_dir in scan_dirs:
raw_dir = scan_dir / "01_raw_images"
if raw_dir.is_dir():
found.append((session_dir.name, scan_dir.name))
return found
def run_batch(self) -> Dict[str, int]:
all_scans = self._discover_session_scan_raw_dirs()
if not all_scans:
raise RuntimeError(f"No scan folders found under {self.source_date_root}")
print(f"[INFO] Found {len(all_scans)} scans under {self.source_date_root}")
totals = {"scans": 0, "pairs_total": 0, "saved": 0, "skipped": 0}
sessions_seen = set()
for session_name, scan_name in all_scans:
if session_name not in sessions_seen:
self._copy_params_link_for_session(session_name)
sessions_seen.add(session_name)
scan_stats = self._process_scan(session_name, scan_name)
totals["scans"] += 1
totals["pairs_total"] += scan_stats["pairs_total"]
totals["saved"] += scan_stats["saved"]
totals["skipped"] += scan_stats["skipped"]
print(
"[INFO] Batch rectification finished: "
f"scans={totals['scans']} pairs={totals['pairs_total']} "
f"saved={totals['saved']} skipped={totals['skipped']}"
)
return totals
+7
View File
@@ -0,0 +1,7 @@
# 04_Rectification — 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