主页 > 精品文章 > 后端开发 > C#实现多相机标定数据管理:使用配置文件与坐标映射实现快速切换

标签推荐

>> 更多

C#实现多相机标定数据管理:使用配置文件与坐标映射实现快速切换

 2026-11-03

换了个相机,标定数据全乱套?配置文件加坐标映射,切换不抓瞎

之前做一个多工位检测的项目,四个工位各一台相机。每台相机都做了标定——内参、畸变系数、外参,还有像素当量、旋转角度这些。

一开始标定数据都是硬编码在代码里的,每台相机对应一组常量。项目后期换了一台相机,重新标定之后发现——改一个参数,关联的代码要改五六处,改漏了一处,测量结果偏了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这种格式,一眼能看出对应哪台相机。

三、C#实现

先定义数据模型。

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. 1. 配置文件化——每台相机一个JSON文件,数据独立,代码不碰
  2. 2. 坐标映射统一封装——像素转世界、世界转像素,逻辑集中
  3. 3. 热加载——换相机、改标定,重新加载配置就行,不用重启

那个多工位项目,改造成配置文件加坐标映射之后,换相机改一个JSON文件就行,代码完全不用动。标定数据也有了版本管理,出问题可以回退到上一个版本。

小提示:标定数据文件建议用相机序列号命名,而不是工位编号。工位会调整,相机序列号不会变。用序列号命名,相机换到哪个工位都能找到对应的标定数据。坐标映射的旋转方向,在标定报告里写清楚,避免后来的人看代码猜。

版权声明: 本站资源均来自互联网或会员发布,如果侵犯了您的权益请与我们联系,我们将在24小时内删除!谢谢!联系QQ:76900276

转载请注明: C#实现多相机标定数据管理:使用配置文件与坐标映射实现快速切换

嘿,我来帮您!