Skip to content

Commit a902d6e

Browse files
authored
Add sai-cli diagnose tool (#69)
* Add sai-cli diagnoze (v1) * Refactor & check for duplicate signal values * Support magnetometer * Support barometer * Add sanity check for maximum frequency * Add script to run sai-cli without installation (#67) * Check timestamps overlap with IMU * Support GPS * Add IMU noise analysis & clean up * Require either --output_html or --output_json * Try checking IMU units are correct * Check if accelerometer signal has gravity * Dont warn about bad delta times with allowDataGaps=True * Add colors to issues list
1 parent 72e63a7 commit a902d6e

File tree

8 files changed

+1199
-3
lines changed

8 files changed

+1199
-3
lines changed

python/cli/calibrate/calibrate.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ def define_subparser(subparsers):
1414
sub = subparsers.add_parser('calibrate', help=__doc__.strip())
1515
sub.set_defaults(func=call_calibrate)
1616
from spectacularAI.calibration import define_args as define_args_calibration
17-
from .report import define_args as define_args_report
18-
define_args_calibration(sub)
19-
define_args_report(sub)
17+
try:
18+
from .report import define_args as define_args_report
19+
define_args_calibration(sub)
20+
define_args_report(sub)
21+
except:
22+
pass

python/cli/diagnose/__init__.py

Whitespace-only changes.

python/cli/diagnose/diagnose.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""
2+
Visualize and diagnose common issues in data in Spectacular AI format
3+
"""
4+
5+
import json
6+
import pathlib
7+
import os
8+
9+
from .html import generateHtml
10+
from .sensors import *
11+
from .gnss import GnssConverter
12+
13+
def define_args(parser):
14+
parser.add_argument("dataset_path", type=pathlib.Path, help="Path to dataset")
15+
parser.add_argument("--output_html", type=pathlib.Path, help="Path to calibration report HTML output.")
16+
parser.add_argument("--output_json", type=pathlib.Path, help="Path to JSON output.")
17+
parser.add_argument("--zero", help="Rescale time to start from zero", action='store_true')
18+
parser.add_argument("--skip", type=float, help="Skip N seconds from the start")
19+
parser.add_argument("--max", type=float, help="Plot max N seconds from the start")
20+
return parser
21+
22+
def define_subparser(subparsers):
23+
sub = subparsers.add_parser('diagnose', help=__doc__.strip())
24+
sub.set_defaults(func=generateReport)
25+
return define_args(sub)
26+
27+
def generateReport(args):
28+
from datetime import datetime
29+
30+
datasetPath = args.dataset_path
31+
jsonlFile = datasetPath if datasetPath.suffix == ".jsonl" else datasetPath.joinpath("data.jsonl")
32+
if not jsonlFile.is_file():
33+
raise FileNotFoundError(f"{jsonlFile} does not exist")
34+
35+
output = {
36+
'passed': True,
37+
'date': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
38+
'dataset_path': str(jsonlFile.parent)
39+
}
40+
41+
if not args.output_html and not args.output_json:
42+
print("Either --output_html or --output_json is required")
43+
return
44+
45+
data = {
46+
'accelerometer': {"v": [], "t": [], "td": []},
47+
'gyroscope': {"v": [], "t": [], "td": []},
48+
'magnetometer': {"v": [], "t": [], "td": []},
49+
'barometer': {"v": [], "t": [], "td": []},
50+
'gps': {"v": [], "t": [], "td": []},
51+
'cpu': {"v": [], "t": []},
52+
'cameras': {}
53+
}
54+
55+
def addMeasurement(type, t, v):
56+
assert type in data, f"Unknown sensor type: {type}"
57+
sensorData = data[type]
58+
sensorData['v'].append(v)
59+
if len(sensorData["t"]) > 0:
60+
diff = t - sensorData["t"][-1]
61+
sensorData["td"].append(diff)
62+
sensorData["t"].append(t)
63+
64+
startTime = None
65+
timeOffset = 0
66+
gnssConverter = GnssConverter()
67+
68+
with open(jsonlFile) as f:
69+
nSkipped = 0
70+
for line in f.readlines():
71+
try:
72+
measurement = json.loads(line)
73+
except:
74+
print(f"Warning: ignoring non JSON line: '{line}'")
75+
continue
76+
time = measurement.get("time")
77+
sensor = measurement.get("sensor")
78+
barometer = measurement.get("barometer")
79+
gps = measurement.get("gps")
80+
frames = measurement.get("frames")
81+
metrics = measurement.get("systemMetrics")
82+
if frames is None and 'frame' in measurement:
83+
frames = [measurement['frame']]
84+
frames[0]['cameraInd'] = 0
85+
86+
if time is None: continue
87+
88+
if (sensor is None
89+
and frames is None
90+
and metrics is None
91+
and barometer is None
92+
and gps is None): continue
93+
94+
if startTime is None:
95+
startTime = time
96+
if args.zero:
97+
timeOffset = startTime
98+
99+
100+
if (args.skip is not None and time - startTime < args.skip) or (args.max is not None and time - startTime > args.max):
101+
nSkipped += 1
102+
continue
103+
104+
t = time - timeOffset
105+
if sensor is not None:
106+
measurementType = sensor["type"]
107+
if measurementType in ["accelerometer", "gyroscope", "magnetometer"]:
108+
v = [sensor["values"][i] for i in range(3)]
109+
addMeasurement(measurementType, t, v)
110+
elif barometer is not None:
111+
addMeasurement("barometer", t, barometer["pressureHectopascals"])
112+
elif gps is not None:
113+
enu = gnssConverter.enu(gps["latitude"], gps["longitude"], gps["altitude"])
114+
addMeasurement("gps", t, [enu["x"], enu["y"], gps["altitude"]])
115+
elif frames is not None:
116+
for f in frames:
117+
if f.get("missingBitmap", False): continue
118+
cameras = data['cameras']
119+
ind = f["cameraInd"]
120+
if cameras.get(ind) is None:
121+
cameras[ind] = {"td": [], "t": [], "features": []}
122+
else:
123+
diff = t - cameras[ind]["t"][-1]
124+
cameras[ind]["td"].append(diff)
125+
if "features" in f: cameras[ind]["features"].append(len(f["features"]))
126+
cameras[ind]["t"].append(t)
127+
elif metrics is not None and 'cpu' in metrics:
128+
data["cpu"]["t"].append(t)
129+
data["cpu"]["v"].append(metrics['cpu'].get('systemTotalUsagePercent', 0))
130+
131+
if nSkipped > 0: print(f'Skipped {nSkipped} lines')
132+
133+
diagnoseCamera(data, output)
134+
diagnoseAccelerometer(data, output)
135+
diagnoseGyroscope(data, output)
136+
diagnoseMagnetometer(data, output)
137+
diagnoseBarometer(data, output)
138+
diagnoseGps(data, output)
139+
diagnoseCpu(data, output)
140+
141+
if args.output_json:
142+
if os.path.dirname(args.output_json):
143+
os.makedirs(os.path.dirname(args.output_json), exist_ok=True)
144+
with open(args.output_json, "w") as f:
145+
f.write(json.dumps(output, indent=4))
146+
print("Generated JSON report data at:", args.output_json)
147+
148+
if args.output_html:
149+
if os.path.dirname(args.output_html):
150+
os.makedirs(os.path.dirname(args.output_html), exist_ok=True)
151+
generateHtml(output, args.output_html)
152+
print("Generated HTML report at:", args.output_html)
153+
154+
if __name__ == '__main__':
155+
def parse_args():
156+
import argparse
157+
parser = argparse.ArgumentParser(description=__doc__.strip())
158+
parser = define_args(parser)
159+
return parser.parse_args()
160+
161+
generateReport(parse_args())

python/cli/diagnose/gnss.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import numpy as np
2+
3+
class Ellipsoid:
4+
def __init__(self, a, b):
5+
self.a = a # semi-major axis
6+
self.b = b # semi-minor axis
7+
f = 1.0 - b / a # flattening factor
8+
self.e2 = 2 * f - f ** 2 # eccentricity squared
9+
10+
class GnssConverter:
11+
def __init__(self):
12+
self.ell = Ellipsoid(a=6378137.0, b=6356752.31424518) # WGS-84 ellipsoid
13+
self.initialized = False
14+
self.originECEF = None
15+
self.R_ecef2enu = None
16+
self.R_enu2ecef = None
17+
self.prev = {"x": 0, "y": 0, "z": 0}
18+
19+
def set_origin(self, lat, lon, alt):
20+
def ecef_to_enu_rotation_matrix(lat, lon):
21+
lat = np.deg2rad(lat)
22+
lon = np.deg2rad(lon)
23+
24+
return np.array([
25+
[-np.sin(lon), np.cos(lon), 0],
26+
[-np.sin(lat)*np.cos(lon), -np.sin(lat)*np.sin(lon), np.cos(lat)],
27+
[np.cos(lat)*np.cos(lon), np.cos(lat)*np.sin(lon), np.sin(lat)]
28+
])
29+
30+
self.initialized = True
31+
self.originECEF = self.__geodetic2ecef(lat, lon, alt)
32+
self.R_ecef2enu = ecef_to_enu_rotation_matrix(lat, lon)
33+
self.R_enu2ecef = self.R_ecef2enu.T
34+
35+
def __geodetic2ecef(self, lat, lon, alt):
36+
# https://gssc.esa.int/navipedia/index.php/Ellipsoidal_and_Cartesian_Coordinates_Conversion
37+
lat = np.deg2rad(lat)
38+
lon = np.deg2rad(lon)
39+
a = self.ell.a
40+
e2 = self.ell.e2
41+
N = a / np.sqrt(1 - e2 * np.sin(lat) * np.sin(lat)) # radius of curvature in the prime vertical
42+
43+
x = (N + alt) * np.cos(lat) * np.cos(lon)
44+
y = (N + alt) * np.cos(lat) * np.sin(lon)
45+
z = ((1 - e2) * N + alt) * np.sin(lat)
46+
return np.array([x, y, z])
47+
48+
def __ecef2geodetic(self, x, y, z):
49+
# https://gssc.esa.int/navipedia/index.php/Ellipsoidal_and_Cartesian_Coordinates_Conversion
50+
a = self.ell.a
51+
e2 = self.ell.e2
52+
p = np.sqrt(x**2 + y**2)
53+
lon = np.arctan2(y, x)
54+
55+
# latitude and altitude are computed by an iterative procedure.
56+
MAX_ITERS = 1000
57+
MIN_LATITUDE_CHANGE_RADIANS = 1e-10
58+
MIN_ALTITUDE_CHANGE_METERS = 1e-6
59+
lat_prev = np.arctan(z / ((1-e2)*p)) # initial value
60+
alt_prev = -100000 # arbitrary
61+
for _ in range(MAX_ITERS):
62+
N_i = a / np.sqrt(1-e2*np.sin(lat_prev)**2)
63+
alt_i = p / np.cos(lat_prev) - N_i
64+
lat_i = np.arctan(z / ((1 - e2 * (N_i/(N_i + alt_i)))*p))
65+
if abs(lat_i - lat_prev) < MIN_LATITUDE_CHANGE_RADIANS and abs(alt_i - alt_prev) < MIN_ALTITUDE_CHANGE_METERS: break
66+
alt_prev = alt_i
67+
lat_prev = lat_i
68+
69+
lat = np.rad2deg(lat_i)
70+
lon = np.rad2deg(lon)
71+
return np.array([lat, lon, alt_i])
72+
73+
def __ecef2enu(self, x, y, z):
74+
# https://gssc.esa.int/navipedia/index.php/Transformations_between_ECEF_and_ENU_coordinates
75+
assert(self.initialized)
76+
xyz = np.array([x, y, z])
77+
xyz = xyz - self.originECEF
78+
return self.R_ecef2enu @ xyz
79+
80+
def __enu2ecef(self, e, n, u):
81+
# https://gssc.esa.int/navipedia/index.php/Transformations_between_ECEF_and_ENU_coordinates
82+
assert(self.initialized)
83+
enu = np.array([e, n, u])
84+
xyz = self.R_enu2ecef @ enu
85+
return xyz + self.originECEF
86+
87+
def enu(self, lat, lon, alt=0, accuracy=1.0, minAccuracy=-1.0):
88+
# Filter out inaccurate measurements to make pose alignment easier.
89+
if (minAccuracy > 0.0 and (accuracy > minAccuracy or accuracy < 0.0)):
90+
return self.prev
91+
92+
if not self.initialized:
93+
self.set_origin(lat, lon, alt)
94+
95+
x, y, z = self.__geodetic2ecef(lat, lon, alt)
96+
enu = self.__ecef2enu(x, y, z)
97+
enu = { "x": enu[0], "y": enu[1], "z": enu[2] }
98+
self.prev = enu
99+
return enu
100+
101+
def wgs(self, e, n, u):
102+
assert(self.initialized)
103+
x, y, z = self.__enu2ecef(e, n, u)
104+
wgs = self.__ecef2geodetic(x, y, z)
105+
return { "latitude": wgs[0], "longitude": wgs[1], "altitude": wgs[2] }
106+
107+
def wgs_array(self, pos):
108+
assert(self.initialized)
109+
arr = []
110+
for enu in pos:
111+
arr.append(self.wgs(enu[0], enu[1], enu[2]))
112+
return arr

0 commit comments

Comments
 (0)