STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
imu_calibration.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3IMU Sensor Calibration System
4
5Calibrates accelerometers, gyroscopes, and magnetometers using:
6- Static position calibration for accelerometers
7- Zero-velocity calibration for gyroscopes
8- Ellipsoid fitting for magnetometers
9
10Similar to PT calibration pattern.
11"""
12
13import numpy as np
14import json
15import csv
16import time
17from typing import Dict, List, Tuple, Optional
18from dataclasses import dataclass, asdict
19from pathlib import Path
20from enum import Enum
21import logging
22
23logger = logging.getLogger(__name__)
24
25
26class SensorType(Enum):
27 ACCELEROMETER = "accelerometer"
28 GYROSCOPE = "gyroscope"
29 MAGNETOMETER = "magnetometer"
30
31
32@dataclass
34 """Single calibration data point"""
35
36 raw_value: np.ndarray # Raw sensor reading [3x1]
37 reference_value: np.ndarray # Reference/true value [3x1]
38 temperature: float # Temperature [°C]
39 timestamp: float
40 uncertainty: float = 0.01
41
42
43@dataclass
45 """Calibration parameters"""
46
47 bias: np.ndarray # Bias vector [3x1]
48 scale_matrix: np.ndarray # Scale factor matrix [3x3]
49 misalignment: np.ndarray # Misalignment matrix [3x3]
50 temperature_coeff: float = 0.0
51 reference_temperature: float = 25.0
52
53 # Quality metrics
54 bias_uncertainty: float = 0.0
55 scale_uncertainty: float = 0.0
56 calibration_quality: float = 0.0
57
58 def to_dict(self) -> Dict:
59 """Convert to dictionary for JSON serialization"""
60 return {
61 "bias": self.bias.tolist(),
62 "scale_matrix": self.scale_matrix.tolist(),
63 "misalignment": self.misalignment.tolist(),
64 "temperature_coeff": self.temperature_coeff,
65 "reference_temperature": self.reference_temperature,
66 "bias_uncertainty": self.bias_uncertainty,
67 "scale_uncertainty": self.scale_uncertainty,
68 "calibration_quality": self.calibration_quality,
69 }
70
71 @classmethod
72 def from_dict(cls, data: Dict) -> "CalibrationParams":
73 """Create from dictionary"""
74 return cls(
75 bias=np.array(data["bias"]),
76 scale_matrix=np.array(data["scale_matrix"]),
77 misalignment=np.array(data["misalignment"]),
78 temperature_coeff=data.get("temperature_coeff", 0.0),
79 reference_temperature=data.get("reference_temperature", 25.0),
80 bias_uncertainty=data.get("bias_uncertainty", 0.0),
81 scale_uncertainty=data.get("scale_uncertainty", 0.0),
82 calibration_quality=data.get("calibration_quality", 0.0),
83 )
84
85
87 """Base IMU sensor calibrator"""
88
89 def __init__(self, sensor_type: SensorType, sensor_id: str):
90 self.sensor_type = sensor_type
91 self.sensor_id = sensor_id
92 self.calibration_points: List[CalibrationPoint] = []
93 self.params: Optional[CalibrationParams] = None
94
95 def add_calibration_point(self, point: CalibrationPoint):
96 """Add calibration data point"""
97 self.calibration_points.append(point)
98
99 def calibrate(self) -> CalibrationParams:
100 """Perform calibration"""
101 raise NotImplementedError
102
103 def apply_calibration(
104 self, raw_value: np.ndarray, temperature: float = 25.0
105 ) -> np.ndarray:
106 """Apply calibration to raw reading"""
107 if self.params is None:
108 raise ValueError("Calibration not performed")
109
110 # Temperature compensation
111 temp_scale = 1.0
112 if abs(self.params.temperature_coeff) > 1e-6:
113 temp_diff = temperature - self.params.reference_temperature
114 temp_scale = 1.0 + self.params.temperature_coeff * temp_diff
115
116 # Apply calibration: calibrated = scale_matrix * (raw - bias) * temp_scale
117 corrected = raw_value - self.params.bias
118 corrected *= temp_scale
119 calibrated = self.params.scale_matrix @ corrected
120
121 return calibrated
122
123 def save_calibration(self, filepath: str):
124 """Save calibration parameters to JSON file"""
125 if self.params is None:
126 raise ValueError("No calibration parameters to save")
127
128 data = {
129 "sensor_type": self.sensor_type.value,
130 "sensor_id": self.sensor_id,
131 "calibration_params": self.params.to_dict(),
132 "num_points": len(self.calibration_points),
133 }
134
135 with open(filepath, "w") as f:
136 json.dump(data, f, indent=2)
137
138 logger.info(f"Saved calibration to {filepath}")
139
140 def load_calibration(self, filepath: str):
141 """Load calibration parameters from JSON file"""
142 with open(filepath, "r") as f:
143 data = json.load(f)
144
145 self.params = CalibrationParams.from_dict(data["calibration_params"])
146 logger.info(f"Loaded calibration from {filepath}")
147
148
149class AccelerometerCalibrator(IMUCalibrator):
150 """Accelerometer calibrator using static position method"""
151
152 def __init__(self, sensor_id: str):
153 super().__init__(SensorType.ACCELEROMETER, sensor_id)
154 self.gravity = 9.81 # m/s²
155
156 def calibrate(self) -> CalibrationParams:
157 """Calibrate accelerometer using static positions"""
158 if len(self.calibration_points) < 6:
159 raise ValueError("Need at least 6 positions for accelerometer calibration")
160
161 # Estimate bias as mean of all readings
162 # (assuming gravity cancels out over all orientations)
163 bias = np.mean([p.raw_value for p in self.calibration_points], axis=0)
164
165 # Estimate scale matrix using least squares
166 # For each point: reference = scale_matrix * (raw - bias)
167 A = []
168 b = []
169
170 for point in self.calibration_points:
171 corrected = point.raw_value - bias
172 # Build linear system for scale matrix
173 for i in range(3):
174 row = np.zeros(9)
175 for j in range(3):
176 row[i * 3 + j] = corrected[j]
177 A.append(row)
178 b.append(point.reference_value[i])
179
180 A = np.array(A)
181 b = np.array(b)
182
183 # Solve least squares
184 scale_vec = np.linalg.lstsq(A, b, rcond=None)[0]
185
186 # Reshape to 3x3 matrix
187 scale_matrix = scale_vec.reshape(3, 3)
188
189 # Compute quality metrics
190 residuals = []
191 for point in self.calibration_points:
192 calibrated = self.apply_calibration(point.raw_value, point.temperature)
193 error = np.linalg.norm(calibrated - point.reference_value)
194 residuals.append(error)
195
196 avg_error = np.mean(residuals)
197 quality = 1.0 / (1.0 + avg_error)
198
199 self.params = CalibrationParams(
200 bias=bias,
201 scale_matrix=scale_matrix,
202 misalignment=np.eye(3), # Included in scale_matrix
203 calibration_quality=quality,
204 bias_uncertainty=np.std(
205 [p.raw_value for p in self.calibration_points], axis=0
206 ).mean(),
207 )
208
209 return self.params
210
211
212class GyroscopeCalibrator(IMUCalibrator):
213 """Gyroscope calibrator using zero-velocity method"""
214
215 def __init__(self, sensor_id: str):
216 super().__init__(SensorType.GYROSCOPE, sensor_id)
217
218 def calibrate(self) -> CalibrationParams:
219 """Calibrate gyroscope using zero-velocity periods"""
220 if len(self.calibration_points) < 10:
221 raise ValueError("Need at least 10 zero-velocity readings")
222
223 # Estimate bias as mean (should be zero during stationary periods)
224 bias = np.mean([p.raw_value for p in self.calibration_points], axis=0)
225
226 # Estimate scale factors from variance
227 # Higher variance indicates lower scale factor confidence
228 variances = np.var([p.raw_value for p in self.calibration_points], axis=0)
229
230 # Scale matrix: identity with scale factors inversely related to variance
231 scale_matrix = np.eye(3)
232 # For now, assume unity scale (would need rotation calibration for full scale)
233
234 # Compute quality
235 residuals = []
236 for point in self.calibration_points:
237 calibrated = self.apply_calibration(point.raw_value, point.temperature)
238 error = np.linalg.norm(calibrated - point.reference_value)
239 residuals.append(error)
240
241 avg_error = np.mean(residuals)
242 quality = 1.0 / (1.0 + avg_error)
243
244 self.params = CalibrationParams(
245 bias=bias,
246 scale_matrix=scale_matrix,
247 misalignment=np.eye(3),
248 calibration_quality=quality,
249 bias_uncertainty=np.std(
250 [p.raw_value for p in self.calibration_points], axis=0
251 ).mean(),
252 )
253
254 return self.params
255
256
257class MagnetometerCalibrator(IMUCalibrator):
258 """Magnetometer calibrator using ellipsoid fitting"""
259
260 def __init__(self, sensor_id: str):
261 super().__init__(SensorType.MAGNETOMETER, sensor_id)
262 self.reference_field_magnitude = 1.0 # Normalized
263
264 def calibrate(self) -> CalibrationParams:
265 """Calibrate magnetometer using ellipsoid fitting"""
266 if len(self.calibration_points) < 6:
267 raise ValueError(
268 "Need at least 6 orientations for magnetometer calibration"
269 )
270
271 # Hard iron correction: bias is center of ellipsoid
272 # Estimate as mean of all readings
273 bias = np.mean([p.raw_value for p in self.calibration_points], axis=0)
274
275 # Soft iron correction: scale and misalignment
276 # For now, use simplified approach
277 # Full ellipsoid fitting would solve for 9 parameters
278
279 # Compute scale factors from variance in each axis
280 corrected_readings = [p.raw_value - bias for p in self.calibration_points]
281 variances = np.var(corrected_readings, axis=0)
282
283 # Normalize to reference field magnitude
284 mean_magnitude = np.mean([np.linalg.norm(cr) for cr in corrected_readings])
285 if mean_magnitude > 0:
286 scale_factor = self.reference_field_magnitude / mean_magnitude
287 else:
288 scale_factor = 1.0
289
290 scale_matrix = np.eye(3) * scale_factor
291
292 # Compute quality
293 residuals = []
294 for point in self.calibration_points:
295 calibrated = self.apply_calibration(point.raw_value, point.temperature)
296 error = np.linalg.norm(calibrated - point.reference_value)
297 residuals.append(error)
298
299 avg_error = np.mean(residuals)
300 quality = 1.0 / (1.0 + avg_error)
301
302 self.params = CalibrationParams(
303 bias=bias,
304 scale_matrix=scale_matrix,
305 misalignment=np.eye(3),
306 calibration_quality=quality,
307 bias_uncertainty=np.std(
308 [p.raw_value for p in self.calibration_points], axis=0
309 ).mean(),
310 )
311
312 return self.params
313
314
315class IMUCalibrationSystem:
316 """Complete IMU calibration system"""
317
318 def __init__(self):
319 self.accel_calibrator: Optional[AccelerometerCalibrator] = None
320 self.gyro_calibrator: Optional[GyroscopeCalibrator] = None
321 self.mag_calibrator: Optional[MagnetometerCalibrator] = None
322
323 def create_calibrator(
324 self, sensor_type: SensorType, sensor_id: str
325 ) -> IMUCalibrator:
326 """Create calibrator for sensor type"""
327 if sensor_type == SensorType.ACCELEROMETER:
328 self.accel_calibrator = AccelerometerCalibrator(sensor_id)
329 return self.accel_calibrator
330 elif sensor_type == SensorType.GYROSCOPE:
331 self.gyro_calibrator = GyroscopeCalibrator(sensor_id)
332 return self.gyro_calibrator
333 elif sensor_type == SensorType.MAGNETOMETER:
334 self.mag_calibrator = MagnetometerCalibrator(sensor_id)
335 return self.mag_calibrator
336 else:
337 raise ValueError(f"Unknown sensor type: {sensor_type}")
338
339 def calibrate_all(self) -> Dict[str, CalibrationParams]:
340 """Calibrate all sensors"""
341 results = {}
342
343 if self.accel_calibrator and len(self.accel_calibrator.calibration_points) >= 6:
344 results["accelerometer"] = self.accel_calibrator.calibrate()
345
346 if self.gyro_calibrator and len(self.gyro_calibrator.calibration_points) >= 10:
347 results["gyroscope"] = self.gyro_calibrator.calibrate()
348
349 if self.mag_calibrator and len(self.mag_calibrator.calibration_points) >= 6:
350 results["magnetometer"] = self.mag_calibrator.calibrate()
351
352 return results
353
354 def save_all_calibrations(self, base_path: str):
355 """Save all calibrations"""
356 if self.accel_calibrator and self.accel_calibrator.params:
357 self.accel_calibrator.save_calibration(f"{base_path}_accel.json")
358
359 if self.gyro_calibrator and self.gyro_calibrator.params:
360 self.gyro_calibrator.save_calibration(f"{base_path}_gyro.json")
361
362 if self.mag_calibrator and self.mag_calibrator.params:
363 self.mag_calibrator.save_calibration(f"{base_path}_mag.json")
364
365
366def main():
367 """Example usage"""
368 import argparse
369
370 parser = argparse.ArgumentParser(description="IMU Calibration System")
371 parser.add_argument(
372 "--sensor-type", choices=["accel", "gyro", "mag"], required=True
373 )
374 parser.add_argument("--sensor-id", default="imu_0")
375 parser.add_argument("--data-file", help="CSV file with calibration data")
376 parser.add_argument("--output", help="Output calibration file")
377
378 args = parser.parse_args()
379
380 # Create calibrator
381 calib_system = IMUCalibrationSystem()
382
383 if args.sensor_type == "accel":
384 calibrator = calib_system.create_calibrator(
385 SensorType.ACCELEROMETER, args.sensor_id
386 )
387 elif args.sensor_type == "gyro":
388 calibrator = calib_system.create_calibrator(
389 SensorType.GYROSCOPE, args.sensor_id
390 )
391 else:
392 calibrator = calib_system.create_calibrator(
393 SensorType.MAGNETOMETER, args.sensor_id
394 )
395
396 # Load data from CSV or use interactive mode
397 if args.data_file:
398 # Load from CSV
399 # Format: raw_x, raw_y, raw_z, ref_x, ref_y, ref_z, temperature
400 with open(args.data_file, "r") as f:
401 reader = csv.reader(f)
402 for row in reader:
403 raw = np.array([float(row[0]), float(row[1]), float(row[2])])
404 ref = np.array([float(row[3]), float(row[4]), float(row[5])])
405 temp = float(row[6]) if len(row) > 6 else 25.0
406
407 point = CalibrationPoint(
408 raw_value=raw,
409 reference_value=ref,
410 temperature=temp,
411 timestamp=time.time(),
412 )
413 calibrator.add_calibration_point(point)
414 else:
415 print("Interactive calibration mode")
416 print("Enter calibration points (or 'done' to finish):")
417 # Interactive input would go here
418
419 # Perform calibration
420 try:
421 params = calibrator.calibrate()
422 print(f"Calibration successful!")
423 print(f" Bias: {params.bias}")
424 print(f" Quality: {params.calibration_quality:.3f}")
425
426 if args.output:
427 calibrator.save_calibration(args.output)
428 except Exception as e:
429 print(f"Calibration failed: {e}")
430
431
432if __name__ == "__main__":
433 main()
"CalibrationParams" from_dict(cls, Dict data)