2024年6月

Github action 是一个Github官方提供的非常流行且速度集成 持续集成和持续交付(CI/CD)的工具。它允许你在GitHub仓库中自动化、定制和执行你的软件开发工作流。你可以发现、创建和分享用于执行任何你想要的工作的操作,包括CI/CD,并在完全定制的工作流中组合操作。

持续集成需要做如下的工作:就拿我们公司来讲,有一个很严格和规范的流程来卡主开发提交代码并check其工作流来保证代码的质量和流程的规范化运作,这也是属于项目管理的一部分,并通过长期实践已融入血液中。

先来看看github actions都有哪些功能吧:AI时代不懂的问KIMI Prompt:
(一)假设你是一位github action持续集成专家,我有几个关于action actions的问题想向你咨询
(二)github action是什么,请简单介绍一下
(三)实际工作中都有哪些CI/CD的流程可以借鉴和使用github actions
(四)请结合一个实际软件项目案例来帮我简要分析下实施过程中github actions扮演的角色和重要的功能,要求逻辑清晰,简洁,分条罗列要点,字数不超过500字,语言生动有趣富有表现力
(五)请结合实际项目,说几个实用型的github actions的功能并给出解决方案
(六)在持续集成功能中具体可以做哪些工作并给出yaml配置信息
(七)假定我在做一个Java持续集成的项目请给出github actions的yaml配置信息 {此条prompt写的不太清晰}
(八)假定我在做一个Java8的基于Gradle构建的持续集成的项目请给出github actions的yaml配置信息,需要添加代码静态检查,code style, SonarLint和SonarQube检查,findbugs检查并发现issue邮件通知联系人功能
我挑选了一个我相对满意的KIMI输出的例子:

让我们以一个流行的开源项目——一个名为“GreenThumb”的智能植物监测应用为例,来看看GitHub Actions如何成为这个项目不可或缺的超级助手:

代码守卫:每当开发者提交代码,GitHub Actions 就像一个严格的门卫,立即检查代码是否遵循项目规范。

自动化测试员:它启动自动化测试,确保代码更改不会影响应用的智能监测功能,就像园丁确保植物健康成长。

构建工匠:通过构建流程,GitHub Actions 将源代码转化为用户可以下载和安装的应用程序,就像将原材料加工成精美的工艺品。

安全侦探:它使用安全扫描工具,寻找代码中的潜在漏洞,保护应用免受恶意攻击,就像侦探寻找线索。

部署向导:一旦代码通过所有检查,GitHub Actions 将其部署到测试和生产环境,就像向导带领游客到达目的地。

文档编辑:它自动更新项目文档,为新贡献者提供清晰的指导,就像图书馆里的索引系统。

沟通大使:在关键步骤完成后,GitHub Actions 通过电子邮件、Slack等渠道发送通知,确保团队成员及时了解项目进展,就像宴会上的传令官。

版本发布者:它根据版本控制策略自动发布新版本,简化了发布流程,就像酒庄定期推出新酿的葡萄酒。

GitHub Actions 在GreenThumb项目中扮演了多面手的角色,从代码提交到应用发布,每一步都确保流程的顺畅和高效,就像一个精心编排的交响乐团,每个成员都发挥着关键作用。

其中的第六点kimi也给出了代码:

name: Continuous Integration

on: [push, pull_request]

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v2

    - name: Install dependencies
      run: npm install

    - name: Run tests
      run: npm test

    - name: Lint code
      run: npm run lint

    - name: Build project
      run: npm run build

    - name: Upload build artifacts
      uses: actions/upload-artifact@v2
      with:
        name: build
        path: dist/

    - name: Deploy to test environment
      if: success() # Only run if all previous steps succeeded
      run: |
        # Deploy script or command goes here
        echo "Deploying to test environment..."

    - name: Send notification
      if: failure()
      run: |
        # Notification script or command goes here
        echo "Sending failure notification..."

第八点KIMI的答案:

name: Java CI with Gradle, SonarQube, and FindBugs

on: [push, pull_request]

jobs:
  build-and-analyze:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout source code
      uses: actions/checkout@v2

    - name: Set up JDK 8
      uses: actions/setup-java@v2
      with:
        java-version: '8'
        distribution: 'adopt'

    - name: Cache Gradle packages
      uses: actions/cache@v2
      with:
        path: |
          ~/.gradle/caches
          ~/.gradle/wrapper
        key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
        restore-keys: ${{ runner.os }}-gradle-

    - name: Install dependencies
      run: gradle dependencies

    - name: Run Checkstyle for code style
      run: gradle checkstyleMain

    - name: Run SonarQube analysis
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
      run: |
        gradle sonarqube
        # 确保替换下面的URL为你的SonarQube服务器URL
        # sonar-scanner -Dsonar.projectKey=your_project_key -Dsonar.host.url=https://your.sonarqube.server

    - name: Run FindBugs
      run: gradle findbugsMain

    - name: Build project
      run: gradle build

    - name: Upload build artifact
      uses: actions/upload-artifact@v2
      with:
        name: java-app
        path: build/libs/*.jar

    - name: Deploy
      if: success() && github.ref == 'refs/heads/main'
      run: |
        # 这里添加你的部署脚本或命令
        echo "Deploying application..."

    - name: Send email notification on issue
      if: failure()
      env:
        EMAIL_ADDRESS: ${{ secrets.EMAIL_ADDRESS }}
        EMAIL_SERVER: ${{ secrets.EMAIL_SERVER }}
        EMAIL_PORT: ${{ secrets.EMAIL_PORT }}
        EMAIL_USERNAME: ${{ secrets.EMAIL_USERNAME }}
        EMAIL_PASSWORD: ${{ secrets.EMAIL_PASSWORD }}
      run: |
        # 这里添加发送邮件的脚本或命令
        # 可以使用像curl这样的命令行工具来发送邮件
        echo "Sending email notification to ${EMAIL_ADDRESS}"
        # 以下是一个使用curl发送邮件的示例
        # curl ... --mail-from ... --mail-rcpt ... -F ...

但是这次和00后的小朋友一起合作的Web3 黑客松项目(
https://github.com/Sifotd
)
我就发现项目管理中的诸多问题,由于前期他们还没有养成提交代码规范化写commit message的习惯,胡乱写一堆message ,真让人抓狂,人在项目中是不可控因素,那这应该是需要从流程的角度去hold住。

首先还是来看下需求,需要github action做哪些工作, github action的原理是什么,原理决定了哪些需求可做哪些不可做,可做的如何实现,不可做的需要用什么别的替代方案来做到。后期如何丝滑和贯彻落实,落实之后要建立反馈机制在下一个迭代周期中完善和改进。改进了还不行,需要把实践的东西上升为形而上的方法论去不断精进,唯独这样才能把工匠精神落到实处,在不断耕耘中收货成功的种子 。

让github workflow来为我实现如下需求,整理如下:
1.github action可以做自动化部署
2.commit message check
3.代码质量静态检查,代码风格检查

下期结合实际问题来编写yaml代码并一一落地,并结合实际来验证KIMI给出的答案可行性和落地效果。这是个令人兴奋的工作,有了它能持续精进和打磨团队,让其高效而有质量的产出。流程和效率是一个相互博弈的过程,需要根据实际情况来辩证看待!
参考文章:
GitHub Actions - The Complete Guide
https://www.udemy.com/course/github-actions-the-complete-guide/
如何使用 GitHub Actions 实现开源项目的自动化
https://www.freecodecamp.org/chinese/news/automate-open-source-projects-with-github-actions/
Github action 博客自动化部署:
https://frostming.com/2020/04-26/github-actions-deploy/
https://www.freecodecamp.org/chinese/news/automate-open-source-projects-with-github-actions/
10 个你该了解的 GitHub Actions 进阶技巧
https://cloud.tencent.com/developer/article/1782556

我们是
袋鼠云数栈 UED 团队
,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:霁明

背景

我们产品中会有一些流程图应用,例如审批中心的审批流程图:

file

我们数栈产品内的流程图,基本都是使用的 mxGraph 实现的,mxGraph 使用了SVG来渲染图形。
流程图组件库除了 mxGraph,还有其他一些流行的库,例如:ReactFlow、G6、X6等等,各个库的特点、具体实现原理各有不同,但图形渲染方式却主要都是这两种:Canvas 和 SVG。
本文会通过绘制流程图(只是简单绘制,不涉及图表库的实现),来介绍 Canvas 和 SVG 的使用方式、动画实现以及两者之间的一些差异。

Canvas

简介

MDN 对 Canvas 的介绍:

Canvas API 提供了一个通过 JavaScript 和 HTML的 <canvas>元素来绘制图形的方式。它可以用于动画、游戏画面、数据可视化、图片编辑以及实时视频处理等方面。

目前所有主流的浏览器都支持 Canvas。

使用

基本用法

创建蓝白红3个色块:

import { useEffect } from 'react';

function Page() {
  useEffect(() => {
    const canvas = document.getElementById('canvas') as HTMLCanvasElement;
    if (canvas?.getContext) {
      const ctx = canvas.getContext('2d');
      ctx.fillStyle = '#002153';
      ctx.fillRect(10, 10, 50, 100);
      ctx.fillStyle = '#ffffff';
      ctx.fillRect(60, 10, 50, 100);
      ctx.fillStyle = '#d00922';
      ctx.fillRect(110, 10, 50, 100);
    }
  }, []);
  return <canvas id="canvas"></canvas>;
}

export default Page;

效果如下图:

file

绘制流程图

绘制一个开始节点、一个中间节点和一个结束节点,节点之间用有向线条进行连接,如下图:

file

前置知识:
devicePixelRatio
:设备像素比,返回当前显示设备的物理像素分辨率与 _CSS _ 像素分辨率之比,它告诉浏览器应使用多少屏幕实际像素来绘制单个 CSS 像素。比如屏幕物理像素是2000px,css 像素是1000px,则设备像素比为2。

实现代码如下:

import { useEffect } from 'react';
import styles from '../../styles/canvas.module.css';

function Page() {
  useEffect(() => {
    const canvas = document.getElementById('canvas') as HTMLCanvasElement;
    if (canvas?.getContext) {
      // 处理图像模糊问题
      const ratio = window.devicePixelRatio || 1;
      const { width, height } = canvas;
      canvas.width = Math.round(width * ratio);
      canvas.height = Math.round(height * ratio);
      canvas.style.width = `${width}px`;
      canvas.style.height = `${height}px`;

      const ctx = canvas.getContext('2d');
      // 放大(处理图像模糊问题)
      ctx.scale(ratio, ratio);
      ctx.font = '12px sans-serif';

      // 开始节点
      ctx.beginPath();
      ctx.arc(300, 125, 25, Math.PI / 2, (Math.PI * 3) / 2, false); // 左边框
      ctx.lineTo(350, 100);
      ctx.arc(350, 125, 25, (Math.PI * 3) / 2, (Math.PI * 5) / 2, false); // 右边框
      ctx.lineTo(300, 150);
      ctx.lineWidth = 3;
      ctx.stroke();
      ctx.fillStyle = '#FFF';
      ctx.fill();
      ctx.fillStyle = '#000';
      ctx.fillText('开始', 312, 130);

      // 中间节点
      ctx.beginPath();
      ctx.arc(280, 230, 5, Math.PI, (Math.PI * 3) / 2, false); // 左上圆角
      ctx.lineTo(370, 225);
      ctx.arc(370, 230, 5, (Math.PI * 3) / 2, Math.PI * 2, false); // 右上圆角
      ctx.lineTo(375, 270);
      ctx.arc(370, 270, 5, 0, Math.PI / 2, false); // 右下圆角
      ctx.lineTo(280, 275);
      ctx.arc(280, 270, 5, Math.PI / 2, Math.PI, false); // 左下圆角
      ctx.lineTo(275, 230);
      ctx.lineWidth = 3;
      ctx.stroke();
      ctx.fillStyle = '#FFF';
      ctx.fill();
      ctx.fillStyle = '#000';
      ctx.fillText('中间节点', 300, 254);

      // 结束节点
      ctx.beginPath();
      ctx.arc(300, 400, 25, Math.PI / 2, (Math.PI * 3) / 2, false); // 左边框
      ctx.lineTo(350, 375);
      ctx.arc(350, 400, 25, (Math.PI * 3) / 2, (Math.PI * 5) / 2, false); // 右边框
      ctx.lineTo(300, 425);
      ctx.stroke();
      ctx.fillStyle = '#FFF';
      ctx.fill();
      ctx.fillStyle = '#000';
      ctx.fillText('结束', 312, 405);

      // 线条1
      ctx.beginPath();
      ctx.moveTo(325, 150);
      ctx.lineTo(325, 225);
      ctx.lineWidth = 1;
      ctx.stroke();
      // 箭头1
      ctx.beginPath();
      ctx.moveTo(320, 215);
      ctx.lineTo(330, 215);
      ctx.lineTo(325, 225);
      ctx.fill();

      // 线条2
      ctx.beginPath();
      ctx.moveTo(325, 275);
      ctx.lineTo(325, 375);
      ctx.stroke();
      // 箭头2
      ctx.beginPath();
      ctx.moveTo(320, 365);
      ctx.lineTo(330, 365);
      ctx.lineTo(325, 375);
      ctx.fill();
    }
  }, []);
  return (
    <div className={styles.container}>
      <canvas id="canvas" width="800" height="600"></canvas>
    </div>
  );
}

export default Page;

绘制图形可以通过绘制矩形、绘制路径的方式来绘制图形,还可以使用
Path2D
对象来绘制,具体使用方法可以查看
MDN

样式和颜色

给节点加上样式,效果如下:

file

对比上一步,可以发现给节点内容和边框填充了颜色,以开始节点为例:

...

// 开始节点
ctx.beginPath();
ctx.arc(300, 125, 25, Math.PI / 2, (Math.PI * 3) / 2, false); // 左边框
ctx.lineTo(350, 100);
ctx.arc(350, 125, 25, (Math.PI * 3) / 2, (Math.PI * 5) / 2, false); // 右边框
ctx.lineTo(300, 150);
ctx.lineWidth = 3;
ctx.strokeStyle = '#82b366';
ctx.stroke();
ctx.fillStyle = '#d5e8d4';
ctx.fill();
ctx.fillStyle = '#000';
ctx.fillText('开始', 312, 130);

...

canvas 支持绘制许多样式,例如:颜色、透明度、线条样式、阴影等,具体使用可查看
MDN

动画实现

实现线条流动动画,实现效果如下图所示:

file

实现原理:将线条设置为虚线,然后设置偏移量,每间隔一定时间渲染一次,每次的偏移量都递增,便实现了线条流动的动画效果。
原理了解了,但在开发之前有两个点要考虑一下:

  • 动画是有执行频率的,要控制的话用哪种方式好一点?
  • 每次动画执行时,一般是整个画布都刷新,考虑到性能问题,是否可以局部刷新?

带着这两个问题,我们看下代码实现:

import { useEffect } from 'react';
import styles from '../../styles/page.module.css';

const rAFSetInterval = (handler: (timer: number) => void, timeout?: number) => {
  let timer = null;
  let startTime = Date.now();
  const loop = () => {
    let currentTime = Date.now();
    if (currentTime - startTime >= timeout) {
      startTime = currentTime;
      handler(timer);
    }
    timer = requestAnimationFrame(loop);
  };
  loop();
  return timer;
};

function Page() {
  let canvas: HTMLCanvasElement;
  let ctx: CanvasRenderingContext2D;
  let offset = 0;

  useEffect(() => {
    canvas = document.getElementById('canvas') as HTMLCanvasElement;
    if (canvas) {
      const ratio = window.devicePixelRatio || 1;
      const { width, height } = canvas;
      canvas.width = Math.round(width * ratio);
      canvas.height = Math.round(height * ratio);
      canvas.style.width = `${width}px`;
      canvas.style.height = `${height}px`;
      ctx = canvas.getContext('2d');
      ctx.scale(ratio, ratio);
      ctx.font = '12px sans-serif';
      draw();
      rAFSetInterval(run, 50);
    }
  }, []);

  const run = () => {
    offset++;
    if (offset > 1000) {
      offset = 0;
    }
    drawAnimateLine();
  };

  const draw = () => {
    // 初始化
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.setLineDash([]);
    ctx.lineDashOffset = 0;

    // 开始节点
    ctx.beginPath();
    ctx.arc(300, 125, 25, Math.PI / 2, (Math.PI * 3) / 2, false); // 左边框
    ctx.lineTo(350, 100);
    ctx.arc(350, 125, 25, (Math.PI * 3) / 2, (Math.PI * 5) / 2, false); // 右边框
    ctx.lineTo(300, 150);
    ctx.lineWidth = 3;
    ctx.strokeStyle = '#82b366';
    ctx.stroke();
    ctx.fillStyle = '#d5e8d4';
    ctx.fill();
    ctx.fillStyle = '#000';
    ctx.fillText('开始', 312, 130);

    // 中间节点
    ctx.beginPath();
    ctx.arc(280, 230, 5, Math.PI, (Math.PI * 3) / 2, false); // 左上圆角
    ctx.lineTo(370, 225);
    ctx.arc(370, 230, 5, (Math.PI * 3) / 2, Math.PI * 2, false); // 右上圆角
    ctx.lineTo(375, 270);
    ctx.arc(370, 270, 5, 0, Math.PI / 2, false); // 右下圆角
    ctx.lineTo(280, 275);
    ctx.arc(280, 270, 5, Math.PI / 2, Math.PI, false); // 左下圆角
    ctx.lineTo(275, 230);
    ctx.lineWidth = 3;
    ctx.strokeStyle = '#6c8ebf';
    ctx.stroke();
    ctx.fillStyle = '#dae8fc';
    ctx.fill();
    ctx.fillStyle = '#000';
    ctx.fillText('中间节点', 300, 254);

    // 结束节点
    ctx.beginPath();
    ctx.arc(300, 375, 25, Math.PI / 2, (Math.PI * 3) / 2, false); // 左边框
    ctx.lineTo(350, 350);
    ctx.arc(350, 375, 25, (Math.PI * 3) / 2, (Math.PI * 5) / 2, false); // 右边框
    ctx.lineTo(300, 400);
    ctx.strokeStyle = '#82b366';
    ctx.stroke();
    ctx.fillStyle = '#d5e8d4';
    ctx.fill();
    ctx.fillStyle = '#000';
    ctx.fillText('结束', 312, 380);

    // 线条1
    ctx.beginPath();
    ctx.moveTo(325, 150);
    ctx.lineTo(325, 223);
    ctx.setLineDash([4, 4]);
    ctx.lineDashOffset = -offset;
    ctx.lineWidth = 1.5;
    ctx.strokeStyle = '#000';
    ctx.stroke();
    // 箭头1
    ctx.beginPath();
    ctx.moveTo(320, 215);
    ctx.lineTo(325, 218);
    ctx.lineTo(330, 215);
    ctx.lineTo(325, 225);
    ctx.fill();

    // 线条2
    ctx.beginPath();
    ctx.moveTo(325, 275);
    ctx.lineTo(325, 348);
    ctx.stroke();
    // 箭头2
    ctx.beginPath();
    ctx.moveTo(320, 340);
    ctx.lineTo(325, 343);
    ctx.lineTo(330, 340);
    ctx.lineTo(325, 350);
    ctx.fill();
  };

  const drawAnimateLine = () => {
    // 清空线条
    ctx.clearRect(324, 150, 2, 67);
    ctx.clearRect(324, 275, 2, 67);

    // 绘制线条1
    ctx.beginPath();
    ctx.moveTo(325, 150);
    ctx.lineTo(325, 223);
    ctx.setLineDash([4, 4]);
    ctx.lineDashOffset = -offset;
    ctx.lineWidth = 1.5;
    ctx.strokeStyle = '#000';
    ctx.stroke();

    // 绘制线条2
    ctx.beginPath();
    ctx.moveTo(325, 275);
    ctx.lineTo(325, 348);
    ctx.stroke();
  };

  return (
    <div className={styles.container}>
      <canvas id="canvas" width="800" height="600"></canvas>
    </div>
  );
}

export default Page;

针对前面的两个问题,这里总结一下:

  • 使用 requestAnimationFrame 实现一个 setInterval 方法,做到定时控制和性能兼顾
  • 针对动画区域,通过坐标和区域宽高,进行 canvas 的局部刷新

SVG

简介

引用 MDN 对 SVG 的介绍:

可缩放矢量图形(Scalable Vector Graphics,SVG)基于 XML 标记语言,用于描述二维的矢量图形。
和传统的点阵图像模式(如 JPEG 和 PNG)不同的是,SVG 格式提供的是矢量图,这意味着它的图像能够被无限放大而不失真或降低质量,并且可以方便地修改内容,无需图形编辑器。通过使用合适的库进行配合,SVG 文件甚至可以随时进行本地化。

目前所有主流的浏览器都支持SVG(IE部分支持)。

使用

常用标签

流程图中主要用到的几种标签:
<svg>
SVG 容器元素,SVG 的代码都包裹在该元素下,可以作为根元素(一般是 svg 图片),也可以内嵌在HTML文档中。如果 svg 不是根元素,svg 元素可以用于在当前文档内嵌套一个独立的 svg 片段。这个独立片段拥有独立的视口和坐标系统。
<g>
元素 g 是用来组合对象的容器。添加到 g 元素上的变换会应用到其所有的子元素上。添加到 g 元素的属性会被其所有的子元素继承。
<rect>
rect元素是 SVG 的一个基本形状,用来创建矩形,基于一个角位置以及它的宽和高。它还可以用来创建圆角矩形。
<path>
path 元素是用来定义形状的通用元素。所有的基本形状都可以用 path 元素来创建。
<foreignObject>
foreignObject 元素允许包含来自不同的 XML 命名空间的元素。在浏览器的上下文中,很可能是 XHTML / HTML。在我们的流程图中,通过 HTML 渲染的节点一般都渲染在这个标签内。

基本用法

使用svg渲染图片

function Page() {
  return (
    <svg width="150" height="100">
      <rect width="50" height="100" x="0" fill="#002153" />
      <rect width="50" height="100" x="50" fill="#ffffff" />
      <rect width="50" height="100" x="100" fill="#d00922" />
    </svg>
  );
}

export default Page;

上面代码渲染效果如下图:

file

绘制流程图

使用svg绘制流程图:

file

代码实现如下:

import styles from '../../styles/page.module.css';

function Page() {
  return (
    <svg width="800" height="600" className={styles.container}>
      <g>
        <path
          d="M 320 110 C 286 110, 286 160, 320 160 L 370 160 C 404 160, 404 110, 370 110 Z"
          stroke="#82b366"
          strokeWidth="2"
          fill="#d5e8d4"
          />
        <text x="332" y="140" style={{ fontSize: 12 }}>
          开始
        </text>
      </g>
      <g>
        <rect
          x="295"
          y="235"
          width="100"
          height="50"
          rx="5"
          fill="#dae8fc"
          stroke="#6c8ebf"
          strokeWidth="2"
          ></rect>
        <text x="320" y="264" style={{ fontSize: 12 }}>
          中间节点
        </text>
      </g>
      <g>
        <path
          d="M 320 360 C 286 360, 286 410, 320 410 L 370 410 C 404 410, 404 360, 370 360 Z"
          stroke="#82b366"
          strokeWidth="2"
          fill="#d5e8d4"
          />
        <text x="332" y="390" style={{ fontSize: 12 }}>
          结束
        </text>
      </g>
      <g>
        <path d="M 345 160 L 345 235" stroke="#000"></path>
        <path d="M 340 225 L 345 228 L 350 225 L 345 235 Z" fill="#000"></path>
      </g>
      <g>
        <path d="M 345 285 L 345 360" stroke="#000"></path>
        <path d="M 340 350 L 345 353 L 350 350 L 345 360 Z" fill="#000"></path>
      </g>
    </svg>
  );
}

export default Page;

以开始节点为例,主要看下path元素:

<path
  d="M 320 110 C 286 110, 286 160, 320 160 L 370 160 C 404 160, 404 110, 370 110 Z"
  stroke="#82b366"
  strokeWidth="2"
  fill="#d5e8d4"
/>

d 属性定义了要绘制的路径,路径定义是一个路径命令组成的列表,其中的每一个命令由命令字母和用于表示命令参数的数字组成。每个命令之间通过空格或逗号分隔。
M 表示 move to,即移动到某个坐标;L 表示 line to,即连线到某个坐标。
C 表示使用三次方贝塞尔曲线,后面跟随3个坐标点,分别是起始控制点、终点控制点、终点。
Z 表示 ClosePath,将从当前位置绘制一条直线到路径中的第一个点。上面只用到了4种命令,而命令总共有20种,具体可以查看
MDN

stroke、strokeWidth、fill 则分别指定了边框颜色、宽度,以及填充颜色。

动画实现

实现线条流动动画,实现效果如下图:

file

实现原理:先将线条设置为虚线,然后通过 css 动画,修改虚线的偏移量并无限循环,从而实现线条流动效果。
代码实现如下:

.animate-path {
  stroke-dasharray: 5;
  animation: dashdraw 0.5s linear infinite;
}
@keyframes dashdraw {
  0% {
    stroke-dashoffset: 10;
  }
}

svg 可以通过 css、js 或者 animate 标签来实现动画,适用于需要高质量矢量图形、可缩放和交互性强的场景

对比

使用方式

Canvas 是比 SVG 更低级别的 API,绘制图形需要通过 JS 来操作。Canvas 提供了更大的灵活性,但复杂度也更高,理论上任何使用 SVG 绘制的图形,都可以通过 Canvas绘制出来。相反,由于 SVG 是比 Canvas 更高级别的 API,可以当作 HTML 元素去使用,也可以结合 JS、CSS 去操作,使用 SVG 创建一些复杂的图形会比使用 Canvas 更加简单。

交互性

SVG 位于 DOM 中,和普通 DOM 元素一样支持响应事件。Canvas 也可以响应交互事件,但需要额外的代码去实现。

性能

Canvas 和 SVG 性能的影响因素主要有两个:绘制图形的数量、绘制图形的大小。
下图是微软 MSDN 上给的一个对比图。

file

Canvas 的性能受画布尺寸影响更大,而 SVG 的性能受图形元素个数影响更大。网络上的对于性能及使用相关的建议是:如果绘制图像面积大或者绘制元素数量小时,建议使用SVG,如果绘制图像面积小或者绘制元素数量较大时,则建议使用 Canvas。

总结

本文介绍了 Canvas 和 SVG 的一些基本概念和使用方式,在我们日常开发中,有时会碰到需要绘制图形的场景,对于 Canvas 和 SVG,分别有其适合的场景:

  • 需要绘制的图像简单、交互性强或者是矢量图(例如图标),建议使用 SVG。
  • 需要支持像素级别的操作,或者复杂的动画和交互(例如数据可视化、交互式游戏),建议使用Canvas。

大多数流程图组件库都是使用 Canvas 或 SVG 来绘制图形,流程图一般图形简单,节点数量不多,会有一些简单的交互,因而大多数流程图组件库都使用 SVG 来进行渲染,例如 ReactFlow、draw.io、mxGraph、X6、XFlow 等,都是使用 svg 来进行渲染。

链接

https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API
https://developer.mozilla.org/zh-CN/docs/Web/SVG

最后

欢迎关注【袋鼠云数栈UED团队】~
袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star

一:背景

1. 讲故事

最近在分析dump时,发现有程序的卡死和
WeakReference
有关,在以前只知道怎么用,但不清楚底层逻辑走向是什么样的,借着这个dump的契机来简单研究下。

二:弱引用的玩法

1. 一些基础概念

用过WeakReference的朋友都知道这里面又可以分为
弱短

弱长
两个概念,对应着构造函数中的
trackResurrection
参数,同时它也是对底层
GCHandle.Alloc
方法的封装,参考源码如下:


public WeakReference(object? target, bool trackResurrection)
{
    Create(target, trackResurrection);
}

private void Create(object target, bool trackResurrection)
{
    nint num = GCHandle.InternalAlloc(target, trackResurrection ? GCHandleType.WeakTrackResurrection : GCHandleType.Weak);
    _taggedHandle = (trackResurrection ? (num | 1) : num);
    ComAwareWeakReference.ComInfo comInfo = ComAwareWeakReference.ComInfo.FromObject(target);
    if (comInfo != null)
    {
        ComAwareWeakReference.SetComInfoInConstructor(ref _taggedHandle, comInfo);
    }
}

public enum GCHandleType
{
    //
    // Summary:
    //     This handle type is used to track an object, but allow it to be collected. When
    //     an object is collected, the contents of the System.Runtime.InteropServices.GCHandle
    //     are zeroed. Weak references are zeroed before the finalizer runs, so even if
    //     the finalizer resurrects the object, the Weak reference is still zeroed.
    Weak = 0,
    //
    // Summary:
    //     This handle type is similar to System.Runtime.InteropServices.GCHandleType.Weak,
    //     but the handle is not zeroed if the object is resurrected during finalization.
    WeakTrackResurrection = 1
}

从上面的
GCHandleType
的注释来看。

  • Weak 会在终结器执行之前判断持有的对象是否为垃圾对象,如果是的话直接切断引用。
  • WeakTrackResurrection 会在终结器执行之后判断对象是否为垃圾对象,如果是的话直接切断引用。

可能这么说有点抽象,画张图如下:

2. 一个简单的测试例子

为了方便讲述两者的区别,使用
对象复活
来做测试。

  1. Weak 的情况

因为在 ScanForFinalization 方法之前做的判断,所以与垃圾对象的联系会被马上切断,参考代码如下:


    class Program
    {
        static void Main()
        {
            WeakReferenceCase();

            GC.Collect();
            GC.WaitForPendingFinalizers();

            Console.WriteLine(weakHandle.Target ?? "Person 引用被切断");

            Console.ReadLine();
        }

        public static GCHandle weakHandle;

        static void WeakReferenceCase()
        {
            var person = new Person() { ressurect = false };
            weakHandle = GCHandle.Alloc(person, GCHandleType.Weak);
        }
    }

    public class Person
    {
        public bool ressurect = false;

        ~Person()
        {
            if (ressurect)
            {
                Console.WriteLine("Person 被永生了,不可能被消灭的。。。");
                GC.ReRegisterForFinalize(this);
            }
            else
            {
                Console.WriteLine("Person 析构已执行...");
            }
        }
    }

  1. WeakTrackResurrection 的情况

因为是在 ScanForFinalization 之后做的判断,这时候可能会存在
对象复活
的情况,所以垃圾又变成不垃圾了,如果是这种情况就不能切断,参考代码如下:


static void WeakReferenceCase()
{
    var person = new Person() { ressurect = true };
    weakHandle = GCHandle.Alloc(person, GCHandleType.WeakTrackResurrection);
}

3. coreclr源码分析

在 coreclr 里有一个 struct 枚举强对应 GCHandleType 结构体,而且名字看的更加清楚,代码如下:


typedef enum
{
	HNDTYPE_WEAK_SHORT = 0,
	HNDTYPE_WEAK_LONG = 1,
}
HandleType;

接下来看下刚才截图源码上的验证。


void gc_heap::mark_phase(int condemned_gen_number, BOOL mark_only_p)
{
	// null out the target of short weakref that were not promoted.
	GCScan::GcShortWeakPtrScan(condemned_gen_number, max_generation, &sc);

	dprintf(3, ("Finalize marking"));
	finalize_queue->ScanForFinalization(GCHeap::Promote, condemned_gen_number, mark_only_p, __this);

	// null out the target of long weakref that were not promoted.
	GCScan::GcWeakPtrScan(condemned_gen_number, max_generation, &sc);
}

BOOL CFinalize::ScanForFinalization(promote_func* pfn, int gen, BOOL mark_only_p, gc_heap* hp)
{
    for (unsigned int Seg = startSeg; Seg <= gen_segment(0); Seg++)
    {
        Object** endIndex = SegQueue(Seg);
        for (Object** i = SegQueueLimit(Seg) - 1; i >= endIndex; i--)
        {
            CObjectHeader* obj = (CObjectHeader*)*i;

            if (!g_theGCHeap->IsPromoted(obj))
            {
                if (method_table(obj)->HasCriticalFinalizer())
                {
                    MoveItem(i, Seg, CriticalFinalizerListSeg);
                }
                else
                {
                    MoveItem(i, Seg, FinalizerListSeg);
                }
            }
        }
    }

    if(finalizedFound) GCToEEInterface::EnableFinalization(true);

    return finalizedFound;
}

源码中有几个注意点:

  1. 如何判断一个对象为垃圾

gc 在标记时,将有根的对象mt的第一位设为
1
来表示当前已经标记过,即有用对象,未被标记的即为垃圾对象。

  1. 终结器线程真的被启动了吗

从简化的源码看,一旦有垃圾对象被送入到 终结器队列的
预备区
时,就会通过
GCToEEInterface::EnableFinalization(true)
启动终结器线程,所以在测试代码中加了
GC.WaitForPendingFinalizers();
就是为了等待终结器线程执行完毕然后才判断 Target,这样结果就会更加准确。

4. 切断逻辑在哪里

有些朋友会好奇那个
weakHandle.Target=null
的逻辑到底在 coreclr 的何处,这个比较简单,可以用 windbg 下 ba 断点即可,我们还是拿弱引用来举例,截图如下:

三:总结

WeakReference 的内部玩法有很多,更深入的理解还需要对
g_HandleTableMap
进行深度挖掘,后面有机会再聊吧,有时候dump分析还是挺苦逼的,需要对相关领域底层知识有一个足够了解,否则谈何修复呢?

图片名称

首先说一下使用场景  WebAPI接口入参使用 object和 dynamic 后续解析和处理

1.object和dynamic 区别

在.NET中,
object

dynamic
也有一些区别:

  1. object

    object
    是.NET中的顶级类,所有类都是
    object
    的子类。在C#中,您可以使用
    object
    关键字来声明变量,该变量可以存储任何类型的对象引用。

  2. dynamic

    dynamic
    是.NET中的一种类型,它允许您在运行时执行动态绑定。这意味着您可以在编译时避免类型检查,而是在运行时确定变量的类型。这使得代码更加灵活,但也可能导致运行时错误。

在C#中,您可以像这样使用它们:

这里使用 Raccoon 代码助手 给出实例代码: (国产免费ai有时候真智障,仅供参考)

object myObject = "Hello, World!";
dynamic myDynamic = "Hello, Dynamic!";

// 以下代码将引发编译错误,因为myObject的类型在编译时被确定
// myObject = 42;

// 以下代码不会引发编译错误,因为myDynamic的类型在运行时被确定
myDynamic = 42;

2. 构建json 和 dynamic 对应关系

由于WebAPI接口接收的参数,来自于前端提交,没有一定的类型检查机制(js弱类型), 再加上有些内容不定(参数里面有单个对象,也有数组之类的)

后端处理 就会显得有点棘手.

在考虑如何解析这些前端传过来的结构不定对象之前,先复习一下基础知识,(掌握必备知识,才能更好的解决问题)

下面代码默认使用 TestDriven 插件执行, 命名空间有

using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using System.Linq;
using System.Linq.Expressions;
using Newtonsoft.Json.Linq;

2.1 示例代码1: 数组和对象集合

        public static void Test1()
        {
            var defaultStringValue = new List<string> { "" };
            JArray jArrayString = JArray.FromObject(defaultStringValue);
            Console.WriteLine(JsonConvert.SerializeObject(defaultStringValue)); // 输出 [""]
            Console.WriteLine(JsonConvert.SerializeObject(jArrayString));       // 输出 [""]

            var defaultObjValue = new List<object> { new object() };
            JArray jArrayObj = JArray.FromObject(defaultObjValue);
            Console.WriteLine(JsonConvert.SerializeObject(defaultObjValue));    // 输出 [{}]
            Console.WriteLine(JsonConvert.SerializeObject(jArrayObj));          // 输出 [{}]
        }

接下来看一个复杂的对象构造示例:

现有如下json格式字符串 ,构造 dynamic 类型

{"Guid":"3eab6205-6a05-4d09-9dd3-7a004369bb32","names":["张三","李四","王五"],"Params":[{"id_list":["0001"]}]}

说明一下, 上面这个对象象 ,有三个属性,  Guid 是一个 guid字符串, names 是一个数组,  Params 是一个复杂对象(集合)

2.2 示例代码2: 构造复杂对象

        public static void Test02()
        {
            string[] strings = new string[] { "张三", "李四", "王五" };
            JArray jArray = JArray.FromObject(strings);

            JObject jObj_idlist = new JObject();
            JArray j_id_list = JArray.FromObject(new string[] { "0001" });
            JProperty p_id_list = new JProperty("id_list", j_id_list);
            jObj_idlist.Add(p_id_list);
            
            dynamic obj = new
            {
                Guid = Guid.NewGuid().ToString(),
                names = jArray,
                Params = JArray.FromObject(new List<object>() { jObj_idlist })
            };
            Console.WriteLine(JsonConvert.SerializeObject(obj));
        }

2.3 示例代码3:

判断一个dynamic 的值类型是 数组(集合),还是单个的对象 ,这里继续使用上面的 dynamic obj 对象

            Console.WriteLine(obj.Guid is JArray);   // False
            Console.WriteLine(obj.names is JArray);  // True
            Console.WriteLine(obj.Params is JArray); // True

2.3 示例代码4:

    public class StudentInfo
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public DateTime Birthday { get; set; }
        public int? Age
        {
            get
            {
                if (Birthday.HasValue)
                {
                    int year = (DateTime.Now - this.Birthday.Value).Days;
                    return year / 365;
                }
                else
                {
                    return null;
                }
            }
        }
    }
    public class TestClass
    {
        public static void Test1()
        {
            StudentInfo stu1 = new StudentInfo() { Id = 1, Name = "张三", Birthday = new DateTime(2000,1,1)};
            Console.WriteLine(JsonConvert.SerializeObject(stu1));
            // 输出  {"Id":1,"Name":"张三","Birthday":"2000-01-01T00:00:00","Age":24}
        }
    }

加入这个参数   {"Id":1,"Name":"张三","Birthday":"2000-01-01T00:00:00","Age":24} 从前端传入, 那么通过 StudentInfo 可以直接接收,

但是如果 不知道这个人的出生年月日,传入的 字符串为 下面这几种情况

{"Id":1,"Name":"张三","Birthday":""}          // 反序列化失败

或者  {"Id":1,"Name":"张三","Birthday":"null"} ,   // 反序列化失败

或者  {"Id":1,"Name":"张三","Birthday":null}       // 可成功序列化

        public static void Test2()
        {
            string json1 = "{\"Id\":1,\"Name\":\"张三\",\"Birthday\":\"\"} ";
            StudentInfo stu1 = JsonConvert.DeserializeObject<StudentInfo>(json1);  // 报错
            Console.WriteLine(JsonConvert.SerializeObject(stu1)); 
        }
        public static void Test3()
        {
            string json1 = "{\"Id\":1,\"Name\":\"张三\",\"Birthday\":\"null\"} ";
            StudentInfo stu1 = JsonConvert.DeserializeObject<StudentInfo>(json1);  // 报错
            Console.WriteLine(JsonConvert.SerializeObject(stu1));
        }

        public static void Test4()
        {
            string json1 = "{\"Id\":1,\"Name\":\"张三\",\"Birthday\":null} ";
            StudentInfo stu1 = JsonConvert.DeserializeObject<StudentInfo>(json1); // 可正常解析
            Console.WriteLine(JsonConvert.SerializeObject(stu1));
            // 输出  {"Id":1,"Name":"张三","Birthday":null,"Age":null}
        }

2.3 泛型类 MapHelper<T>预处理:

为了接口不报错, 这个先用 dynamic obj 接收这个入参, 然后近一步处理

MapHelper
 using System;
using System.Reflection;
using Newtonsoft.Json.Linq;

namespace Common.Helper
{
    /// <summary>
    /// dynamic 数据映射
    /// </summary>
    /// <typeparam name="T">目标实体类</typeparam>
    public class MapHelper<T> where T : new()
    {
        /// <summary>
        ///  将数据源 转换成对应的 目标对象(处理可空类型, 以及 补全缺失的字段)
        /// </summary>
        /// <param name="obj">dynamic 数据源对象</param>
        /// <returns></returns>
        public static dynamic DynamicFormat(dynamic obj)
        {
            JObject jobj = (Newtonsoft.Json.Linq.JObject)obj;

            T model = new T();//不关心对象T的值, 只关心对象T的字段的类型
            Type ts = model.GetType();
            //反射, 获取 T的所有 属性
            PropertyInfo[] PropertyList = ts.GetProperties();
            foreach (PropertyInfo item in PropertyList)
            {
                string colName = item.Name;
                string typeName = "";
                if (item.PropertyType.IsGenericType && item.PropertyType.GetGenericTypeDefinition() == typeof(Nullable<>))
                {
                    //如果是 Nullable<T>, 直接返回 T, 例如 DateTime?  返回 DateTime
                    Type[] arr = item.PropertyType.GetGenericArguments();
                    var columnType = arr[0];
                    typeName = columnType.ToString();
                    // Console.WriteLine($"{colName}的 目标类型是Nullable<{typeName}>");
                }
                else
                {
                    //普通类型, int,string,datetime 等
                    typeName = item.PropertyType.Name;
                }

                //Console.WriteLine("字段:" + colName);
                //Console.WriteLine("类型:" + typeName);

                #region  尝试类型转换
                var s = jobj.GetValue(colName);// {ID:"10"}
                if (s == null)
                {
                    jobj[colName] = null;
                    continue;
                }

                object va = ((Newtonsoft.Json.Linq.JValue)s).Value;//"10"

                if (va == null)
                {
                    jobj[colName] = null;
                    continue;
                }
                else
                {
                    if (typeName == "System.Int32")
                    {
                        int t;
                        bool parseRes = int.TryParse(va.ToString(), out t);
                        if (parseRes)
                        {
                            jobj[colName] = t;
                        }
                        else
                        {
                            jobj[colName] = null;
                        }
                    }
                    else if (typeName == "System.Decimal")
                    {
                        Decimal t;
                        bool parseRes = decimal.TryParse(va.ToString(), out t);
                        if (parseRes)
                        {
                            jobj[colName] = t;
                        }
                        else
                        {
                            jobj[colName] = null;
                        }
                    }

                    else if (typeName == "System.Double")
                    {
                        double t;
                        bool parseRes = double.TryParse(va.ToString(), out t);
                        if (parseRes)
                        {
                            jobj[colName] = t;
                        }
                        else
                        {
                            jobj[colName] = null;
                        }
                    }
                    else if (typeName == "System.Int64")
                    {
                        long t;
                        bool parseRes = long.TryParse(va.ToString(), out t);
                        if (parseRes)
                        {
                            jobj[colName] = t;
                        }
                        else
                        {
                            jobj[colName] = null;
                        }
                    }
                    else if (typeName == "System.DateTime")
                    {
                        DateTime t;
                        bool parseRes = DateTime.TryParse(va.ToString(), out t);
                        if (parseRes)
                        {
                            jobj[colName] = t;
                        }
                        else
                        {
                            DateTime? init = null;

                            jobj[colName] = init;
                        }
                    }
                    else if (typeName == "System.Int16")
                    {
                        short t;
                        bool parseRes = short.TryParse(va.ToString(), out t);
                        if (parseRes)
                        {
                            jobj[colName] = t;
                        }
                        else
                        {
                            jobj[colName] = null;
                        }
                    }
                }
                #endregion
            }
            return jobj;
        }

        public static dynamic DynamicFormatList(dynamic obj)
        {
            JArray jArray = new JArray();
            foreach (dynamic item in obj)
            {
                JObject jObject = JObject.FromObject(item);
                jArray.Add(DynamicFormat(jObject));
            }
            return jArray;
        }
    }
}

2.3.1 将上面的 Test2() 改为  TestNew2()

        public static void TestNew2()
        {
            JObject jobj_stu = new JObject();

            JProperty ip_id = new JProperty("Id", 1);
            JProperty ip_name = new JProperty("Name", "张三");
            JProperty ip_birthday = new JProperty("Birthday", "");

            jobj_stu.Add(ip_id);
            jobj_stu.Add(ip_name);
            jobj_stu.Add(ip_birthday);

            string json_ori = JsonConvert.SerializeObject(jobj_stu);
            Console.WriteLine("初始JSON:"+json_ori);
            // 输出 初始JSON:{"Id":1,"Name":"张三","Birthday":""}

            dynamic objNew = MapHelper<StudentInfo>.DynamicFormat(jobj_stu);
            string json_new = JsonConvert.SerializeObject(objNew);
            Console.WriteLine("处理后JSON:" + json_new);
            // 输出 处理后JSON:{"Id":1,"Name":"张三","Birthday":null,"Age":null}
            
            StudentInfo stu1 = JsonConvert.DeserializeObject<StudentInfo>(json_new);
            Console.WriteLine("对象序列化后JSON:"+JsonConvert.SerializeObject(stu1));
            // 输出 对象序列化后JSON:{"Id":1,"Name":"张三","Birthday":null,"Age":null}
        }

2.3.2 将上面的 Test3() 改为  TestNew3()

        public static void TestNew3()
        {
            JObject jobj_stu = new JObject();

            JProperty ip_id = new JProperty("Id", 1);
            JProperty ip_name = new JProperty("Name", "张三");
            JProperty ip_birthday = new JProperty("Birthday", "null");

            jobj_stu.Add(ip_id);
            jobj_stu.Add(ip_name);
            jobj_stu.Add(ip_birthday);

            string json_ori = JsonConvert.SerializeObject(jobj_stu);
            Console.WriteLine("初始JSON:"+json_ori);
            // 输出 初始JSON:{"Id":1,"Name":"张三","Birthday":"null"}

            dynamic objNew = MapHelper<StudentInfo>.DynamicFormat(jobj_stu);
            string json_new = JsonConvert.SerializeObject(objNew);
            Console.WriteLine("处理后JSON:" + json_new);
            // 输出 处理后JSON:{"Id":1,"Name":"张三","Birthday":null,"Age":null}
            
            StudentInfo stu1 = JsonConvert.DeserializeObject<StudentInfo>(json_new);
            Console.WriteLine("对象序列化后JSON:"+JsonConvert.SerializeObject(stu1));
            // 输出 对象序列化后JSON:{"Id":1,"Name":"张三","Birthday":null,"Age":null}
        }

3. 使用 Dictionary<string,object> 处理 入参

除了使用 dynamic 和 JObject  之类的处理方式, 还有 使用 Dictionary<string,object>的处理方式,

var dict = JsonConvert.DeserializeObject<Dictionary<string, object>>(json_ori);

4.构造极为复杂的Json对象

比如有如下json结构的参数

{"Id":1,"Name":"张三","Birthday":"null","Extends":[[]],"Extends2":[[[]],[[]]]}

添加了
Extends 和  Extends2

一眼看上去 好像不是很好构造,下面给出 C#代码 示例

        public static void TestNew5()
        {
            JObject jobj_stu = new JObject();

            JProperty ip_id = new JProperty("Id", 1);
            JProperty ip_name = new JProperty("Name", "张三");
            JProperty ip_birthday = new JProperty("Birthday", "null");

            var l1 = new List<object>() { new List<object>() { }};
            JProperty jp_extends = new JProperty("Extends", JArray.FromObject(l1) );
          
            dynamic p = new List<JArray>() { JArray.FromObject(l1), JArray.FromObject(l1) };
            JProperty jp_extends2 = new JProperty("Extends2", p);

            jobj_stu.Add(ip_id);
            jobj_stu.Add(ip_name);
            jobj_stu.Add(ip_birthday);
            jobj_stu.Add(jp_extends);
            jobj_stu.Add(jp_extends2);

            string json_ori = JsonConvert.SerializeObject(jobj_stu);
            Console.WriteLine("JSON:" + json_ori);
            // 输出 {"Id":1,"Name":"张三","Birthday":"null","Extends":[[]],"Extends2":[[[]],[[]]]}

            var dict =  JsonConvert.DeserializeObject < Dictionary<string, object>>(json_ori);
            Console.WriteLine("JSON:" + JsonConvert.SerializeObject(dict));
            // 输出 {"Id":1,"Name":"张三","Birthday":"null","Extends":[[]],"Extends2":[[[]],[[]]]}
        }

关于 object,dynamic ,以及 配合使用  Newtonsoft.Json 库 就先介绍到这里.

LED虚拟拍摄-跟踪算法

Track追踪

图引用
拍摄黑科技,LED虚拟影棚揭秘

标定流程

上面是一台Track设备,现精度比较高的主要是Redspy,Mosys,一般影视用这二种,其底层技术参考SMAL单目+惯性传感器(IMU),因为需要稳定精准的结果,实现上会贴红外反光片,使用红外相机得到这些贴片对应的稳定特征点用于建图(红外相机受外部亮度影响较小,在红外相机里,红外反光贴片相对周边会非常明亮,就像星空里发光的星星一样),也因为单目的原因,传感器本身的尺度与现实其实不对应,需要额外确保传感器本身的尺度与现实对应.

Track跟踪,主要是如下几步,求相机内参,相机与Track相对变换,Track空间与LED空间的相对变换,现在的标定一般来说,都是三步一起解决的.

标定内参,主要还是类似张正友标定法,得到在不同相机姿态态,得到一系列角点的2D-3D关系,其角点的选取可以来自棋盘格,这种方式限制大,需要拍摄全,对相机方向与位置限定了,影响后续手眼标定的精度,所以现在更多选择是的Aruco码,Aruco角点是有索引的,这样就方便找到明确的3D位置,相机只需要拍摄一部分aruco码就可以了,不会对相机距离与方向有限制,不过Aruco角点精度可能比不上圆形块,开了亚像素也不太行,于是像hecoos这样的,会利用视频流打圆形块,根据一系列帧出现/没出现计算出唯一索引,也能确定唯一位置,但是这种方式标定就会比较费时.

得到一系列3D-2D的角点后,代入cv::calibrateCamera求出就可以了,也得到每帧数据相机的变换,一般在记录图片时,也会记录Track的变换,这样就得到一组相机与Track的变换,如果根据这组数据,得到相机与Track相对变换了,这其实就是机器人常见的手眼标定的问题.

其手眼标定(本文限定眼在手上)的算法利用二个坐标系的钢体变化,构建一个AX=XB的问题去求解,先求得相记与Track的变换矩阵,然后得到Track坐标系与LED坐标系的变换,把结果代入记录数组,计算重投影的误差来判断结果的好坏.

实际过程来说,一般是取九张不同姿态的图,然后先算内参,再用手眼标定算相机与Track相对变换,但是直接九张图效果一般不太好,会加上RANSAC算法,选择其中3-5张结合结果,使用SVD得到最优Track空间与LED空间的相对变换,最后根据这二个结果代入之前记录计算重投影拿到最优结果.

手眼标定算法改进

这里有个问题,前面提到因为单目的原因,传感器本身的尺度与现实其实不对应,意思Track给出的数据是1m,但是在现实中可能对应10cm,10m,3m这些位移,OpneCV本身手眼标定并不能计算这种与现实的尺度不匹配的数据,现场一般会利用Redspy/Mosys硬件本身的功能纠正,但是使用比较麻烦,比如已经绑在摄像机上需要重新取下,人工取尺输入现实数据移动,精度就和人扯上关系了,和人扯上关系,就容易出问题,能不能改了?其实是可以,还记的当时那段时间刚好新冠被封锁在租的小区里,就仔细看了下opencv里的手眼标定的Tsai算法.

改进的主要就是求得位移的过程,先来看下opencv原手眼标定里求位移相关逻辑.

    // 解决追踪器与摄像机使用不同尺寸问题.
    Mat A(3 * K, 3, CV_64FC1);
    // Will store: Pcij - Pgij
    Mat B(3 * K, 1, CV_64FC1);
    idx = 0;
    for (size_t i = 0; i < Hg.size(); i++)
    {
        for (size_t j = i+1; j < Hg.size(); j++, idx++)
        {
            //Defines coordinate transformation from Gi to Gj
            //Hgi is from Gi (gripper) to RW (robot base)
            //Hgj is from Gj (gripper) to RW (robot base)
            Mat Hgij = vec_Hgij[static_cast<size_t>(idx)];
            //Defines coordinate transformation from Ci to Cj
            //Hci is from CW (calibration target) to Ci (camera)
            //Hcj is from CW (calibration target) to Cj (camera)
            Mat Hcij = vec_Hcij[static_cast<size_t>(idx)];

            //Left-hand side: (Rgij - I)
            Mat diff = Hgij(Rect(0,0,3,3)) - Mat::eye(3,3,CV_64FC1);
            diff.copyTo(A(Rect(0, idx*3, 3, 3)));

            //Right-hand side: Rcg*Tcij - Tgij
            diff = Rcg*Hcij(Rect(3, 0, 1, 3)) - Hgij(Rect(3, 0, 1, 3));
            diff.copyTo(B(Rect(0, idx*3, 1, 3)));
        }
    }

    Mat Tcg;
    //Translation from camera to gripper is obtained from the set of equations:
    //    (Rgij - I) * Tcg = Rcg*Tcij - Tgij    (eq 15)
    solve(A, B, Tcg, DECOMP_SVD);

利用等式

\[(Rgij - I) * Tcg = Rcg*Tcij - Tgij
\]

其中Rgij表示标定记录中二次记录之间追踪器空间下追踪器之间的旋转,根据记录是已知量.I表示3x3的单位矩阵,是固定量.Tcg表示追踪器空间下,摄像机相对追踪器的位移偏移,也就是我们要求解的值.Rcg表示追踪器空间下,摄像机相对追踪器的旋转偏移.Tcij 表示标定记录中二次记录之间摄像机空间下标定板之间的旋转,根据记录是已知量.Tgij 表示标定记录中二次记录之间追踪器空间下追踪器之间的位移,根据记录是已知量.假定Tracl与现实中的缩放参数是Scale,那么可以重组为如下等式.

\[(Rgij - I) * Tcg - Rcg*Tcij*Scale = -Tgij
\]

原来矩阵算式如下.

\[\begin{bmatrix}
A_{00}&A_{01}&A_{02}\\
A_{10}&A_{11}&A_{12}\\
A_{20}&A_{21}&A_{22}\\
\end{bmatrix} * \begin{bmatrix}
T_{0}\\
T_{1}\\
T_{2}\\
\end{bmatrix} = \begin{bmatrix}
B_{0}\\
B_{1}\\
B_{2}\\
\end{bmatrix}
\]

现在变换后,矩阵算式如下.

\[\begin{bmatrix}
A_{00}&A_{01}&A_{02}&C_{03}\\
A_{10}&A_{11}&A_{12}&C_{13}\\
A_{20}&A_{21}&A_{22}&C_{23}\\
\end{bmatrix} * \begin{bmatrix}
T_{0}\\
T_{1}\\
T_{2}\\
S_{3}\\
\end{bmatrix} = \begin{bmatrix}
B_{0}\\
B_{1}\\
B_{2}\\
\end{bmatrix}
\]

其中新变换里的C表示- Rcg*Tcij,这样就把缩放参数带入要求解的[T0,T1,T2,S3]中的S3里面,新的代码如下.

    // 解决追踪器与摄像机使用不同尺寸问题.
    Mat TA(3 * K, 4, CV_64FC1);
    // Will store: Pcij - Pgij
    Mat TB(3 * K, 1, CV_64FC1);
    idx = 0;
    for (size_t i = 0; i < Hg.size(); i++) {
        for (size_t j = i + 1; j < Hg.size(); j++, idx++) {
            // Defines coordinate transformation from Gi to Gj
            // Hgi is from Gi (gripper) to RW (robot base)
            // Hgj is from Gj (gripper) to RW (robot base)
            Mat Hgij = vec_Hgij[static_cast<size_t>(idx)];
            // Defines coordinate transformation from Ci to Cj
            // Hci is from CW (calibration target) to Ci (camera)
            // Hcj is from CW (calibration target) to Cj (camera)
            Mat Hcij = vec_Hcij[static_cast<size_t>(idx)];

            // Left-hand side:3x3_(Rgij - I) 3x1_(Rcg*Tcij)
            Mat ldiff1 = Hgij(Rect(0, 0, 3, 3)) - Mat::eye(3, 3, CV_64FC1);
            ldiff1.copyTo(TA(Rect(0, idx * 3, 3, 3)));
            Mat ldiff2 = -(Rcg * Hcij(Rect(3, 0, 1, 3)));
            ldiff2.copyTo(TA(Rect(3, idx * 3, 1, 3)));

            // Right-hand side: -Tgij
            Mat diff = -Hgij(Rect(3, 0, 1, 3));
            diff.copyTo(TB(Rect(0, idx * 3, 1, 3)));
        }
    }
    Mat Tcg;
    solve(TA, TB, Tcg, DECOMP_SVD);

其求得的4*1前面三个表示Tcg(摄像机相对追踪器的位移),最后数据表示现实世界相对追踪器坐标系下缩放是scale.

这样后,应该得到类似一个如下结果.

struct ECameraTrack {
  // 追踪器坐标系如何变换成标定板坐标系
  Eigen::Matrix4d base2target = {};
  // 保存摄像机相对track的姿态
  Eigen::Matrix4d camera2track = {};
  // 追踪器坐标系相对标定板的位移缩放
  double scale = 1.0;
};

图优化标定结果

上面处理后,一般来说,结果平均在8个像素误差左右,用于VP肯定是够了,VP一般是扩展FOV的,但是如果想用于XR拍摄,这个精度就可能会不够,还能不能计算更精准,先看下优化后的效果.

可以看到平均4个像素误差优化到1个左右,实际实现情况大的LED幕墙,原结果平均8个像素(还不稳定)可以优化到稳定的平均3个像素左右误差,精度够XR用了.

那段时间正好在看同事推荐的视觉SLAM十四讲,学习图优化相关框架g2o,可以直接使用测量值比较误差求解或是优化参数,简单总结图优化的步骤,一是确定需要优化的变量,二是确定变量到观测量的计算过程,通过这个过程得到计算结果与观测量比较得到误差,第三步是构建变量与计算过程与观测量误差的图.

前面二步对应图优化框架g2o里的二个概念,分别是顶点和边,其顶点对应的就是需要求解的变量,而边就是由顶点构建计算过程,得到结果并与测量值确定误差,第三步优化过程就是构建顶点,边与测量值的图.

相对常规解法,图优化是直接根据测量值优化结果,只需要构建一个参数能正确和测量值比较误差的模型就行,相比手眼标定算法,需要理解钢体关系多帧间几个变化的相等性到构建AX=XB的处理来说,直接由结果去代入重投影比较误差,然后构建模型会非常容易理解,在3D视觉中,有非常多的这种需求,知道变量,知道变量导致的结果误差,但是不知道怎么求解,其图优化就可以求解这种情况,所以在3D视觉中,其图优化使用非常普遍.

如下有些代码,其变换关系不理解会比较乱,为了方便理解,简单介绍一下结构与命名,比如target说的是屏幕坐标系,camera表示摄像机在屏幕坐标系下的运动,base是追踪器坐标系,track表示追踪器在追踪器坐标系下的运动,这样如base2target表示追踪器坐标系转到屏幕坐标系下的变换,而camera2track表示camera相对track在追踪器坐标系下相对变换,camera2target表示摄像机在屏幕坐标系下的变换,target2camera表示camera2target的逆变换,可以理解成屏幕在摄像机下的变换,track2base表示追踪器在追踪器坐标系下的变换.

如前面介绍图优化的过程,先需要确定求解的值,在这就是camera2track(也就是原手眼标定求解的值),base2target用于把追踪器坐标系转化到屏幕坐标系下,需要注意的是,这里的track给的位移与现实中的位移没有对应,有个缩放关系,在这我们也需要求解这个Scale值,这样就有三个值需要求解,然后假定标定过程使用的Track变换相对真实值有细微误差,针对每个Track记录变换也当做一个需要优化的值,这个处理有兴趣的可以详细参考
基于重投影误差最小化的手眼标定
,能正确的处理Track异常记录,经实际测试,对于结果的正确性会有很大提高.

需要的结果是二个变换+一个缩放,和中间Track记录变换的优化,变换对应的变量直接使用g2o内置的VertexSE3,截取重要代码如下,有兴趣可以自己去看g2o里的源码,而缩放只需要简单封装一个double就行,简单来说,顶点最主要的是实现方法oplusImpl,告诉顶点如果更新参数.

// 内置钢体变换顶点 
class G2O_TYPES_SLAM3D_API VertexSE3 : public BaseVertex<6, Isometry3> {
 public:
  EIGEN_MAKE_ALIGNED_OPERATOR_NEW;

  virtual void setToOriginImpl() { _estimate = Isometry3::Identity(); }
  virtual void oplusImpl(const double* update) {
    Eigen::Map<const Vector6> v(update);
    Isometry3 increment = internal::fromVectorMQT(v);
    _estimate = _estimate * increment;
  }
}
// 封装缩放的顶点
class ScaleVertex : public g2o::BaseVertex<1, double> {
 public:
  ScaleVertex() {}

  virtual void setToOriginImpl() { _estimate = 1.0; }
  virtual void oplusImpl(const double* update) {    
    _estimate += update[0];
  }
  virtual bool read(std::istream& is) {
    is >> _estimate;
    return true;
  }
  virtual bool write(std::ostream& os) const {
    os << _estimate;
    return true;
  }
};

确定顶点后,然后就是确定边,就如前面所说,边是确定如何优化顶点与测量值的误差变小的,这里使用重投影确定角点UV与测量UV的误差,角点的三维位置与测量UV分别由建立屏幕坐标系时确定以及OPENCV查找角点得到,边的computeError记录了如何把角点的三维位置转化到摄像机下的位置,并得到UV,然后与测量UV比较的过程.

class ProjectionHandEdge
    : public g2o::BaseUnaryEdge<2, Eigen::Vector2d, g2o::VertexSE3> {
  EIGEN_MAKE_ALIGNED_OPERATOR_NEW
 public:
  ProjectionHandEdge()
      : g2o::BaseUnaryEdge<2, Eigen::Vector2d, g2o::VertexSE3>() {
    resizeParameters(1);
    installParameter(lensModelPar, 0);
  }

  virtual bool read(std::istream& is) { return false; }

  virtual bool write(std::ostream& os) const { return false; };

  virtual void computeError() override;
  g2o::Vector3 point = {};

 protected:
  LensModelParameter* lensModelPar = nullptr;
};

void ProjectionHandEdge::computeError() {
  const g2o::VertexSE3* target2cameraVec =
      dynamic_cast<const g2o::VertexSE3*>(_vertices[0]);
  Eigen::Isometry3d target2camera = target2cameraVec->estimate();
  // 点在相机下位置
  g2o::Vector3 cameraPos = target2camera * point;
  // UV
  g2o::Vector2 cuv = lensModelPar->map(cameraPos);
  // 误差由观测值减预测值
  _error = measurement() - cuv;
}

// 使用小孔相机模型确定3维顶点的投影平面
g2o::Vector2 lensMap(const LensModel& lensModel, const g2o::Vector3& pos,
                     double scale) {
  double x = pos[0] / pos[2];
  double y = pos[1] / pos[2];
  double fx = lensModel.focalLength.x;
  double fy = lensModel.focalLength.y;
  double cx = lensModel.focalCenter.x;
  double cy = lensModel.focalCenter.y;
  double k1 = lensModel.K1;
  double k2 = lensModel.K2;
  double k3 = lensModel.K3;
  double p1 = lensModel.P1;
  double p2 = lensModel.P2;
  // 径向畸变
  double r2 = x * x + y * y;
  double r4 = r2 * r2;
  double r6 = r4 * r2;
  // 切向畸变
  double a1 = 2 * x * y;
  double a2 = r2 + 2 * x * x;
  double a3 = r2 + 2 * y * y;
  double cdist = 1.0 + k1 * r2 + k2 * r4 + k3 * r6;
  x = x * cdist + p1 * a1 + p2 * a2;
  y = y * cdist + p1 * a3 + p2 * a1;
  // UV
  double u = fx * x + cx;
  double v = fy * y + cy;
  return g2o::Vector2(u * scale, v * scale);
}

每次记录对应的Track变换优化对应设计如下,已经每次记录track2base(track在Track坐标系下的变换),和根据需要求解的变量camera2track,base2target,scale以及摄像机在屏幕坐标系下的变换,得到求解出来的track2base,根据测试出来的track2base,比较误差.

// track姿态优化
class HandEyeEdge : public g2o::BaseMultiEdge<6, Eigen::Isometry3d> {
  EIGEN_MAKE_ALIGNED_OPERATOR_NEW
 public:
  HandEyeEdge();

  virtual bool read(std::istream& is);

  virtual bool write(std::ostream& os) const;

  virtual void computeError() override;
};
HandEyeEdge::HandEyeEdge() { resize(4); }

bool HandEyeEdge::read(std::istream& is) {
  Vector7 est = {};
  bool state = internal::readVector(is, est);
  _measurement = internal::fromVectorQT(est);
  return readInformationMatrix(is);
}

bool HandEyeEdge::write(std::ostream& os) const {
  internal::writeVector(os, internal::toVectorQT(measurement()));
  return writeInformationMatrix(os);
}

Eigen::Isometry3d getScaleIsometry3d(const Eigen::Isometry3d& src,
                                     double scale) {
  Eigen::Isometry3d result = src;
  result.translation() = scale * src.translation();
  return result;
};

void HandEyeEdge::computeError() {
  // 摄像机相对Track
  const g2o::VertexSE3* camera2trackVec =
      dynamic_cast<const g2o::VertexSE3*>(_vertices[0]);
  // 追踪器坐标系变换到角点坐标系
  const g2o::VertexSE3* base2targetVec =
      dynamic_cast<const g2o::VertexSE3*>(_vertices[1]);
  // 角点
  const g2o::VertexSE3* target2cameraVec =
      dynamic_cast<const g2o::VertexSE3*>(_vertices[2]);
  // 缩放
  const ScaleVertex* scaleVec = dynamic_cast<const ScaleVertex*>(_vertices[3]);
  // 当前值
  Eigen::Isometry3d camera2track = camera2trackVec->estimate();
  Eigen::Isometry3d base2target = base2targetVec->estimate();
  Eigen::Isometry3d target2camera = target2cameraVec->estimate();
  double scale = scaleVec->estimate();
  // 优化量计算得到的Track
  Eigen::Isometry3d base2track = camera2track * target2camera * base2target;
  // 转化成世界尺度下
  Eigen::Isometry3d measurementScale = getScaleIsometry3d(_measurement, scale);
  // 比较计算的Track与测量的Track姿态
  Eigen::Isometry3d delta = measurementScale * base2track;
  _error = g2o::internal::toVectorMQT(delta);
}

同上面重投影边一样,在computeError把根据这些结果求得的track变换与测量的track变换比较误差,最后我们把所有已知数据与测量值组成图如下.

CameraTrack HandEyeOptimizer::compute(const HandEyeParamet& handEyeParamet,
                                      const LensModel& lensModel,
                                      const CameraTrack& cameraTrack) {
  handEyePar = handEyeParamet;
  eigen::ECameraTrack camtrack = {};
  eigen::toCameraTrack(cameraTrack, camtrack);
  Eigen::Isometry3d camera2track = Eigen::Isometry3d::Identity();
  Eigen::Isometry3d base2target = Eigen::Isometry3d::Identity();
  camera2track.matrix() = camtrack.camera2track;
  base2target.matrix() = camtrack.base2target;
  // 设置HandEyeEdge的变化率
  Eigen::MatrixXd handeyeInf = Eigen::MatrixXd::Identity(6, 6);
  // 移动部分
  handeyeInf.topLeftCorner(3, 3) *= 0.01;
  // 旋转部分
  handeyeInf.bottomRightCorner(3, 3) *= 1.0;
  // 优化器
  SparseOptimizer optimizer;
  using LinearSolver =
      g2o::LinearSolverDense<g2o::BlockSolverX::PoseMatrixType>;
  OptimizationAlgorithmGaussNewton* solver =
      new g2o::OptimizationAlgorithmGaussNewton(
          std::make_unique<g2o::BlockSolverX>(
              std::make_unique<LinearSolver>()));
  // 设置优化方法
  optimizer.setAlgorithm(solver);
  // 设置镜头参数
  LensModelParameter* lensPar = new LensModelParameter(lensModel);
  lensPar->uvScale = handEyeParamet.uvScale;
  lensPar->setId(0);
  optimizer.addParameter(lensPar);
  // 优化量camera2Track
  VertexSE3* c2tVec = new VertexSE3();
  c2tVec->setEstimate(camera2track);
  c2tVec->setId(0);
  // c2tVec->setFixed(true);
  optimizer.addVertex(c2tVec);
  // 优化量base2target
  VertexSE3* b2tVec = new VertexSE3();
  b2tVec->setEstimate(base2target);
  b2tVec->setId(1);
  // b2tVec->setFixed(true);
  optimizer.addVertex(b2tVec);
  ScaleVertex* scaleVec = new ScaleVertex();
  scaleVec->setEstimate(cameraTrack.scale);
  scaleVec->setId(2);
  scaleVec->setFixed(handEyePar.bFixScale);
  optimizer.addVertex(scaleVec);
  // 每条记录(包含摄像机变换,Track变换,角点UV与三维位置)
  for (const TrackCorners& trackCorner : trackCorners) {
    aoce::Mat4x4d trackPose = trackCorner.trackPose;
    aoce::Mat4x4d cameraPose = trackCorner.cameraPose;
    Eigen::Isometry3d track2base = Eigen::Isometry3d::Identity();
    track2base.matrix() = eigen::toMat(trackPose);
    // track转化成真实世界坐标系中
    Eigen::Isometry3d track2baseScale =
        getScaleIsometry3d(track2base, cameraTrack.scale);
    // 角点相对摄像机坐标系的转换
    Eigen::Isometry3d camera2target = Eigen::Isometry3d::Identity();
    if (trackCorner.cameraPose.valid()) {
      camera2target.matrix() = eigen::toMat(trackCorner.cameraPose);
    } else {
      camera2target = base2target * track2baseScale * camera2track;
    }
    Eigen::Isometry3d target2camera = camera2target.inverse();
    g2o::VertexSE3* t2cVec = new g2o::VertexSE3();
    t2cVec->setEstimate(target2camera);
    t2cVec->setId(optimizer.vertices().size());
    // t2cVec->setFixed(true);
    optimizer.addVertex(t2cVec);
    // 设置Track姿态边
    HandEyeEdge* handEyeEdge = new HandEyeEdge();
    handEyeEdge->vertices()[0] = c2tVec;
    handEyeEdge->vertices()[1] = b2tVec;
    handEyeEdge->vertices()[2] = t2cVec;
    handEyeEdge->vertices()[3] = scaleVec;
    // 比较实测与观测的Track数据
    handEyeEdge->setMeasurement(track2base);
    handEyeEdge->setInformation(handeyeInf);
    handEyeEdge->setId(optimizer.edges().size());
    if (handEyePar.robustHandEye) {
      g2o::RobustKernelHuber* kerner = new g2o::RobustKernelHuber();
      kerner->setDelta(handEyePar.handEyeDelta);
      handEyeEdge->setRobustKernel(kerner);
    }
    optimizer.addEdge(handEyeEdge);
    // 经测试,UV使用的粒度小时,精度会提升
    vec2d sizeInv = {
        handEyeParamet.uvScale / trackCorner.pointCorners.imageSize.x,
        handEyeParamet.uvScale / trackCorner.pointCorners.imageSize.y};
    for (int32_t i = 0; i < trackCorner.pointCorners.count; i++) {
      vec2f corner = *(trackCorner.pointCorners.corners + i);
      vec3f point = *(trackCorner.pointCorners.points + i);
      // 设置投影边
      ProjectionHandEdge* proEdge = new ProjectionHandEdge();
      proEdge->setMeasurement({corner.x * sizeInv.x, corner.y * sizeInv.y});      
      proEdge->setInformation(Eigen::Matrix2d::Identity() * 0.01);
      proEdge->vertices()[0] = t2cVec;
      proEdge->setParameterId(0, 0);
      proEdge->setId(optimizer.edges().size());
      proEdge->point = {point.x, point.y, point.z};
      if (handEyePar.projectionHand) {
        g2o::RobustKernelHuber* kerner = new g2o::RobustKernelHuber();
        kerner->setDelta(handEyePar.projectionHand);
        proEdge->setRobustKernel(kerner);
      }
      optimizer.addEdge(proEdge);
    }
  }
  // 执行优化
  bool bInit = optimizer.initializeOptimization();
  if (!bInit) {
    logMessage(LogLevel::warn,
               "ZoomScaleOptimizer::computePoseZoom init optimizer failed");
    return cameraTrack;
  }
  // optimizer.setVerbose(true);
  optimizer.optimize(10);
  // 检测
  camtrack.camera2track = c2tVec->estimate().matrix();
  camtrack.base2target = b2tVec->estimate().matrix();
  camtrack.scale = scaleVec->estimate();
  CameraTrack result = {};
  eigen::toCameraTrack(camtrack, result);
  return result;
}

整个过程经过优化器多次迭代就能得到更优结果,相对于原始手眼标定的结果来说,优点不少,原始的数据越多,其直接组合一起计算结果很差,只能从各种组合计算结果使用重投影确定最优值,一般最多只选择其中不大于五条记录会是一个最好的结果,大部分记录被排除计算,数据量比较少的情况下,又不能保证结果在10个像素以内,使用图优化的方法,在记录少的情况就能得到非常优秀的结果,在记录多的情况下,更能保证更多的数据得到平均最优的结果,代入以前记录的各组记录数据,在图优化的情况下,能保证所有结果都能得到更好的结果.

变焦标定

在标定Track之后,知道镜头在某个焦段的内外参,扩展到变焦镜头,当镜头的zoom变化后,内参如何变化?

一般相机内参fx/fy,cx/cy,考虑畸变系数K1,K2,P1,P2,K3的畸变模型,与zoom有关的是fx/fy,cx/cy影响不大,畸变P1,P2现在相机在工艺上上,值非常小,对畸变的影响非常小,K3影响也不太大,故需要考虑的fx/fy,K1,K2这四个参数,考虑fx/fy比值固定,只需要考虑fx,k1,k2这三个参数在变焦镜头的zoom变化后,如何变化?

相机zoom变化后,内参不做变化,可以看到匹配的特征误差越来越大,也就是反投影的误差变大,误差优化参数,还得是图优化.

相比上一个手眼标定的图优化过程的点边模型,这个模型会简单不少,假定fx,k1,k2是zoom变化的曲线(ax^2+bx+c=y)变化,最后结果就是求得fx,k1,k2对应的a,b,c的值.对应有二种思路,一种是根据每个zoom下的图,反投影图优化得到fx,k1,k2,最后再拟合多个fx,k1,k2各自的曲线,第二种是直接把各自曲线所有zoom下的图然后反投影图优化一起优化,现二种最终结果相关不大,在定焦三个误差内,变焦后各zoom平均在10个像素,后面也还需要持续优化.

这里列一种处理的部分代码.

void ZoomCurveEdge::computeError() {
  const CurveVertex* fxVer = dynamic_cast<const CurveVertex*>(_vertices[0]);
  const CurveVertex* k1Ver = dynamic_cast<const CurveVertex*>(_vertices[1]);
  const CurveVertex* k2Ver = dynamic_cast<const CurveVertex*>(_vertices[2]);
  const ScaleVertex* scaleVer = dynamic_cast<const ScaleVertex*>(_vertices[3]);

  const g2o::Vector3 fxCurve = fxVer->estimate();
  const g2o::Vector3 k1Curve = k1Ver->estimate();
  const g2o::Vector3 k2Curve = k2Ver->estimate();
  const double sacle = scaleVer->estimate();

  LensModel lensModel = lensModelPar->lensModel;
  double aspectRatio = lensModel.focalLength.y / lensModel.focalLength.x;
  double fx = getCurveVal(fxCurve, sacle);
  lensModel.focalLength.x = fx;
  lensModel.focalLength.y = fx * aspectRatio;
  lensModel.K1 = getCurveVal(k1Curve, sacle);
  lensModel.K2 = getCurveVal(k2Curve, sacle);
  g2o::Vector2 estValue = lensMap(lensModel, point);
  // 误差由观测值减预测值
  _error = measurement() - estValue;
}
bool ZoomLensOptimizer::compute(const ZoomOptParamet& paramet) {
  optParamet = paramet;
  // 开始优化
  SparseOptimizer optimizer;
  // LinearSolverDense LinearSolverEigen LinearSolverPCG
  using LinearSolver =
      g2o::LinearSolverEigen<g2o::BlockSolverX::PoseMatrixType>;
  // OptimizationAlgorithmGaussNewton OptimizationAlgorithmLevenberg
  // OptimizationAlgorithmDogleg
  OptimizationAlgorithmGaussNewton* solver =
      new g2o::OptimizationAlgorithmGaussNewton(
          std::make_unique<g2o::BlockSolverX>(
              std::make_unique<LinearSolver>()));
  // 设置优化方法
  optimizer.setAlgorithm(solver);
  // 设置参数
  LensModelParameter* lensPar = new LensModelParameter(lensModel);
  lensPar->setId(0);
  optimizer.addParameter(lensPar);
  // 曲线
  CurveVertex* fxCurve = new CurveVertex();
  fxCurve->setEstimate({0, 0, lensModel.focalLength.x});
  fxCurve->setId(0);
  optimizer.addVertex(fxCurve);
  CurveVertex* k1Curve = new CurveVertex();
  k1Curve->setEstimate({0, 0, lensModel.K1});
  k1Curve->setId(1);
  k1Curve->setFixed(optParamet.fixDistort);
  optimizer.addVertex(k1Curve);
  CurveVertex* k2Curve = new CurveVertex();
  k2Curve->setEstimate({0, 0, lensModel.K2});
  k2Curve->setId(2);
  k2Curve->setFixed(true);
  optimizer.addVertex(k2Curve);
  std::vector<ScaleVertex*> fxList;
  // 添加点与边
  for (const CameraZoom& poseZoom : poseZooms) {
    Eigen::Isometry3d target2camera = Eigen::Isometry3d::Identity();
    target2camera.matrix() = eigen::toMat(invPoseMat(poseZoom.cameraPose));
    // 假定变焦环的数据并不严谨(推理不出来,只能固定)
    ScaleVertex* scaleValue = new ScaleVertex();
    scaleValue->setId(optimizer.vertices().size());
    scaleValue->setEstimate(poseZoom.zoomScale);
    scaleValue->setFixed(true);
    optimizer.addVertex(scaleValue);
    //
    vec2d sizeInv = {1.0 / poseZoom.pointCorners.imageSize.x,
                     1.0 / poseZoom.pointCorners.imageSize.y};
    // 反投影
    for (int32_t i = 0; i < poseZoom.pointCorners.count; i++) {
      vec2f corner = *(poseZoom.pointCorners.corners + i);
      vec3f point = *(poseZoom.pointCorners.points + i);
      // 点在相机下位置
      g2o::Vector3 cornerPos = {point.x, point.y, point.z};
      g2o::Vector3 cameraPos = target2camera * cornerPos;
      // 设置边
      ZoomCurveEdge* zoomLensEdge = new ZoomCurveEdge();
      zoomLensEdge->vertices()[0] = fxCurve;
      zoomLensEdge->vertices()[1] = k1Curve;
      zoomLensEdge->vertices()[2] = k2Curve;
      zoomLensEdge->vertices()[3] = scaleValue;
      zoomLensEdge->setMeasurement(
          {corner.x * sizeInv.x, corner.y * sizeInv.y});
      zoomLensEdge->setInformation(Eigen::Matrix2d::Identity());
      zoomLensEdge->setParameterId(0, 0);
      zoomLensEdge->setId(optimizer.edges().size());
      zoomLensEdge->point = cameraPos;
      if (true) {
        g2o::RobustKernelHuber* kerner = new g2o::RobustKernelHuber();
        kerner->setDelta(1.0);
        zoomLensEdge->setRobustKernel(kerner);
      }
      optimizer.addEdge(zoomLensEdge);
    }
  }
  // 执行优化
  bool bInit = optimizer.initializeOptimization();
  optimizer.optimize(10);
  fxPar = fxCurve->estimate();
  k1Par = k1Curve->estimate();
  k2Par = k2Curve->estimate();
  // 计算误差
  LensModel slensModel = lensModel;
  for (CameraZoom& poseZoom : poseZooms) {
    Eigen::Vector2d offset = {0, 0};
    if (getLensModel(poseZoom.zoomScale, slensModel)) {
      Mat4x4d target2camera = poseZoom.cameraPose.inverse();
      offset = projectOffset(target2camera, slensModel, poseZoom.pointCorners);
    }
  }
  return true;
}

这个结果还需优化,在写到这里的时候仔细想了下,主要可能有几点,一是fx/fy在变焦下比值是否有相对大的变化,二是上述曲线是否有更科学的模型,三是现k2代入后,结果大部分结果更差,导致k2现在用固定的,但是焦距变化比较大后,K2的变化还是比较明显的,后面有机会再想想改进吧.

整个跟踪相关的算法差不多就是这个样子,其实算法占的比例并不大,主要是工程上的各种问题,如各种相机采集(Decklink,MF...),Redspy,Mosys硬件接入,以及给的是欧拉角,如何确定顺序,追踪数据如何通过LiveLink数据发送到UE,不同空间坐标系的变换,FBXMesh的导入与导出,各个模块如何有序的组合,动态链接库与UE使用冲突,等等这些细节问题才是更麻烦的.