stereo-charuco-pipeline 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
recorder/camera.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import List
5
+
6
+ from .ffmpeg import FFmpegRunner
7
+
8
+
9
+ def list_dshow_devices() -> List[str]:
10
+ """
11
+ List DirectShow video devices on Windows using ffmpeg.
12
+
13
+ Returns a list of device names that you can pass to:
14
+ ffmpeg -f dshow -i video="<NAME>"
15
+ """
16
+ ffmpeg = FFmpegRunner.resolve_ffmpeg()
17
+ runner = FFmpegRunner()
18
+
19
+ cmd = [ffmpeg, "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"]
20
+ res = runner.run_capture(cmd)
21
+
22
+ # ffmpeg prints devices to stderr in many builds
23
+ text = (res.stdout or "") + "\n" + (res.stderr or "")
24
+
25
+ devices: List[str] = []
26
+ in_video_section = False
27
+
28
+ # Typical lines:
29
+ # [dshow @ ...] DirectShow video devices
30
+ # [dshow @ ...] "3D USB Camera"
31
+ for line in text.splitlines():
32
+ if "DirectShow video devices" in line:
33
+ in_video_section = True
34
+ continue
35
+ if "DirectShow audio devices" in line:
36
+ in_video_section = False
37
+ continue
38
+
39
+ if in_video_section:
40
+ m = re.search(r'"([^"]+)"', line)
41
+ if m:
42
+ devices.append(m.group(1))
43
+
44
+ # de-dup preserve order
45
+ seen = set()
46
+ out = []
47
+ for d in devices:
48
+ if d not in seen:
49
+ out.append(d)
50
+ seen.add(d)
51
+ return out
recorder/cli.py ADDED
@@ -0,0 +1,122 @@
1
+ """
2
+ Command-line entry points for stereo-charuco-pipeline.
3
+
4
+ After `pip install .`, these commands are available:
5
+ stereo-pipeline Full workflow (Project Manager -> Calibration -> Pipeline)
6
+ stereo-calibrate Calibration UI only
7
+ stereo-record Pipeline UI only (record + reconstruct)
8
+ """
9
+ import argparse
10
+ import logging
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from .paths import resolve_default_config
15
+
16
+
17
+ def main():
18
+ """Full workflow: Project Manager -> Calibration UI -> Pipeline UI."""
19
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
20
+
21
+ parser = argparse.ArgumentParser(description="Unified Stereo Pipeline")
22
+ parser.add_argument(
23
+ "--config", type=str, default=None,
24
+ help="Path to YAML config file (default: built-in default.yaml)",
25
+ )
26
+ parser.add_argument(
27
+ "--projects-base", type=str, default=None,
28
+ help="Base directory for projects (default: ./projects)",
29
+ )
30
+ args = parser.parse_args()
31
+
32
+ config_path = _resolve_config(args.config)
33
+ projects_base = Path(args.projects_base) if args.projects_base else Path.cwd() / "projects"
34
+
35
+ # Stage 1: Project Manager
36
+ from .project_manager import ProjectManagerUI
37
+
38
+ pm = ProjectManagerUI(projects_base)
39
+ pm.mainloop()
40
+
41
+ context = pm.result
42
+ if context is None:
43
+ print("No project selected. Exiting.")
44
+ return
45
+
46
+ print(f"Project: {context.project_dir}")
47
+ print(f" New: {context.is_new}")
48
+ print(f" Needs calibration: {context.needs_calibration}")
49
+
50
+ # Stage 2: Calibration UI (if needed)
51
+ if context.needs_calibration:
52
+ print("\nOpening Calibration Recording UI...")
53
+ from .calibration_ui import CalibrationUI
54
+
55
+ calib_app = CalibrationUI(
56
+ config_path=config_path,
57
+ project_dir=context.project_dir,
58
+ on_complete=lambda: None,
59
+ )
60
+ calib_app.mainloop()
61
+
62
+ # Stage 3: Pipeline UI
63
+ print("\nOpening Pipeline UI...")
64
+ from .pipeline_ui import PipelineUI
65
+
66
+ pipeline_app = PipelineUI(
67
+ config_path=config_path,
68
+ project_dir=context.project_dir,
69
+ )
70
+ pipeline_app.mainloop()
71
+
72
+ print("\nPipeline closed.")
73
+
74
+
75
+ def calibrate_only():
76
+ """Calibration UI standalone."""
77
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
78
+
79
+ parser = argparse.ArgumentParser(description="Stereo Calibration UI")
80
+ parser.add_argument("--config", type=str, default=None)
81
+ parser.add_argument("--project-dir", type=str, default=None)
82
+ args = parser.parse_args()
83
+
84
+ config_path = _resolve_config(args.config)
85
+
86
+ from .calibration_ui import CalibrationUI
87
+
88
+ app = CalibrationUI(
89
+ config_path=config_path,
90
+ project_dir=Path(args.project_dir) if args.project_dir else None,
91
+ )
92
+ app.mainloop()
93
+
94
+
95
+ def pipeline_only():
96
+ """Pipeline UI standalone."""
97
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
98
+
99
+ parser = argparse.ArgumentParser(description="Stereo Pipeline UI")
100
+ parser.add_argument("--config", type=str, default=None)
101
+ parser.add_argument("--project-dir", type=str, default=None)
102
+ args = parser.parse_args()
103
+
104
+ config_path = _resolve_config(args.config)
105
+
106
+ from .pipeline_ui import PipelineUI
107
+
108
+ app = PipelineUI(
109
+ config_path=config_path,
110
+ project_dir=Path(args.project_dir) if args.project_dir else None,
111
+ )
112
+ app.mainloop()
113
+
114
+
115
+ def _resolve_config(user_config):
116
+ """Resolve config path from user arg or built-in default."""
117
+ if user_config:
118
+ p = Path(user_config)
119
+ if not p.is_absolute():
120
+ p = Path.cwd() / p
121
+ return p
122
+ return resolve_default_config()
recorder/config.py ADDED
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, asdict
4
+ from pathlib import Path
5
+ from typing import Any, Dict, Optional
6
+
7
+
8
+ @dataclass
9
+ class RecorderConfig:
10
+ # capture
11
+ device: str = "3D USB Camera"
12
+ size: str = "3200x1200"
13
+ fps: int = 60
14
+ seconds: int = 120
15
+
16
+ # output
17
+ outdir: str = "."
18
+ outroot: str = "" # if empty: script decides session folder
19
+
20
+ # optional: pass-through (future)
21
+ extra_args: Optional[list[str]] = None
22
+
23
+ def to_args(self) -> list[str]:
24
+ """Convert config to CLI args that match scripts/record_and_split.py."""
25
+ args = [
26
+ "--device", self.device,
27
+ "--size", self.size,
28
+ "--fps", str(int(self.fps)),
29
+ "--seconds", str(int(self.seconds)),
30
+ "--outdir", str(self.outdir),
31
+ ]
32
+ if self.outroot:
33
+ args += ["--outroot", str(self.outroot)]
34
+ if self.extra_args:
35
+ args += list(self.extra_args)
36
+ return args
37
+
38
+ def as_dict(self) -> Dict[str, Any]:
39
+ return asdict(self)
40
+
41
+
42
+ def load_yaml_config(path: str | Path) -> Dict[str, Any]:
43
+ """
44
+ Load YAML into dict. If PyYAML isn't installed, raise a clear error.
45
+ """
46
+ path = Path(path)
47
+ try:
48
+ import yaml # type: ignore
49
+ except Exception as e:
50
+ raise RuntimeError(
51
+ "PyYAML is required to load YAML config. Please `pip install pyyaml`."
52
+ ) from e
53
+
54
+ if not path.exists():
55
+ raise FileNotFoundError(f"Config not found: {path}")
56
+
57
+ with path.open("r", encoding="utf-8") as f:
58
+ data = yaml.safe_load(f) or {}
59
+ if not isinstance(data, dict):
60
+ raise ValueError(f"YAML root must be a mapping/dict. Got: {type(data)}")
61
+ return data
62
+
63
+
64
+ def config_from_yaml(path: str | Path) -> RecorderConfig:
65
+ """
66
+ Create RecorderConfig from YAML mapping. Unknown keys are ignored (safe for future expansion).
67
+ """
68
+ data = load_yaml_config(path)
69
+ cfg = RecorderConfig()
70
+
71
+ for k, v in data.items():
72
+ if hasattr(cfg, k):
73
+ setattr(cfg, k, v)
74
+
75
+ return cfg
@@ -0,0 +1,38 @@
1
+ camera:
2
+ device_name: "3D USB Camera"
3
+ backend: "dshow"
4
+ video_size: "3200x1200"
5
+ fps: 60
6
+
7
+ split:
8
+ full_w: 3200
9
+ full_h: 1200
10
+ xsplit: 1600
11
+
12
+ recording:
13
+ min_seconds: 30
14
+ target_seconds: 120
15
+ default_seconds: 120
16
+
17
+ output:
18
+ base_dir: ""
19
+ keep_avi: true
20
+
21
+ ffmpeg:
22
+ executable: ""
23
+
24
+ charuco:
25
+ columns: 10
26
+ rows: 16
27
+ board_height_cm: 80.0
28
+ board_width_cm: 50.0
29
+ dictionary: "DICT_4X4_100"
30
+ aruco_scale: 0.7
31
+ square_size_cm: 5.0
32
+
33
+ mp4:
34
+ preset: "veryfast"
35
+ crf: 18
36
+
37
+ project:
38
+ project_dir: ""
recorder/ffmpeg.py ADDED
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Callable, Optional
10
+
11
+
12
+ LogCallback = Callable[[str], None]
13
+
14
+
15
+ @dataclass
16
+ class RunResult:
17
+ returncode: int
18
+ stdout: str
19
+ stderr: str
20
+ cmd: list[str]
21
+
22
+
23
+ class FFmpegRunner:
24
+ """
25
+ Minimal runner for subprocess-based tools (ffmpeg, python scripts, etc.)
26
+ - supports live log streaming
27
+ - supports cancellation
28
+ """
29
+
30
+ def __init__(self, executable: Optional[str] = None):
31
+ self.executable = executable
32
+ self._proc: Optional[subprocess.Popen] = None
33
+
34
+ @classmethod
35
+ def resolve_ffmpeg(cls) -> str:
36
+ """
37
+ Resolve ffmpeg path:
38
+ 1) System PATH (cross-platform)
39
+ 2) Repo-local bin/ directory (development fallback)
40
+ """
41
+ from .paths import resolve_ffmpeg
42
+ return resolve_ffmpeg()
43
+
44
+ def run_capture(
45
+ self,
46
+ cmd: list[str],
47
+ cwd: Optional[str | Path] = None,
48
+ env: Optional[dict[str, str]] = None,
49
+ timeout: Optional[int] = None,
50
+ ) -> RunResult:
51
+ """
52
+ Run synchronously and capture stdout/stderr fully.
53
+ """
54
+ p = subprocess.run(
55
+ cmd,
56
+ cwd=str(cwd) if cwd else None,
57
+ env=env,
58
+ text=True,
59
+ capture_output=True,
60
+ timeout=timeout,
61
+ )
62
+ return RunResult(
63
+ returncode=p.returncode,
64
+ stdout=p.stdout or "",
65
+ stderr=p.stderr or "",
66
+ cmd=cmd,
67
+ )
68
+
69
+ def run_stream(
70
+ self,
71
+ cmd: list[str],
72
+ on_log: Optional[LogCallback] = None,
73
+ cwd: Optional[str | Path] = None,
74
+ env: Optional[dict[str, str]] = None,
75
+ ) -> int:
76
+ """
77
+ Run and stream combined stdout/stderr line-by-line to on_log.
78
+ Returns process return code.
79
+ """
80
+ # Merge env
81
+ merged_env = os.environ.copy()
82
+ if env:
83
+ merged_env.update(env)
84
+
85
+ self._proc = subprocess.Popen(
86
+ cmd,
87
+ cwd=str(cwd) if cwd else None,
88
+ env=merged_env,
89
+ stdout=subprocess.PIPE,
90
+ stderr=subprocess.STDOUT,
91
+ text=True,
92
+ bufsize=1,
93
+ universal_newlines=True,
94
+ )
95
+
96
+ assert self._proc.stdout is not None
97
+ for line in self._proc.stdout:
98
+ line = line.rstrip("\n")
99
+ if on_log:
100
+ on_log(line)
101
+
102
+ rc = self._proc.wait()
103
+ self._proc = None
104
+ return rc
105
+
106
+ def cancel(self) -> None:
107
+ """
108
+ Best-effort termination of the running process.
109
+ """
110
+ if not self._proc:
111
+ return
112
+
113
+ try:
114
+ self._proc.terminate()
115
+ except Exception:
116
+ pass
117
+
118
+ try:
119
+ self._proc.wait(timeout=2)
120
+ self._proc = None
121
+ return
122
+ except Exception:
123
+ pass
124
+
125
+ try:
126
+ self._proc.kill()
127
+ except Exception:
128
+ pass
129
+ finally:
130
+ self._proc = None
131
+
132
+
133
+ def python_executable() -> str:
134
+ """
135
+ Always use the current interpreter (e.g. conda env) to run scripts.
136
+ """
137
+ return sys.executable
recorder/paths.py ADDED
@@ -0,0 +1,87 @@
1
+ """
2
+ Centralized path resolution for the recorder package.
3
+
4
+ Handles finding configs and tools in both development mode
5
+ (running from repo clone) and installed mode (pip install).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ from importlib import resources
11
+ from pathlib import Path
12
+
13
+
14
+ def tool_root() -> Path:
15
+ """Return the calib_record_tool root directory.
16
+
17
+ In dev mode (repo clone), returns the actual calib_record_tool/ dir.
18
+ After pip install, returns the current working directory.
19
+ """
20
+ dev = _dev_root()
21
+ return dev if dev else Path.cwd()
22
+
23
+
24
+ def _dev_root() -> Path | None:
25
+ """Return calib_record_tool/ root if running from a repo clone."""
26
+ # __file__ = .../calib_record_tool/src/recorder/paths.py
27
+ candidate = Path(__file__).resolve().parents[2]
28
+ if (candidate / "configs" / "default.yaml").exists():
29
+ return candidate
30
+ return None
31
+
32
+
33
+ def resolve_default_config() -> Path:
34
+ """Find the default.yaml config file.
35
+
36
+ Search order:
37
+ 1. Package data (recorder/configs/default.yaml) — works after pip install
38
+ 2. Repo layout (calib_record_tool/configs/default.yaml) — works in dev
39
+ """
40
+ # Try package data first (works after pip install from wheel)
41
+ try:
42
+ pkg_configs = resources.files("recorder") / "configs" / "default.yaml"
43
+ p = Path(str(pkg_configs))
44
+ if p.exists():
45
+ return p
46
+ except (TypeError, FileNotFoundError, AttributeError):
47
+ pass
48
+
49
+ # Fallback: repo layout
50
+ dev = _dev_root()
51
+ if dev:
52
+ p = dev / "configs" / "default.yaml"
53
+ if p.exists():
54
+ return p
55
+
56
+ raise FileNotFoundError(
57
+ "Cannot find default.yaml config. "
58
+ "Pass --config explicitly or run from the repo directory."
59
+ )
60
+
61
+
62
+ def resolve_ffmpeg() -> str:
63
+ """Find ffmpeg executable.
64
+
65
+ Search order:
66
+ 1. System PATH (preferred — works cross-platform)
67
+ 2. Repo-local bin/ directory (development fallback)
68
+ """
69
+ # System PATH first
70
+ which = shutil.which("ffmpeg")
71
+ if which:
72
+ return which
73
+
74
+ # Dev fallback: bin/ffmpeg/ffmpeg.exe
75
+ dev = _dev_root()
76
+ if dev:
77
+ local = dev / "bin" / "ffmpeg" / "ffmpeg.exe"
78
+ if local.exists():
79
+ return str(local)
80
+
81
+ raise FileNotFoundError(
82
+ "ffmpeg not found. Install ffmpeg and ensure it is on your PATH.\n"
83
+ " Windows: winget install ffmpeg OR choco install ffmpeg\n"
84
+ " Linux: sudo apt install ffmpeg\n"
85
+ " macOS: brew install ffmpeg\n"
86
+ " Conda: conda install -c conda-forge ffmpeg"
87
+ )