Skip to content

Commit 6a09507

Browse files
oseiskarpekkaran
andauthored
New command: sai cli convert (#53)
Usage: sai cli convert [format] [input] [output] (options...) As the first supported format, added the TUM ASL/DSO/EuRoC format (as format = tum). --------- Co-authored-by: Pekka Rantalankila <[email protected]>
1 parent 312bea6 commit 6a09507

File tree

4 files changed

+329
-0
lines changed

4 files changed

+329
-0
lines changed

python/cli/convert/__init__.py

Whitespace-only changes.

python/cli/convert/convert.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Convert data to Spectacular AI format"""
2+
3+
from .tum import define_subparser as define_subparser_tum
4+
5+
def define_subparser(subparsers):
6+
sub = subparsers.add_parser('convert', help=__doc__.strip())
7+
format_subparsers = sub.add_subparsers(title='format', dest='format', required=True)
8+
define_subparser_tum(format_subparsers)
9+
return sub

python/cli/convert/tum.py

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Convert data from TUM "Euroc / DSO / ASL" benchmark format to Spectacular AI
4+
format. See <https://vision.in.tum.de/data/datasets/visual-inertial-dataset>
5+
for details about the data format.
6+
"""
7+
8+
import argparse
9+
import csv
10+
import json
11+
import os
12+
from pathlib import Path
13+
import subprocess
14+
import yaml
15+
from contextlib import contextmanager
16+
import shutil
17+
import tempfile
18+
import tarfile
19+
import zipfile
20+
import numpy as np
21+
22+
def define_args(parser):
23+
parser.add_argument('input', help='Path to the input data in TUM format (.tar, .zip or directory)')
24+
parser.add_argument('output', help='Path to the output directory')
25+
parser.add_argument('--fps', type=int, default=20, help='Frames per second (metadata only)')
26+
parser.add_argument('--crf', type=int, default=15, help='FFmpeg video compression quality (0=lossless)')
27+
parser.add_argument('--mono', action='store_true', help='Monocular mode')
28+
29+
def convertVideo(files, output, fps, crf):
30+
# Use `-crf 0` for lossless compression.
31+
subprocess.check_call(["ffmpeg",
32+
"-y",
33+
"-r", str(fps),
34+
"-f", "image2",
35+
"-pattern_type", "glob", "-i", files,
36+
"-c:v", "libx264",
37+
"-preset", "veryfast",
38+
"-crf", str(crf),
39+
"-vf", "format=yuv420p",
40+
"-an",
41+
"-hide_banner",
42+
"-loglevel", "error",
43+
output])
44+
45+
@contextmanager
46+
def maybe_extract_tar_or_zip(path):
47+
temp_dir = None
48+
try:
49+
if not os.path.exists(path):
50+
raise FileNotFoundError(f"The path '{path}' does not exist.")
51+
52+
if path.lower().endswith('.tar') and os.path.isfile(path):
53+
temp_dir = tempfile.mkdtemp(prefix="extracted_tar_")
54+
try:
55+
with tarfile.open(path, 'r') as tar:
56+
tar.extractall(path=temp_dir)
57+
except tarfile.TarError as e:
58+
shutil.rmtree(temp_dir)
59+
raise tarfile.TarError(f"Failed to extract tar file '{path}': {e}")
60+
61+
subdirs = [d for d in os.listdir(temp_dir) if os.path.isdir(os.path.join(temp_dir, d))]
62+
if len(subdirs) != 1:
63+
raise ValueError(f"The directory '{path}' does not contain exactly one subfolder.")
64+
65+
yield os.path.join(temp_dir, subdirs[0])
66+
67+
elif path.lower().endswith('.zip') and os.path.isfile(path):
68+
temp_dir = tempfile.mkdtemp(prefix="extracted_zip_")
69+
try:
70+
with zipfile.ZipFile(path, 'r') as zip_ref:
71+
zip_ref.extractall(path=temp_dir)
72+
except zipfile.BadZipFile as e:
73+
shutil.rmtree(temp_dir)
74+
raise zipfile.BadZipFile(f"Failed to extract zip file '{path}': {e}")
75+
yield temp_dir
76+
77+
elif os.path.isdir(path):
78+
yield path
79+
else:
80+
raise ValueError(f"The path '{path}' is neither a .tar file nor a directory.")
81+
finally:
82+
if temp_dir and os.path.isdir(temp_dir):
83+
try:
84+
shutil.rmtree(temp_dir)
85+
except Exception as e:
86+
print(f"Warning: Failed to delete temporary directory '{temp_dir}': {e}")
87+
88+
def get_calibration(input_dir, stereo):
89+
calibration = { "cameras": [] }
90+
91+
def convert_distortion(model, coeffs):
92+
if coeffs is None:
93+
return ('pinhole', None)
94+
if model == "radial-tangential":
95+
c1,c2,c3,c4 = coeffs
96+
return ('brown-conrady', [c1, c2, c3, c4, 0, 0, 0, 0])
97+
elif model == "equidistant":
98+
return ('kannala-brandt4', coeffs)
99+
else:
100+
raise ValueError("Unknown distortion model: " + model)
101+
102+
def convert_camera_model(yaml_data):
103+
intrinsics = yaml_data["intrinsics"]
104+
out = {
105+
"focalLengthX": intrinsics[0],
106+
"focalLengthY": intrinsics[1],
107+
"principalPointX": intrinsics[2],
108+
"principalPointY": intrinsics[3],
109+
"imageWidth": yaml_data["resolution"][0],
110+
"imageHeight": yaml_data["resolution"][1],
111+
}
112+
113+
model, coeffs = convert_distortion(
114+
yaml_data.get("distortion_model"),
115+
yaml_data.get("distortion_coeffs", yaml_data.get("distortion_coefficients")))
116+
117+
if 'T_cam_imu' in yaml_data:
118+
out['imuToCamera'] = yaml_data['T_cam_imu']
119+
else:
120+
if 'T_imu_cam' in yaml_data:
121+
cam_to_imu = yaml_data['T_imu_cam']
122+
elif 'T_BS' in yaml_data:
123+
cam_to_imu = np.array(yaml_data['T_BS']['data']).reshape((4, 4))
124+
else:
125+
raise ValueError("No IMU to cam transformation found")
126+
out['imuToCamera'] = np.linalg.inv(cam_to_imu).tolist()
127+
128+
out['model'] = model
129+
out['distortionCoefficients'] = coeffs
130+
return out
131+
132+
if stereo:
133+
cams = [0, 1]
134+
else:
135+
cams = [0]
136+
137+
dsoPath = os.path.join(input_dir, 'dso', 'camchain.yaml')
138+
if os.path.exists(dsoPath):
139+
with open(dsoPath) as yamlFile:
140+
data = yaml.load(yamlFile, Loader=yaml.FullLoader)
141+
for i in cams:
142+
cam = "cam{}".format(i)
143+
d = data[cam]
144+
calibration["cameras"].append(convert_camera_model(d))
145+
146+
elif os.path.exists(os.path.join(input_dir, 'mav0', 'cam0', 'sensor.yaml')):
147+
for cam in cams:
148+
with open(os.path.join(input_dir, 'mav0', 'cam%d' % cam, 'sensor.yaml')) as f:
149+
p = yaml.load(f, Loader=yaml.FullLoader)
150+
calibration["cameras"].append(convert_camera_model(p))
151+
else:
152+
print('Warning: no TUM calibration files found')
153+
return None
154+
155+
return calibration
156+
157+
def convert_with_existing_folders(rawPath, outPath, fps, crf, stereo):
158+
calibration = get_calibration(rawPath, stereo)
159+
if calibration is not None:
160+
with open(os.path.join(outPath, "calibration.json"), "w") as f:
161+
f.write(json.dumps(calibration, indent=2))
162+
163+
# The two stereo folder image files seem to be perfectly matched, unlike in the EuRoC data.
164+
NS_TO_SECONDS = 1000 * 1000 * 1000 # Timestamps are in nanoseconds
165+
166+
# Use images that are present for both cameras.
167+
# Rename bad files so that they do not match glob `*.png` given for ffmpeg.
168+
timestamps = []
169+
timestamps0 = []
170+
timestamps1 = []
171+
dir0 = os.path.join(rawPath, 'mav0', 'cam0', 'data')
172+
dir1 = os.path.join(rawPath, 'mav0', 'cam1', 'data')
173+
n_bad_frames = 0
174+
for filename in os.listdir(dir0):
175+
timestamps0.append(filename)
176+
if stereo:
177+
for filename in os.listdir(dir1):
178+
timestamps1.append(filename)
179+
for t in timestamps0:
180+
if stereo and t not in timestamps1:
181+
n_bad_frames += 1
182+
else:
183+
timestamps.append(int(os.path.splitext(t)[0]))
184+
185+
temp_dir = None
186+
if n_bad_frames > 0:
187+
print('Warning: {} frame(s) are missing in one of the stereo cameras, creating temp dir'.format(n_bad_frames))
188+
assert(stereo)
189+
temp_dir = tempfile.mkdtemp(prefix="fixed_frames_")
190+
for cam in ["cam0", "cam1"]:
191+
tmp_cam_dir = os.path.join(temp_dir, cam, 'data')
192+
os.makedirs(tmp_cam_dir)
193+
for t in timestamps:
194+
src = os.path.join(rawPath, 'mav0', cam, 'data', '{}.png'.format(t))
195+
dst = os.path.join(tmp_cam_dir, '{}.png'.format(t))
196+
shutil.copyfile(src, dst)
197+
198+
# shift timestamps to around zero to avoid floating point accuracy issues.
199+
timestamps = sorted(timestamps)
200+
t0 = timestamps[0]
201+
202+
output = []
203+
number = 0
204+
for timestamp in timestamps:
205+
t = (timestamp - t0) / NS_TO_SECONDS
206+
x = {
207+
"number": number,
208+
"time": t,
209+
"frames": [
210+
{"cameraInd": 0, "time": t},
211+
],
212+
}
213+
if stereo:
214+
x['frames'].append({"cameraInd": 1, "time": t})
215+
output.append(x)
216+
number += 1
217+
218+
with open(os.path.join(rawPath, 'mav0', 'imu0', 'data.csv')) as csvfile:
219+
# timestamp [ns],w_RS_S_x [rad s^-1],w_RS_S_y [rad s^-1],w_RS_S_z [rad s^-1],
220+
# a_RS_S_x [m s^-2],a_RS_S_y [m s^-2],a_RS_S_z [m s^-2]
221+
csvreader = csv.reader(csvfile, delimiter=',')
222+
next(csvreader) # Skip header
223+
for row in csvreader:
224+
timestamp = (int(row[0]) - t0) / NS_TO_SECONDS
225+
output.append({
226+
"sensor": {
227+
"type": "gyroscope",
228+
"values": [float(row[1]), float(row[2]), float(row[3])]
229+
},
230+
"time": timestamp
231+
})
232+
output.append({
233+
"sensor": {
234+
"type": "accelerometer",
235+
"values": [float(row[4]), float(row[5]), float(row[6])]
236+
},
237+
"time": timestamp
238+
})
239+
240+
gtPath = None
241+
242+
mocapPath = os.path.join(rawPath, 'mav0', 'mocap0', 'data.csv')
243+
gtStatePath = os.path.join(rawPath, 'mav0', 'state_groundtruth_estimate0', 'data.csv')
244+
if os.path.exists(mocapPath):
245+
gtPath = mocapPath
246+
elif os.path.exists(gtStatePath):
247+
gtPath = gtStatePath
248+
249+
if gtPath is not None:
250+
with open(gtPath) as csvfile:
251+
# timestamp [ns], p_RS_R_x [m], p_RS_R_y [m], p_RS_R_z [m],
252+
# q_RS_w [], q_RS_x [], q_RS_y [], q_RS_z []
253+
csvreader = csv.reader(csvfile, delimiter=',')
254+
next(csvreader) # Skip header
255+
for row in csvreader:
256+
timestamp = (int(row[0]) - t0) / NS_TO_SECONDS
257+
output.append({
258+
"groundTruth": {
259+
"position": {
260+
"x": float(row[1]), "y": float(row[2]), "z": float(row[3])
261+
},
262+
"orientation": {
263+
"w": float(row[4]), "x": float(row[5]), "y": float(row[6]), "z": float(row[7])
264+
}
265+
},
266+
"time": timestamp
267+
})
268+
269+
output = sorted(output, key=lambda row: row["time"]) # Sort by time
270+
with open(os.path.join(outPath, 'data.jsonl'), "w") as f:
271+
for obj in output:
272+
f.write(json.dumps(obj, separators=(',', ':')))
273+
f.write("\n")
274+
275+
if not stereo:
276+
# would be nicer if this was not needed
277+
with open(os.path.join(outPath, 'vio_config.yaml'), 'w') as f:
278+
f.write('useStereo: false')
279+
280+
if temp_dir is None:
281+
video_dir = os.path.join(rawPath, 'mav0')
282+
else:
283+
video_dir = temp_dir
284+
try:
285+
# convert videos last. This is the slowest step
286+
convertVideo(os.path.join(video_dir, 'cam0', 'data', '*.png'), os.path.join(outPath, 'data.mp4'), fps, crf)
287+
if stereo:
288+
convertVideo(os.path.join(video_dir, 'cam1', 'data', '*.png'), os.path.join(outPath, 'data2.mp4'), fps, crf)
289+
finally:
290+
if temp_dir:
291+
shutil.rmtree(temp_dir)
292+
293+
294+
def convert(inputPath, outputPath, **kwargs):
295+
Path(outputPath).mkdir(parents=True, exist_ok=True)
296+
with maybe_extract_tar_or_zip(inputPath) as path:
297+
convert_with_existing_folders(path, outputPath, **kwargs)
298+
299+
def convert_cli(args):
300+
convert(args.input, args.output, fps=args.fps, crf=args.crf, stereo=not args.mono)
301+
302+
def define_subparser(subparsers):
303+
sub = subparsers.add_parser('tum',
304+
description="Convert data from TUM format to Spectacular AI format",
305+
epilog=__doc__,
306+
formatter_class=argparse.RawDescriptionHelpFormatter)
307+
sub.set_defaults(func=convert_cli)
308+
return define_args(sub)
309+
310+
if __name__ == '__main__':
311+
def parse_args():
312+
import argparse
313+
parser = argparse.ArgumentParser(description=__doc__.strip())
314+
define_args(parser)
315+
return parser.parse_args()
316+
317+
args = parse_args()
318+
convert_cli(args)

python/cli/sai_cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from .process.process import define_subparser as process_define_subparser
44
from .record.record import define_subparser as record_define_subparser
5+
from .convert.convert import define_subparser as convert_define_subparser
56
from .smooth import define_subparser as smooth_define_subparser
67

78
def parse_args():
@@ -10,6 +11,7 @@ def parse_args():
1011
process_define_subparser(subparsers)
1112
record_define_subparser(subparsers)
1213
smooth_define_subparser(subparsers)
14+
convert_define_subparser(subparsers)
1315
return parser.parse_args()
1416

1517
def main():

0 commit comments

Comments
 (0)