换了个相机,标定数据全乱套?配置文件加坐标映射,切换不抓瞎
之前做一个多工位检测的项目,四个工位各一台相机。每台相机都做了标定——内参、畸变系数、外参,还有像素当量、旋转角度这些。
一开始标定数据都是硬编码在代码里的,每台相机对应一组常量。项目后期换了一台相机,重新标定之后发现——改一个参数,关联的代码要改五六处,改漏了一处,测量结果偏了0.3mm,排查了大半天。
后来把标定数据抽出来做成配置文件,坐标映射也统一管理。换相机、换镜头、换工位,改配置文件就行,代码不用动。
一、问题出在哪
多相机标定数据的管理,常见的几种乱象:
硬编码在代码里。每台相机的内参、外参、畸变系数写死在const或者静态变量里。换相机要改代码,重新编译,重新部署。
散落在多个文件。有的在代码里,有的在XML里,有的在数据库里。查一个参数要翻好几个地方。
坐标映射逻辑不统一。相机A的像素坐标转世界坐标用一个公式,相机B用另一个公式。换工位的时候要对逻辑,容易搞混。
标定版本没有管理。重新标定之后,旧的标定数据没有归档,新数据覆盖了旧的。出了问题想回退都没办法。
二、配置文件的设计
标定数据用JSON存,每台相机一个配置文件。文件名用相机序列号或者工位编号,避免混淆。
{
"cameraId":0,
"cameraName":"CAM01",
"serialNumber":"SN20260101",
"calibrationDate":"2026-09-20T14:30:00",
"calibrationVersion":"v1.2",
"intrinsics":{
"focalLength":8.0,
"pixelSizeX":4.5e-6,
"pixelSizeY":4.5e-6,
"centerX":1224.0,
"centerY":1024.0,
"imageWidth":2448,
"imageHeight":2048
},
"distortion":{
"k1":-0.123,
"k2":0.045,
"p1":0.001,
"p2":-0.002,
"k3":0.0
},
"extrinsics":{
"rotationMatrix":[
[1.0,0.0,0.0],
[0.0,1.0,0.0],
[0.0,0.0,1.0]
],
"translationVector":[0.0,0.0,500.0]
},
"pixelToWorld":{
"scaleX":0.015,
"scaleY":0.015,
"offsetX":100.0,
"offsetY":50.0,
"rotationDeg":0.5
},
"roi":{
"x":0,
"y":0,
"width":2448,
"height":2048
}
}
每台相机的标定数据独立存储。文件名用calib_CAM01.json这种格式,一眼能看出对应哪台相机。
先定义数据模型。
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
/// <summary>
/// 相机标定数据
/// </summary>
publicclassCameraCalibrationData
{
publicint CameraId { get; set; }
publicstring CameraName { get; set; }
publicstring SerialNumber { get; set; }
public DateTime CalibrationDate { get; set; }
publicstring CalibrationVersion { get; set; }
public IntrinsicsData Intrinsics { get; set; }
public DistortionData Distortion { get; set; }
public ExtrinsicsData Extrinsics { get; set; }
public PixelToWorldData PixelToWorld { get; set; }
public RoiData Roi { get; set; }
}
publicclassIntrinsicsData
{
publicdouble FocalLength { get; set; }
publicdouble PixelSizeX { get; set; }
publicdouble PixelSizeY { get; set; }
publicdouble CenterX { get; set; }
publicdouble CenterY { get; set; }
publicint ImageWidth { get; set; }
publicint ImageHeight { get; set; }
}
publicclassDistortionData
{
publicdouble K1 { get; set; }
publicdouble K2 { get; set; }
publicdouble P1 { get; set; }
publicdouble P2 { get; set; }
publicdouble K3 { get; set; }
}
publicclassExtrinsicsData
{
publicdouble[][] RotationMatrix { get; set; }
publicdouble[] TranslationVector { get; set; }
}
publicclassPixelToWorldData
{
publicdouble ScaleX { get; set; }
publicdouble ScaleY { get; set; }
publicdouble OffsetX { get; set; }
publicdouble OffsetY { get; set; }
publicdouble RotationDeg { get; set; }
}
publicclassRoiData
{
publicint X { get; set; }
publicint Y { get; set; }
publicint Width { get; set; }
publicint Height { get; set; }
}
然后是标定数据的管理器。
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
/// <summary>
/// 多相机标定数据管理器
/// </summary>
publicclassCalibrationDataManager
{
privatereadonlystring _configDirectory;
privatereadonly Dictionary<int, CameraCalibrationData> _calibrationCache;
privatereadonlyobject _cacheLock = newobject();
publicCalibrationDataManager(string configDirectory)
{
_configDirectory = configDirectory;
_calibrationCache = new Dictionary<int, CameraCalibrationData>();
if (!Directory.Exists(_configDirectory))
Directory.CreateDirectory(_configDirectory);
}
/// <summary>
/// 加载指定相机的标定数据
/// </summary>
public CameraCalibrationData LoadCalibration(int cameraId)
{
lock (_cacheLock)
{
// 先查缓存
if (_calibrationCache.TryGetValue(cameraId, outvar cached))
return cached;
// 从文件加载
string filePath = GetCalibrationFilePath(cameraId);
if (!File.Exists(filePath))
thrownew FileNotFoundException($"标定文件不存在: {filePath}");
string json = File.ReadAllText(filePath);
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
var data = JsonSerializer.Deserialize<CameraCalibrationData>(json, options);
// 校验数据
ValidateCalibration(data);
_calibrationCache[cameraId] = data;
return data;
}
}
/// <summary>
/// 保存标定数据
/// </summary>
publicvoidSaveCalibration(int cameraId, CameraCalibrationData data)
{
lock (_cacheLock)
{
// 备份旧文件
string filePath = GetCalibrationFilePath(cameraId);
if (File.Exists(filePath))
{
string backupPath = filePath + $".{DateTime.Now:yyyyMMddHHmmss}.bak";
File.Copy(filePath, backupPath);
}
// 写入新文件
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
string json = JsonSerializer.Serialize(data, options);
File.WriteAllText(filePath, json);
// 更新缓存
_calibrationCache[cameraId] = data;
}
}
/// <summary>
/// 清除缓存(重新加载时用)
/// </summary>
publicvoidClearCache()
{
lock (_cacheLock)
{
_calibrationCache.Clear();
}
}
/// <summary>
/// 获取所有已配置的相机ID
/// </summary>
public List<int> GetConfiguredCameraIds()
{
var files = Directory.GetFiles(_configDirectory, "calib_*.json");
var ids = new List<int>();
foreach (varfilein files)
{
string fileName = Path.GetFileNameWithoutExtension(file);
// 文件名格式:calib_CAM01
string[] parts = fileName.Split('_');
if (parts.Length >= 2 && parts[1].StartsWith("CAM"))
{
if (int.TryParse(parts[1].Substring(3), outint id))
ids.Add(id);
}
}
return ids.OrderBy(x => x).ToList();
}
privatestringGetCalibrationFilePath(int cameraId)
{
return Path.Combine(_configDirectory, $"calib_CAM{cameraId:D2}.json");
}
privatevoidValidateCalibration(CameraCalibrationData data)
{
if (data.Intrinsics == null)
thrownew InvalidDataException("标定数据缺少内参");
if (data.PixelToWorld == null)
thrownew InvalidDataException("标定数据缺少像素当量");
if (data.Intrinsics.ImageWidth <= 0 || data.Intrinsics.ImageHeight <= 0)
thrownew InvalidDataException("图像尺寸无效");
if (data.PixelToWorld.ScaleX <= 0 || data.PixelToWorld.ScaleY <= 0)
thrownew InvalidDataException("像素当量无效");
}
}
四、坐标映射
标定数据加载之后,坐标映射的逻辑也统一封装。像素坐标转世界坐标,涉及畸变校正、仿射变换等。
using OpenCvSharp;
/// <summary>
/// 坐标映射器:把像素坐标转成世界坐标
/// </summary>
publicclassCoordinateMapper
{
privatereadonly CameraCalibrationData _calib;
privatereadonly Mat _cameraMatrix;
privatereadonly Mat _distCoeffs;
publicCoordinateMapper(CameraCalibrationData calibration)
{
_calib = calibration;
// 构造相机内参矩阵
_cameraMatrix = new Mat(3, 3, MatType.CV_64F);
_cameraMatrix.Set(0, 0, calibration.Intrinsics.FocalLength
/ calibration.Intrinsics.PixelSizeX);
_cameraMatrix.Set(1, 1, calibration.Intrinsics.FocalLength
/ calibration.Intrinsics.PixelSizeY);
_cameraMatrix.Set(0, 2, calibration.Intrinsics.CenterX);
_cameraMatrix.Set(1, 2, calibration.Intrinsics.CenterY);
_cameraMatrix.Set(2, 2, 1.0);
// 构造畸变系数
_distCoeffs = new Mat(1, 5, MatType.CV_64F);
_distCoeffs.Set(0, 0, calibration.Distortion.K1);
_distCoeffs.Set(0, 1, calibration.Distortion.K2);
_distCoeffs.Set(0, 2, calibration.Distortion.P1);
_distCoeffs.Set(0, 3, calibration.Distortion.P2);
_distCoeffs.Set(0, 4, calibration.Distortion.K3);
}
/// <summary>
/// 像素坐标 → 世界坐标(带畸变校正)
/// </summary>
public (double x, double y) PixelToWorld(double pixelX, double pixelY)
{
// 1. 畸变校正
var distorted = new[] { new Point2d(pixelX, pixelY) };
var undistorted = Cv2.UndistortPoints(distorted, _cameraMatrix, _distCoeffs);
double ux = undistorted[0].X;
double uy = undistorted[0].Y;
// 2. 仿射变换(旋转 + 平移 + 缩放)
double rad = _calib.PixelToWorld.RotationDeg * Math.PI / 180.0;
double cos = Math.Cos(rad);
double sin = Math.Sin(rad);
// 旋转
double rx = ux * cos - uy * sin;
double ry = ux * sin + uy * cos;
// 缩放和平移
double worldX = rx * _calib.PixelToWorld.ScaleX + _calib.PixelToWorld.OffsetX;
double worldY = ry * _calib.PixelToWorld.ScaleY + _calib.PixelToWorld.OffsetY;
return (worldX, worldY);
}
/// <summary>
/// 批量转换
/// </summary>
public List<(double x, double y)> PixelToWorldBatch(
List<(double px, double py)> pixelPoints)
{
var result = new List<(double x, double y)>(pixelPoints.Count);
foreach (var pt in pixelPoints)
{
result.Add(PixelToWorld(pt.px, pt.py));
}
return result;
}
/// <summary>
/// 世界坐标 → 像素坐标(逆变换)
/// </summary>
public (double px, double py) WorldToPixel(double worldX, double worldY)
{
// 逆平移和缩放
double rx = (worldX - _calib.PixelToWorld.OffsetX) / _calib.PixelToWorld.ScaleX;
double ry = (worldY - _calib.PixelToWorld.OffsetY) / _calib.PixelToWorld.ScaleY;
// 逆旋转
double rad = -_calib.PixelToWorld.RotationDeg * Math.PI / 180.0;
double cos = Math.Cos(rad);
double sin = Math.Sin(rad);
double ux = rx * cos - ry * sin;
double uy = rx * sin + ry * cos;
// 加畸变(逆畸变校正比较复杂,这里简化处理)
return (ux, uy);
}
}
五、快速切换
标定数据管理器的核心价值在于快速切换。多工位场景下,每个工位一套标定数据,切换工位的时候重新加载对应的配置。
/// <summary>
/// 多相机视觉系统的标定管理器
/// </summary>
publicclassMultiCameraCalibrationService : IDisposable
{
privatereadonly CalibrationDataManager _dataManager;
privatereadonly Dictionary<int, CoordinateMapper> _mappers;
privatereadonly Dictionary<int, CameraCalibrationData> _activeCalibrations;
privatereadonlyobject _lock = newobject();
publicMultiCameraCalibrationService(string configDirectory)
{
_dataManager = new CalibrationDataManager(configDirectory);
_mappers = new Dictionary<int, CoordinateMapper>();
_activeCalibrations = new Dictionary<int, CameraCalibrationData>();
}
/// <summary>
/// 加载所有相机的标定数据
/// </summary>
publicvoidLoadAll()
{
lock (_lock)
{
_mappers.Clear();
_activeCalibrations.Clear();
var cameraIds = _dataManager.GetConfiguredCameraIds();
foreach (int id in cameraIds)
{
try
{
var data = _dataManager.LoadCalibration(id);
var mapper = new CoordinateMapper(data);
_mappers[id] = mapper;
_activeCalibrations[id] = data;
Console.WriteLine($"相机{id} 标定已加载: " +
$"版本={data.CalibrationVersion}, " +
$"标定日期={data.CalibrationDate:yyyy-MM-dd}");
}
catch (Exception ex)
{
Console.WriteLine($"相机{id} 标定加载失败: {ex.Message}");
}
}
}
}
/// 热加载指定相机的标定(不重启程序)
publicvoidReloadCamera(int cameraId)
{
lock (_lock)
{
_dataManager.ClearCache();
var data = _dataManager.LoadCalibration(cameraId);
_mappers[cameraId] = new CoordinateMapper(data);
_activeCalibrations[cameraId] = data;
Console.WriteLine($"相机{cameraId} 标定已重新加载");
}
}
/// <summary>
/// 获取指定相机的坐标映射器
/// </summary>
public CoordinateMapper GetMapper(int cameraId)
{
lock (_lock)
{
if (_mappers.TryGetValue(cameraId, outvar mapper))
return mapper;
thrownew KeyNotFoundException($"相机{cameraId} 的标定数据未加载");
}
}
/// 获取指定相机的标定数据
/// </summary>
public CameraCalibrationData GetCalibration(int cameraId)
{
lock (_lock)
{
if (_activeCalibrations.TryGetValue(cameraId, outvar data))
return data;
thrownew KeyNotFoundException($"相机{cameraId} 的标定数据未加载");
}
}
/// <summary>
/// 更新标定数据并保存
/// </summary>
publicvoidUpdateCalibration(int cameraId, CameraCalibrationData newData)
{
lock (_lock)
{
newData.CalibrationDate = DateTime.Now;
_dataManager.SaveCalibration(cameraId, newData);
_mappers[cameraId] = new CoordinateMapper(newData);
_activeCalibrations[cameraId] = newData;
}
}
publicvoidDispose()
{
lock (_lock)
{
_mappers.Clear();
_activeCalibrations.Clear();
}
}
}
六、使用示例
class Program
{
staticvoidMain(string[] args)
{
var service = new MultiCameraCalibrationService("config/calibrations");
// 加载所有标定
service.LoadAll();
// 使用相机0的标定做坐标转换
var mapper = service.GetMapper(0);
var (worldX, worldY) = mapper.PixelToWorld(1224.0, 1024.0);
Console.WriteLine($"像素(1224,1024) → 世界({worldX:F3}, {worldY:F3})");
// 换相机之后,重新加载
service.ReloadCamera(0);
mapper = service.GetMapper(0);
(worldX, worldY) = mapper.PixelToWorld(1224.0, 1024.0);
Console.WriteLine($"重新加载后: 世界({worldX:F3}, {worldY:F3})");
// 手动修改标定参数
var calib = service.GetCalibration(0);
calib.PixelToWorld.ScaleX = 0.0148;
calib.PixelToWorld.ScaleY = 0.0148;
service.UpdateCalibration(0, calib);
Console.WriteLine("标定数据已更新");
}
}
七、几个容易踩的坑
坑一:配置文件路径硬编码
config/calibrations这个路径不能写死在代码里。用程序启动目录的相对路径,或者从环境变量读。
坑二:标定数据校验不严
从文件反序列化出来的数据可能不完整。加载时校验关键字段——内参、像素当量、图像尺寸。缺失就报错,不要静默用默认值。
坑三:缓存和文件不同步
文件被手动修改了,但程序还读缓存。加一个文件修改时间检查,或者提供手动刷新接口。
坑四:坐标映射的旋转方向
RotationDeg是顺时针还是逆时针,不同项目定义不一样。标定的时候要确认清楚,写反了坐标全歪。
坑五:备份没有管理
每次保存标定都备份,时间长了备份文件堆满目录。加清理策略——只保留最近10个备份。
八、总结
多相机标定数据管理的核心就三件事:
-
1. 配置文件化——每台相机一个JSON文件,数据独立,代码不碰
-
2. 坐标映射统一封装——像素转世界、世界转像素,逻辑集中
-
3. 热加载——换相机、改标定,重新加载配置就行,不用重启
那个多工位项目,改造成配置文件加坐标映射之后,换相机改一个JSON文件就行,代码完全不用动。标定数据也有了版本管理,出问题可以回退到上一个版本。
小提示:标定数据文件建议用相机序列号命名,而不是工位编号。工位会调整,相机序列号不会变。用序列号命名,相机换到哪个工位都能找到对应的标定数据。坐标映射的旋转方向,在标定报告里写清楚,避免后来的人看代码猜。