Skip to content

Commit 59ffa83

Browse files
Implemented a basic CLI
commit-id:b2136058
1 parent fdf1f29 commit 59ffa83

16 files changed

+1029
-388
lines changed

packages/child-lab-cli/hello.py

-1
This file was deleted.

packages/child-lab-cli/pyproject.toml

+28-1
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,31 @@ version = "0.1.0"
44
description = "Add your description here"
55
readme = "README.md"
66
requires-python = ">=3.12.7"
7-
dependencies = []
7+
dependencies = [
8+
"click>=8.1.8",
9+
"pyserde>=0.23.0",
10+
"torch>=2.6.0",
11+
"tqdm>=4.67.1",
12+
"viser>=0.2.23",
13+
"child-lab-visualization",
14+
"child-lab-procedures",
15+
"depth-estimation",
16+
"marker-detection",
17+
"transformation-buffer",
18+
"video-io",
19+
]
20+
21+
[tool.uv.sources]
22+
child-lab-procedures = { workspace = true }
23+
child-lab-visualization = { workspace = true }
24+
depth-estimation = { workspace = true }
25+
marker-detection = { workspace = true }
26+
transformation-buffer = { workspace = true }
27+
video-io = { workspace = true }
28+
29+
[build-system]
30+
requires = ["hatchling"]
31+
build-backend = "hatchling.build"
32+
33+
[project.scripts]
34+
child-lab = "child_lab_cli:child_lab"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import click
2+
3+
from .commands import (
4+
calibrate_camera,
5+
estimate_transformations,
6+
generate_pointcloud,
7+
process,
8+
video,
9+
visualize,
10+
workspace,
11+
)
12+
13+
14+
@click.group('child-lab')
15+
def cli() -> None: ...
16+
17+
18+
cli.add_command(calibrate_camera)
19+
cli.add_command(estimate_transformations)
20+
cli.add_command(generate_pointcloud)
21+
cli.add_command(process)
22+
cli.add_command(video)
23+
cli.add_command(visualize)
24+
cli.add_command(workspace)
25+
26+
27+
def child_lab() -> None:
28+
cli(max_content_width=120)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from .calibrate_camera import calibrate_camera
2+
from .estimate_transformations import estimate_transformations
3+
from .generate_pointcloud import generate_pointcloud
4+
from .process import process
5+
from .video import video
6+
from .visualize import visualize
7+
from .workspace import workspace
8+
9+
__all__ = [
10+
'calibrate_camera',
11+
'estimate_transformations',
12+
'generate_pointcloud',
13+
'process',
14+
'video',
15+
'visualize',
16+
'workspace',
17+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from pathlib import Path
2+
3+
import click
4+
from child_lab_procedures.calibrate_camera import Configuration, Procedure, VideoIoContext
5+
from marker_detection.chessboard import BoardProperties, VisualizationContext
6+
from serde.yaml import to_yaml
7+
from tqdm import trange
8+
from video_io import Reader, Visualizer, Writer
9+
10+
from child_lab_cli.workspace.model import Workspace
11+
12+
13+
@click.command('calibrate-camera', options_metavar='<options>')
14+
@click.argument('workspace-root', type=Path, metavar='<workspace>')
15+
@click.argument('video-name', type=str, metavar='<video>')
16+
@click.option(
17+
'--square-size',
18+
type=float,
19+
required=True,
20+
help='Board square size in centimeters',
21+
metavar='<square-size>',
22+
)
23+
@click.option(
24+
'--inner-board-corners',
25+
nargs=2,
26+
type=int,
27+
required=True,
28+
help="Number of chessboard's inner corners in rows and columns",
29+
metavar='<inner-shape>',
30+
)
31+
@click.option(
32+
'--max-samples',
33+
type=int,
34+
required=False,
35+
help='Maximal number of board samples to collect',
36+
)
37+
@click.option(
38+
'--max-speed',
39+
type=float,
40+
default=float('inf'),
41+
required=False,
42+
help='Maximal speed the board can move with to be captured, in pixels per second',
43+
)
44+
@click.option(
45+
'--min-distance',
46+
type=float,
47+
default=0.3,
48+
required=False,
49+
help='Minimal distance between new observation and the previous observations to be captured',
50+
)
51+
def calibrate_camera(
52+
workspace_root: Path,
53+
video_name: str,
54+
square_size: float,
55+
inner_board_corners: tuple[int, int],
56+
max_samples: int | None,
57+
max_speed: float,
58+
min_distance: float,
59+
) -> None:
60+
"""
61+
Calibrate the camera using <video> from <workspace> by detecting
62+
inner corners of a chessboard of <inner-shape> with <square-size>.
63+
"""
64+
65+
workspace = Workspace.in_directory(workspace_root)
66+
67+
video_output = workspace.output / 'calibration'
68+
video_output.mkdir(exist_ok=True)
69+
video_destination = (video_output / video_name).with_suffix('.mp4')
70+
71+
calibration_destination = (workspace.calibration / video_name).with_suffix('.yml')
72+
73+
video = next((v for v in workspace.videos() if v.name == video_name), None)
74+
if video is None:
75+
raise click.ClickException(
76+
f'Input video {video_name} not found in {workspace.input}'
77+
)
78+
79+
reader = Reader(video.location)
80+
writer = Writer(
81+
video_destination,
82+
reader.metadata,
83+
Visualizer[VisualizationContext]({'chessboard_draw_corners': True}),
84+
)
85+
video_io_context = VideoIoContext(video_name, reader, writer)
86+
87+
configuration = Configuration(
88+
BoardProperties(square_size, *inner_board_corners),
89+
max_samples,
90+
max_speed,
91+
min_distance,
92+
)
93+
94+
procedure = Procedure(configuration, video_io_context)
95+
96+
progress_bar = trange(
97+
procedure.length_estimate(),
98+
desc='Gathering samples for calibration...',
99+
)
100+
101+
samples = procedure.run(lambda: progress_bar.update())
102+
match samples:
103+
case None:
104+
raise click.ClickException('Procedure has diverged')
105+
106+
case samples:
107+
click.echo('Computing calibration...')
108+
result = samples.calibrate()
109+
calibration_destination.touch()
110+
calibration_destination.write_text(to_yaml(result.calibration))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
from pathlib import Path
2+
3+
import click
4+
from child_lab_procedures.estimate_transformations import (
5+
Configuration,
6+
Procedure,
7+
VideoIoContext,
8+
)
9+
from marker_detection.aruco import (
10+
Dictionary,
11+
RigidModel,
12+
VisualizationContext,
13+
)
14+
from serde.json import to_json
15+
from serde.yaml import from_yaml
16+
from tqdm import trange
17+
from transformation_buffer.rigid_model import Cube
18+
from video_io.calibration import Calibration
19+
from video_io.reader import Reader
20+
from video_io.visualizer import Visualizer
21+
from video_io.writer import Writer
22+
23+
from child_lab_cli.workspace.model import Workspace
24+
25+
26+
@click.command('estimate-transformations')
27+
@click.argument('workspace-root', type=Path, metavar='<workspace>')
28+
@click.argument('video-names', type=str, nargs=-1, metavar='<videos>')
29+
@click.option(
30+
'--marker-dictionary',
31+
type=str,
32+
help='Dictionary to detect markers from',
33+
metavar='<dictionary>',
34+
)
35+
@click.option(
36+
'--marker-size',
37+
type=float,
38+
help='Marker size in centimeters',
39+
metavar='<size>',
40+
)
41+
@click.option(
42+
'--visualize',
43+
type=bool,
44+
is_flag=True,
45+
default=False,
46+
help='Produce videos with visualizations',
47+
)
48+
@click.option(
49+
'--device',
50+
type=str,
51+
required=False,
52+
help='Torch device to use for tensor computations',
53+
)
54+
@click.option(
55+
'--checkpoint',
56+
type=Path,
57+
required=False,
58+
help='File containing a serialized transformation buffer to load and place new transformations in',
59+
)
60+
@click.option(
61+
'--skip',
62+
type=int,
63+
required=False,
64+
help='Seconds of videos to skip at the beginning',
65+
)
66+
def estimate_transformations(
67+
workspace_root: Path,
68+
video_names: list[str],
69+
marker_dictionary: str,
70+
marker_size: float,
71+
visualize: bool,
72+
device: str | None,
73+
checkpoint: Path | None,
74+
skip: int | None,
75+
) -> None:
76+
"""
77+
Estimate mutual poses of cameras using <videos> from <workspace>
78+
by detecting ArUco markers of <size>, from <dictionary>
79+
and save them as a JSON-serialized transformation buffer.
80+
"""
81+
82+
workspace = Workspace.in_directory(workspace_root)
83+
84+
buffer_destination = workspace.transformation / 'buffer.json'
85+
86+
video_output = workspace.output / 'transformation'
87+
video_output.mkdir(exist_ok=True)
88+
89+
video_io_contexts: list[VideoIoContext] = []
90+
calibrated_videos = workspace.calibrated_videos()
91+
92+
for video_name in video_names:
93+
video = next((v for v in calibrated_videos if v.name == video_name), None)
94+
95+
if video is None:
96+
raise click.ClickException(
97+
f'Input video {video_name} not found in {workspace.input}'
98+
)
99+
100+
assert video.calibration.is_file()
101+
102+
calibration = from_yaml(Calibration, video.calibration.read_text())
103+
104+
reader = Reader(video.location)
105+
106+
writer = (
107+
Writer(
108+
(video_output / video.name).with_suffix('.mp4'),
109+
reader.metadata,
110+
Visualizer[VisualizationContext](
111+
{
112+
'intrinsics': calibration.intrinsics_matrix().numpy(),
113+
'marker_draw_masks': True,
114+
'marker_draw_ids': True,
115+
'marker_draw_axes': True,
116+
'marker_draw_angles': True,
117+
'marker_mask_color': (0.0, 1.0, 0.0, 1.0),
118+
'marker_axis_length': 100,
119+
'marker_axis_thickness': 1,
120+
}
121+
),
122+
)
123+
if visualize
124+
else None
125+
)
126+
127+
context = VideoIoContext(
128+
video.name,
129+
calibration,
130+
reader,
131+
writer,
132+
)
133+
video_io_contexts.append(context)
134+
135+
dictionary = Dictionary.parse(marker_dictionary)
136+
assert dictionary is not None
137+
138+
configuration = Configuration(
139+
RigidModel(marker_size, 1.0),
140+
dictionary,
141+
arudice=DEFAULT_ARUDICE,
142+
)
143+
144+
procedure = Procedure(configuration, video_io_contexts)
145+
progress_bar = trange(
146+
procedure.length_estimate(),
147+
desc='Estimating transformations...',
148+
)
149+
150+
result = procedure.run(lambda: progress_bar.update())
151+
152+
match result:
153+
case None:
154+
raise click.ClickException('Procedure has diverged')
155+
156+
case buffer:
157+
buffer_destination.touch()
158+
buffer_destination.write_text(to_json(buffer))
159+
click.echo('Done!')
160+
161+
162+
DEFAULT_ARUDICE = [
163+
Cube[str](
164+
50.0,
165+
('marker_42', 'marker_43', 'marker_44', 'marker_45', 'marker_46', 'marker_47'),
166+
)
167+
]

0 commit comments

Comments
 (0)