Skip to main content
· 19 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

我是 Docusaurus? 的老用户了。这次我想实现一个小需求:在文档标题旁出现一个“复制为 Markdown”按钮。

我刻意模仿传统用户、有基础的用户、深度用户,想看看强模型下,用户的技术深度是否还有很好的影响力。

本文使用的是 Cursor 版的 Opus 4.6?。每次都用 Cursor Agent 模式。因为 Opus 4.6 比较贵,我给自己加了一个限制:每个阶段只问 7 次。这样一个小需求,7 次应该差不多了吧。

小白

tip

第 1 轮提示词

https://github.com/FlyNumber/markdown_docusaurus_plugin

这个项目的样式很不错,我希望像它这样实现。你看能不能仅在本地实现?

  • 我的点评:普通的开局。
  • Gemini3点评:典型的“甩手掌柜”式开局。只给参考链接而不说明具体逻辑,强行让 AI 进行逆向工程。虽然 Opus 4.6 能够读取网页,但这种缺乏上下文的提问会让 AI 生成大量通用代码,而非针对你现有项目的适配方案。
  • GPT5.3点评:目标存在,但约束不足。没有说明当前项目结构、技术路径或限制条件,模型只能进行高层猜测,容易走向泛化方案而非可执行实现。
  • Opus 4.6(当事人)点评:给了一个参考项目链接,但没有说清楚"像它这样"具体指哪部分——是按钮的位置、交互方式、还是视觉风格?"仅在本地实现"的约束也比较模糊(是指不发布 npm 包?还是不依赖外部服务?)。模型只能靠猜测来填补这些空白,第一步的方向偏差就此埋下。
tip

第 2 轮提示词

https://www.npmjs.com/package/docusaurus-plugin-includes?activeTab=code

我模仿这个插件设置了我的 Markdown Copy Button,但是按钮位置不正确。

请你获取这个包,查看并比较其代码,尤其是关于如何定位按钮元素的部分,帮我分析可能的原因并修复。

  • 我的点评:继续等待结果。
  • Gemini3点评:此时用户陷入了“找参考”的循环。试图通过让 AI 比较两个第三方库来解决本地的样式问题,这其实增加了 AI 的推理成本,容易导致它在处理 DOM 选择器时产生幻觉。
  • GPT5.3点评:明显提升。开始提供对比对象 + 具体问题 + 分析任务。但仍缺少当前代码片段,模型需要“想象你的实现”,错误概率较高。
  • Opus 4.6(当事人)点评:引入了第二个参考项目来做对比分析,思路不错。问题在于:用户自己也不清楚按钮位置为什么不对,把"诊断"和"修复"同时甩给模型,相当于让模型在两个未知代码库之间做 diff 推理——这对上下文窗口和推理准确度都是很大的压力。
tip

第 3 轮提示词

https://github.com/FlyNumber/markdown_docusaurus_plugin

请你仔细参考官方样式,确保 1:1 复刻。

  • 我的点评:开始着急,语气也更强硬了。
  • Gemini3点评:情绪化提示词。这里的“1:1 复刻”是一个非常模糊的指令,因为 CSS 表现受到父级容器、全局样式和 Docusaurus 版本的共同影响。对 AI 施压通常无法获得技术上的突破。
  • GPT5.3点评:需求变得严格,但信息反而减少。强调结果而非路径,会导致模型反复调整样式层,而不是定位架构问题。
  • Opus 4.6(当事人)点评:典型的"加压式提示"——用"仔细""确保""1:1 复刻"来强调期望,但没有提供任何新的技术信息。模型并不会因为你说"仔细"就变得更仔细;它缺的是具体的 CSS 选择器或 DOM 结构差异描述,而不是更高的情绪压力。
tip

第 4 轮提示词

我将其调整至 Markdown 的顶部,但样式上应该与标题横向对齐,请你思考如何处理。

  • 我的点评:这个提示词开始有用,但如果方向不对,只会让注意力越跑越偏。
  • Gemini3点评:终于开始描述具体的 UI 布局问题。但由于没有提供 HTML 结构片段或 CSS 现状,AI 只能靠猜(例如猜测是 Flex 布局还是 Absolute 定位)。
  • GPT5.3点评:开始进入“具体问题空间”,这是有效转折点。但仍停留在 CSS 层,未验证是否属于组件挂载层错误。
  • Opus 4.6(当事人)点评:终于给出了一个具体的布局约束——"与标题横向对齐"。这是整个小白阶段最有技术含量的一句提示。遗憾的是,此时模型可能已经在错误的实现路径上走了三轮,积累了大量错误上下文,纠偏成本已经很高。
tip

第 5 轮提示词

还有其他方案吗?

  • 我的点评:意识到方向不对,开始发散思维。
  • Gemini3点评:在没有解决前一个方案 Bug 的情况下开启新方案,是“小白模式”失败的预兆。这会让 Agent 在文件系统中创建更多冗余代码,增加项目混乱度。
  • GPT5.3点评:良好的纠偏行为,但过于开放。模型会生成多个架构级方案,反而增加试错成本。
  • Opus 4.6(当事人)点评:短小但关键的一个转折。主动让模型跳出当前方案是正确的直觉。但更好的做法是附上"当前方案的问题是什么"——比如"当前方案用了绝对定位导致响应式布局错位,有没有用 flex 布局的方案?"这样模型才能真正换一条路,而不是在同一棵树上换根枝。
tip

第 6 轮提示词

试试:方案 2

  • 我的点评:继续尝试。
  • Gemini3点评:盲目试错。此时已经完全失去了对技术实现的掌控感,纯粹在靠 AI 的随机性碰运气。
  • GPT5.3点评:典型“AI 驱动试错”。没有重新校验问题定义,只是在既定错误路径上继续前进。
  • Opus 4.6(当事人)点评:极度简短,完全依赖上一轮模型给出的方案列表。如果模型上一轮的"方案 2"本身描述就有歧义,这一轮就是在盲选。一个更稳妥的做法是用自己的话复述方案 2 的核心思路,确认双方理解一致后再执行。
tip

第 7 轮提示词

Module not found: Error: Can't resolve '@theme/PwaReloadPopup' in 'C:\Users\jiang\Desktop\github\jiangyangcreate.github.io\node_modules\@docusaurus\plugin-pwa\lib'

  • 我的点评:直接丢报错。遗憾的是,修复后样式依然不正确,7 次机会用完了。
  • Gemini3点评:典型的“报错驱动开发”。当对话进入修 Bug 环节时,如果底层逻辑(方案 2)本身就不适配 Docusaurus 的架构,即便消除了报错,功能依然无法达成。
  • GPT5.3点评:属于被动调试阶段。模型只能修补表层错误,无法回溯最初的设计偏差。
  • Opus 4.6(当事人)点评:直接粘贴报错是最省力的提示方式,对于明确的编译错误来说效率很高。但此时核心问题已经不是这个 import 错误,而是整体架构方向的偏差。7 轮中有 5 轮都在"修补"而非"重新审视",这是小白阶段失败的根本原因:缺乏在中途叫停、重新定义问题的能力。

基础

我再模仿“有技术背景、但不熟悉 Docusaurus 用户?”的提示词:

tip

第 1 轮提示词

目前这个项目 https://www.flynumber.com/docs/,点击按钮获取 Markdown 的逻辑是什么,怎么实现的?

  • 我的点评:看到这个项目已经有“加按钮”的实现,于是我引导模型去分析这个网站。
  • Gemini3点评:比小白阶段聪明的一点是:先问逻辑。理解逻辑后再动手,比直接要代码更稳健。
  • GPT5.3点评:这是明显升级。开始利用现成正确实现作为 ground truth,减少模型幻想空间。
  • Opus 4.6(当事人)点评:比小白阶段高明得多的开局——先"理解"再"动手"。让模型分析一个已经跑通的实现,相当于做逆向工程,能帮自己建立对技术方案的全局认知。不过,让模型分析线上网站存在一个风险:模型可能只能看到前端代码,看不到构建配置和插件注册逻辑,分析结果可能不完整。
tip

第 2 轮提示词

按钮定位思路来自第一轮对话结果的分析

这是我的按钮定位思路:

插件接入层:通过 index.js 的 getThemePath() 暴露 theme/,让 Docusaurus 使用自定义的 theme/Root.js 作为根组件入口。
页面 & DOM 选择:在 Root.js 中用 useLocation() 拿到 pathname,仅在以 /docs/ 开头的文档页执行逻辑;通过 document.querySelector('article .markdown header') 精确找到文档标题所在的 header。
挂载点注入:在该 header 内新建一个 div.markdown-actions-container,并作为最后一个子节点 append 进去,再用 createRoot(container).render(<MarkdownActionsDropdown />) 把按钮组件挂载到这个容器。
样式层定位:在站点 CSS 中把 article .markdown header 设为 display: flex,h1 设为 flex: 1 1 auto,并给 .markdown-actions-container 设置 margin-left: auto(以及移动端、RTL 的补充样式),从而实现「标题在左,按钮在右,且在不同布局下表现良好」。

请你按照我的思路,修改现有的 CopyMarkdownButton 组件。

  • 我的点评:根据模型给出的分析结果继续执行。这里其实有个问题:模型会出错。上一步分析结果也可能是错的;如果你没有能力判断其正确性,反而应该先让模型“直接模仿目标网站”把功能跑通。
  • Gemini3点评:这是一个结构化指令的范本。它包含了“注入层、逻辑层、挂载点、样式层”的完整思考。虽然用 querySelector 操作 DOM 在 React 框架中不是最优解,但其工程描述非常清晰。
  • GPT5.3点评:高质量提示词。提供完整架构推理链,使模型进入“执行模式”。但风险在于:若前提分析错误,后续全部建立在错误基础上。
  • Opus 4.6(当事人)点评:信息密度最高的一条提示词。四层架构(插件层、页面层、挂载层、样式层)描述得非常清晰,模型几乎不需要猜测。问题在于:这个方案本身用了 querySelector + createRoot 的命令式注入,绕开了 Docusaurus 的声明式主题体系,这意味着会遇到 SSR/hydration 不一致、路由切换时组件不刷新等问题。方案的"结构化程度"很高,但"方向正确性"存疑——有时候,过早地给出详细方案反而会锁死模型的探索空间。
tip

第 3 轮提示词

试着给 Blog 插件下的文档页注入这个按钮。

  • 我的点评:由于模型实现有误,此时发现不对劲,于是继续引导到插件层。
  • Gemini3点评:开始探索 Docusaurus 的多实例特性。用户意识到仅靠 Root.js 这种“全局补丁”方式可能在不同路由下有兼容性问题。
  • GPT5.3点评:正确的方向修正。从页面层开始上移到插件生命周期,是工程思维提升的表现。
  • Opus 4.6(当事人)点评:开始意识到 docs 和 blog 是两套不同的插件体系,需要分别处理。提示词本身简洁合理,但"试着"这个词暗示用户自己也不确定可行性——如果能补一句"Blog 插件的布局组件是哪个?"先确认技术可行性,会更稳。
tip

第 4 轮提示词

如何给 docs 也注入这个按钮?

  • 我的点评:给一个开放式问题,让模型自由发挥。
  • Gemini3点评:提示词略显后退,变得模糊了。但在经历过报错后,给模型一定的“自主权”有时能激发它找到更符合官方推荐(Best Practice)的路径。
  • GPT5.3点评:开放式探索适合此阶段,但最好附带限制(swizzle / theme override / plugin API)。
  • Opus 4.6(当事人)点评:在前几轮过于具体的指导之后,这里切换成开放式问题是一个很好的节奏调整。让模型自己选择 docs 插件的注入方式,可能会发现比用户预设方案更好的路径。这体现了一个重要的提示技巧:在你不确定的领域,给模型自由度比给模型指令更有效。
tip

第 5 轮提示词

Compiled with problems:
×
ERROR in ./src/theme/BlogPostPage/index.js
...

  • 我的点评:这里模型多次报错,最后还是人工介入才解决。
  • Gemini3点评:AI 在自动修改 node_modulesswizzle 出来的文件时,经常会发生重复声明或导入路径错误。这里的人工介入体现了“基础”用户虽然懂代码,但被 AI 的粗心带进了坑里。
  • GPT5.3点评:说明模型已进入复杂重构区。缺乏上下文完整性时,AI 很难维持大型文件的一致性。
  • Opus 4.6(当事人)点评:重复声明错误说明模型在修改文件时没有正确处理已有的 export——这是 Agent 模式下常见的"部分覆盖"问题。直接贴报错让模型修是对的,但如果能同时说一句"你在追加代码时没有删除原有的 default export",模型定位问题会快一个数量级。
tip

第 6 轮提示词

为什么我打开网页,没有第一时间出现这个按钮,反而是刷新一下就有了?

  • 我的点评:新的 BUG 出现了。
  • Gemini3点评:这是一个非常深刻的单页应用(SPA)生命周期问题。这种观察力是区分小白与基础用户的关键:小白只会说“不行”,而基础用户能发现“路由切换不触发,刷新才触发”的规律。
  • GPT5.3点评:优秀的问题类型。开始关注生命周期与 hydration,而非表面功能。
  • Opus 4.6(当事人)点评:这个问题描述非常精准——"首次加载没有,刷新后有"直接指向了客户端渲染时机的问题(大概率是 useEffect + querySelector 在 SPA 路由切换时没有重新执行)。好的 bug 描述就是最好的提示词,这一轮做得很到位。
tip

第 7 轮提示词

改成「不用 querySelector,直接在 Docusaurus 的文档布局里声明式渲染」

  • 我的点评:最终结论是:这个阶段并没有在 7 轮对话内完成项目。
  • Gemini3点评:这是一个极其关键的转折。用户意识到“命令式(DOM操作)”走不通,开始要求转向“声明式(React组件注入)”。可惜 7 次额度已满,否则这一步将是通往成功的曙光。
  • GPT5.3点评:这是关键跃迁:从 DOM 操作 → 框架声明式扩展。说明用户开始主导架构,而不是让模型主导。
  • Opus 4.6(当事人)点评:这条提示词本质上是在第 7 轮推翻了第 2 轮的整体架构——从命令式注入转向声明式渲染。方向完全正确,这正是 Docusaurus 的正统做法(swizzle 主题组件)。遗憾的是来得太晚了:如果这条出现在第 2 或第 3 轮,整个基础阶段大概率能在 7 轮内完成。这再次印证了一个规律:越早纠正架构方向,越省后续的调试轮次。

熟悉

作为熟悉 Docusaurus 的用户,我最终的提示词是:

tip

第 1 轮提示词

Docusaurus 通常把一些组件/插件隐式运行,但用户可以在 swizzle(导出)后进行替换。这段话在官方文档哪里?

  • 我的点评:引导注意力机制到合适的技术方案上。
  • Gemini3点评降维打击。 专家级用户不直接要代码,而是先确认“底层规则”。通过 Swizzle(Docusaurus 的核心机制)来切入,这直接排除了前两个阶段中所有“暴力操作 DOM”产生的无效路径。
  • GPT5.3点评:优秀的“注意力校准提示词”。不是解决问题,而是先定位正确知识源。
  • Opus 4.6(当事人)点评:高手开局。不是直接说"帮我加按钮",而是先让模型定位到 swizzle 机制的官方文档。这一步的价值在于:(1) 确认模型对 swizzle 的理解是否正确;(2) 把模型的注意力锚定在 Docusaurus 的官方扩展范式上,避免它走弯路去搞 querySelector 注入。用一个"知识确认"问题来校准模型的认知基线,是非常老练的提示技巧。
tip

第 2 轮提示词

请你接管我的电脑,找到合适的组件,让我可以在博客标题旁渲染 "复制为 Markdown" 这个按钮。

  • 我的点评:再次引导注意力机制到合适的技术方案上,给出模糊的目标。
  • Gemini3点评:基于第一步确立的 Swizzle 共识,专家给出了极大的自由度。因为路径正确,此时的“模糊目标”反而能让 AI 精准找到 DocItem/HeaderBlogPostPage 等核心组件。
  • GPT5.3点评:高阶提示方式。目标明确但路径开放,使模型执行搜索与决策,而非生成代码。
  • Opus 4.6(当事人)点评:在第 1 轮已经锚定了 swizzle 范式后,这里故意给出模糊目标——"找到合适的组件"。这不是偷懒,而是策略:让模型自己去判断该 swizzle 哪个组件(DocItem/Layout?BlogPostPage?),这比用户硬指定一个可能错误的组件要安全得多。在正确的框架约束下给出模糊目标,是让模型发挥最大价值的甜蜜点。
tip

第 3 轮提示词

思考下,@custom.css (1719-1721) 这个样式会被应用到 @index.js (13-15) 的 title 吗?如果不会,如何调整最优雅?

  • 我的点评:精准给出 AI 能理解的样式异常。
  • Gemini3点评:这是最高级的反馈:“代码行级别的精准定位”。不再泛泛而谈“样式不对”,而是直接指出 CSS 选择器与 JS 组件的对应关系。这消除了 AI 的所有猜测空间。
  • GPT5.3点评:接近专家级提示词。特点:明确作用域 + 指定文件位置 + 明确假设验证
  • Opus 4.6(当事人)点评:教科书级别的提示词。三个亮点:(1) 用 @文件名 (行号) 精确引用代码位置,消除歧义;(2) 问题本身是一个可验证的 yes/no 判断("会不会被应用到"),模型容易给出准确答案;(3) 追问"如何调整最优雅"而不是"帮我修",给了模型设计空间。整个"熟悉"阶段只用了 3 轮就接近完成,和小白阶段的 7 轮未果形成鲜明对比。

体会

不得不说,Opus 4.6 还是相当强悍。模型是 本体放大器?:你越能提出结构化问题、越能判断中间结果,模型就越强。

  • 我的点评:这次实验非常真实地展示了“提示词质量决定上限,工程判断力决定下限”。如果把 AI 当成一个高水平但会犯错的搭档,而不是“全自动执行器”,效率会更高、返工会更少。2026 年,你依然值得深入学习编程。
  • Gemini3点评:完全赞同。小白在“求药”,基础用户在“开方”,而专家用户在“定规矩”。2026 年的编程,不再是手写每一行代码的体力活,而是从海量可能性中快速“剪枝”的判断力竞争。
  • GPT5.3点评:实验核心结论成立:模型不是替代工程能力,而是放大工程能力的乘数。用户水平越高,AI 从“代码生成器”越接近“协同架构师”。
  • Opus 4.6(当事人)点评:这篇博客最有价值的发现是:三个阶段使用的是同一个模型、同一个需求,但轮次消耗差异巨大(7轮失败 → 7轮未完成 → 3轮接近完成)。这说明在强模型时代,提示词的边际收益远高于模型能力的边际收益。小白阶段的核心问题不是"模型不够强",而是用户无法在中途判断方向对不对、该不该推翻重来。"本体放大器"这个比喻非常精准——模型放大的不只是知识,更是你的"元认知能力":知道自己不知道什么、知道什么时候该叫停、知道该把哪些决策权交给模型。2026 年学编程的意义,或许正在从"会写代码"转向"会驾驭写代码的 AI"。

题外话:我也试着让Qwen3.5-Plus点评,但是同样的提示词只点评全文,不点评每一次的提示词,国产模型依然任重道远。

· 11 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

跨域

想象一下,你曾经或者正登录着银行网站(bank.com),同时在另一个标签页打开了一个恶意网站(evil.com)

如果没有跨域限制,evil.com 里的 JS 脚本可以轻而易举地:

  • 读取你银行网站的 Cookie。
  • 冒充你向银行发送「转账」请求。
  • 读取你的私人账单数据。

这就是著名的 CSRF?(跨站请求伪造)数据泄露 风险。为了防止这种「大乱斗」,浏览器强制执行了 同源策略?(Same-Origin Policy)

虽然跨域有限制,但现代互联网需要资源共享(比如你的博客引用自己的云音乐平台的歌曲,通过脚本读取音频渲染出跳动的音符)。于是有了 CORS?(Cross-Origin Resource Sharing,跨源资源共享)

它本质上是服务器与浏览器之间的一次对话

  1. 浏览器问: 「嘿,服务器,我这有个来自 abc.com 的网页,想拿你的 MP3,准吗?」
  2. 服务器答(响应头): 「准了!我带上 Access-Control-Allow-Origin: https://abc.com。」

如果你是一个善良的免费平台,你也可以用*表示所有人都可以访问:Access-Control-Allow-Origin: *

  1. 浏览器看: 「嗯,名单上有它,放行!」

CORS 是白名单制度。服务器必须明确说出允许谁访问。

如果服务器没配 Header,或者缓存了没头的响应,浏览器会一直拒绝!

tip

浏览器双标行为

在 Web 早期,像 <img><video><audio><script> 标签都被设计为可以引用任何域名的资源。浏览器认为,既然你只是想「看」或者「听」,并不会把像素数据或音频二进制流通过 JS 拿走,那就没风险。这被称为无损读取。因此<audio src="xxx">(无属性):浏览器以「匿名」身份去拿。服务器返回什么它都播,它不在乎有没有 Access-Control-Allow-Origin

如果你想在网页上实现音频可视化(跳动的频谱)、音效滤镜或者点击下载:浏览器要求你必须通过 AudioContext 提取音频数据。此时必须用 fetch("xxx") 才能操作二进制数据。fetch("xxx"):浏览器以「跨域」身份去拿。如果服务器没给「通行证」,即使数据下载完了,浏览器也会在最后关头把数据拦下并报错。

如果你的代码之前通过 audio 标签在浏览器里缓存了一个音频(不带 Access-Control-Allow-Origin),之后又改为用 fetch 获取这个资源,浏览器会从缓存里取出无 CORS 头的旧响应提供给 fetch,并在控制台提示该资源被拦截。

建议: 给 audio 标签加上 crossOrigin="anonymous"。这样 audio 和 fetch 都会要求服务器返回 CORS 头。

为了避免相同资源被频繁读取,不只是浏览器,整个网络都充满了各种各样的缓存。

缓存链路

缓存层级存储位置常见缓存时长 (TTL)对 CORS 的影响缓存清除方式
浏览器强缓存用户本地磁盘/内存分钟级到年级(由 Cache-Control 决定)最致命。一旦存入不带 CORS 头的响应,后续请求不再询问服务器。强制刷新(Ctrl+F5)、手动清除浏览器缓存、或修改资源 URL(加版本号/hash)。
浏览器协商缓存用户本地即时校验(ETag / Last-Modified相对安全。每次都会向服务器确认文件是否变更,服务器在响应(包括 304)中有机会补发正确的 CORS 头。无需特殊清除,每次请求均会与服务器协商。
Nginx 反向代理缓存服务器内存/磁盘通常不主动缓存(除非配置了 proxy_cache若开启缓存且未配置 Vary: Origin,会将缓存的错误响应(无 CORS 头)发送给所有用户。清空 proxy_cache 目录、重载 Nginx,或配置 proxy_cache_bypass
CDN(如云存储/边缘节点)全球边缘节点小时级到天级(通常 24h)扩散性强。一旦边缘节点缓存了缺少 CORS 头的响应,该地区所有用户均会触发 CORS 报错。在 CDN 运营商后台手动刷新/预热缓存,或修改资源 URL 强制回源。
ISP / 运营商网关运营商机房不透明(几小时到几天)极少数情况下(通常仅限 HTTP 明文流量),运营商会劫持并缓存静态资源,HTTPS 下基本不受影响。基本无法主动清除,只能等待自然过期,或改用 HTTPS 规避。
后端框架内存缓存应用服务器内存秒级到小时级(由代码逻辑决定)若缓存了不含 CORS 头的响应对象并直接返回,后续请求将持续缺少 CORS 头,且对所有用户生效。重启应用服务、调用缓存失效接口(如 Redis DEL、Spring @CacheEvict),或等待 TTL 自然过期。

浏览器缓存

缓存条目的 Response Headers 在开发者工具里不会展示 Access-Control-Allow-Origin,给排查带来一定困难。

info

浏览器的逻辑顺序

  • 读取: JS 发起 fetch,浏览器发现磁盘缓存命中。
  • 校验: 浏览器取出 from disk cache 的原始响应头,内部检查是否包含 Access-Control-Allow-Origin
  • 通过: 检查通过,数据交给 JS。出于安全考虑,浏览器在 Network 面板中会省略展示部分安全相关头(如 CORS 头)。
  • 展示: 当你在开发者工具(Network 选项卡)点开该请求时,看到的是浏览器认为对当前调试有意义的头信息。

chrome://net-export/ 是 Chrome 最强大的网络日志工具,可将所有网络行为(包括读取缓存的细节)记录到一个 JSON 文件中。

操作步骤:

  • 在地址栏输入 chrome://net-export/
  • 点击 "Start Logging to Disk",选择保存位置。
  • 回到你的博客页面刷新,重现那个 from disk cache 的请求。
  • 回到日志页面点击 "Stop Logging"。
  • 使用 NetLog Viewer 打开该 JSON。
  • 在日志中搜索 HTTP_CACHE_GET_HEADERS,即可看到缓存里所有原始的 Response Headers
tip

启发式缓存

HTTP 旨在尽可能多地缓存,因此即使没有给出 Cache-Control,如果满足某些条件,响应也会被存储和重用。这称为启发式缓存

HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Tue, 22 Feb 2022 22:22:22 GMT
Last-Modified: Tue, 22 Feb 2021 22:22:22 GMT

复用多长时间取决于实现,但规范建议存储后大约 10% 的时间。

本例中:约 0.1 年 = (Date − Last-Modified) × 10%。

浏览器分析

下面是一段 Chrome NetLog 的片段:

{"params":{"batch_id":0,"encryption_level":"ENCRYPTION_FORWARD_SECURE","packet_number":452,"sent_time_us":472946859030,"size":1250,"transmission_type":"NOT_RETRANSMISSION"},"phase":0,"source":{"id":19077,"start_time":"471676479","type":12},"time":"472946859","type":313},

{"phase":2,"source":{"id":27027,"start_time":"472946851","type":14},"time":"472946862","type":156},

{"params":{"cors_preflight_policy":"consider_preflight","is_revalidating":false,"request_headers":{"headers":["sec-ch-ua-platform: \"Windows\"","Accept-Encoding: identity;q=1, *;q=0","User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36","sec-ch-ua: \"Not:A-Brand\";v=\"99\", \"Google Chrome\";v=\"145\", \"Chromium\";v=\"145\"","Range: bytes=0-","sec-ch-ua-mobile: ?0","Accept: */*"],"line":"GET /static/summary/blog_2026_2_28_.mp3 HTTP/1.1\r\n"},"url":"https://chat.jiangyang.fun/static/summary/blog_2026_2_28_.mp3"},"phase":1,"source":{"id":27030,"start_time":"472946868","type":1},"time":"472946868","type":576},

[事件 27030] 媒体资源加载日志

  • 目标: AI 总结音频(blog_2026_2_28_.mp3)
  • 浏览器行为: 通过 HTTP/1.1 获取资源
  • 安全决策: 启用 CORS 策略检查(consider_preflight
  • 请求头特征: 带有 Range: bytes=0-(请求媒体流)、Chrome 145 的 User-Agent
  • 底层状态: 发送了编号为 452 的加密封包,大小 1.25KB,传输正常

另一条日志中的缓存 Key:

{"params":{"created":false,"key":"Range_1/0/_dk_https://jiangmiemie.com https://jiangmiemie.com https://chat.jiangyang.fun/static/summary/blog_2026_2_28_.mp3:2faabf977670a1:0"},"phase":1,"source":{"id":27032,"start_time":"472946870","type":14},"time":"472946870","type":156},

这个字符串是浏览器在磁盘上查找缓存的「身份证号」,由以下几部分组成:

  • Range_1/0/: 分段缓存,表示该音频曾被分段请求并缓存。
  • dkDouble Keying?): 双重键值。现代浏览器不再只按 URL 缓存,而是按「顶层站点 + 请求来源 + 资源 URL」组合缓存。
  • https://jiangmiemie.com: Top-level Site(顶层站点)。
  • https://jiangmiemie.com: Frame Origin(请求来源)。
  • https://chat.jiangyang.fun/static/...mp3: 资源真实 URL。

结论 A:缓存已经彻底「分区」了

由于 Key 里包含了 https://jiangmiemie.com,这个缓存条目是专门为你的博客页面生成的。即使你直接在地址栏访问该 MP3,浏览器会使用另一套 Key(不含来源域名)。跨域访问与直接访问对应两套独立的缓存条目;当前命中的是带 CORS 头的健康版本。

结论 B:created: false 意味着命中

"created": false 表示没有新建缓存条目,而是命中了已有条目,请求确实来自 disk cache。命中带 jiangmiemie.com 的 Key,说明该缓存项在写入时已通过跨域校验并记录了来源信息。

结论 C:双重验证通过

日志中的 2faabf977670a1 是该缓存条目的哈希指纹。只要该 ID 对应的资源能正常加载,说明其内部存储的 Access-Control-Allow-Origin 头是完整的。

未雨绸缪

若在商业项目中遇到类似问题:某资源未配置跨域,用户端首页大图裂开,CDN 已分发、用户浏览器也已缓存错误响应。如何紧急处理、避免 T0 事故?

思路:把「图片名称」和「真实地址」分离,例如配置里只存 "home_banner_url": "v1.png",真实 URL 由配置中心下发。

前端初始化时接入配置中心 SDK(例如 Nacos?),通过 WebSocket 或长轮询维持连接:

// configClient.js(伪代码)
import { ConfigClient } from 'nacos-sdk';

export const config = {
home_banner_url: "https://cdn.com/image.png"
};

client.subscribe({ dataId: 'app_config' }, (content) => {
const newConfig = JSON.parse(content);
config.home_banner_url = newConfig.home_banner_url;
store.dispatch({ type: 'UPDATE_CONFIG', payload: config });
});

组件不写死 src,而是从全局状态读取当前 URL:

// BannerComponent.jsx
import { useSelector } from 'react-redux';

const Banner = () => {
const bannerUrl = useSelector(state => state.config.home_banner_url);
return (
<div className="banner">
<img src={bannerUrl} alt="首页大图" />
</div>
);
};

场景: 早上 10:00,发现 image.png 因 CORS 缓存污染在全球范围裂开。

紧急修复(无需发版):

  1. 打开 Nacos 管理后台,将 home_banner_url.../image.png 改为 .../image.png?v=0304_fix,点击发布。
  2. 正在运行的前端通过 WebSocket 收到新配置,React 状态更新,imgsrc 变为新 URL。
  3. 浏览器将新 URL 视为新资源,绕过旧缓存,向 CDN 请求最新图片。
  4. 用户无感刷新,裂图恢复。

参考链接

· 12 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

学习

学习可以分为两个核心过程:记忆抽象

传统观点认为,记忆依赖重复:

  • 短时间内重复的内容越少、重复次数越多,记忆越牢固;
  • 反之,若一次塞入大量内容,即使重复,也难以深入记忆。

因此,人们常通过精简信息来提升记忆效率。例如,将"long double 在不同平台上的长度不一致"简化为一句结论,以减少认知负担。

新发现

但最近我发现:适当增加"易于吸收"的背景知识,反而能增强记忆效果

比如,了解以下背景:

1980年代初,Intel 推出了 x87 浮点协处理器(8087),采用 80 位(10 字节)扩展精度格式,显著提升了科学计算的精度。
Linux 和 GCC 选择充分利用这一硬件特性,将 long double 实现为 80 位;
而 Microsoft 在 Visual Studio 中则采取保守策略,兼容性优先。将 long double 等同于 64 位的 double

这段看似"额外"的信息,因其具有历史脉络、技术动机和对比逻辑,反而更容易被记住——甚至比孤立的结论记得更牢。

建立联系

这让我意识到:有效的记忆不仅依赖重复,更依赖"联系"

这种联系有两种关键形式:

向外连接(教学视角)

将新知识与学习者已有的知识网络通过故事性、情境化的方式连接起来。例如,用"Intel 为何设计 80 位?"这个问题,把抽象的数据类型与真实的技术演进挂钩。

关键在于:找到那个能触发共鸣的"锚点"——它可能是历史事件、生活类比,或一个引人好奇的矛盾。

向内连接(自我解释视角)

在尝试向他人解释前,先自问:

  • 我会问自己哪些问题?
  • 是否用陌生术语解释了另一个陌生术语?
  • 小学生能听懂吗?
  • 能否换一种更直观的说法?

这个过程迫使你反复梳理逻辑、提炼本质,本质上是在重复中完成抽象,在抽象中强化记忆。

正反馈机制

学习陷阱

但仅有联系还不够:学习必须有明确的正反馈机制

就像游戏通过音效、金币、等级等即时、直观的反馈激励玩家持续行动,学习也需要清晰的"进度信号"和"成功标志"。否则,极易陷入两种陷阱:

  • 过度发散:例如初学 C 语言时,执着于深挖 printf 的源码实现,而忽略了基础语法体系的完整性;
  • 方向迷失:没有阶段性目标,不知道"学到什么程度算掌握",导致焦虑或低效重复。

因此,好的学习规划必须包含可衡量的标记

  • 好的标记

    • "本段需全文背诵"(可自我检测)
    • "能独立写出一个使用 long double 的示例程序"(行为可验证)
    • "能向同学解释为什么 GCC 和 MSVC 对 long double 处理不同"(输出即检验)
  • 模糊的标记

    • "重要知识点"(重要到什么程度?怎么验证?)
    • "理解即可"("理解"是主观感受,容易自我欺骗)

这些明确的反馈节点,不仅提供成就感,还能划定学习边界——知道何时该深入,何时该暂停、转向下一模块。

高效学习

学习公式

高效学习 = 联系 × 重复 × 反馈

真正的学习不是被动重复,也不是无边界的探索,而是在建立联系的过程中,通过有反馈的重复与抽象,逐步构建可验证、可迁移的知识网络

技术

𝝅ython

现在的Python 就像是这几年的英雄联盟,你想新手入门到大师,需要了解上百个英雄和技能和游戏机制。

学习感悟

前期是非常痛苦的,但是一旦上手你就会想:好久没有新英雄了。多出几个吧。

Python 的基础已经很久没有新鲜玩意儿了,我希望多出几个。

重做对新人也很友好,新选手永远有机会,假设当某一个特性被重做了,那么我之前的所有的熟练度都消失了,新人和老人站在同一跑线上。

面向服务器管理环境

环境管理误区

相当长一段时间,我被教如何给windows配置环境变量,也在这样教别人。

如果你只是一个初学者,或者你本身不是编程人员只在找"他山石"。那么这样做是没有问题的。

但如果要真正的从事生产开发的话,你的所有的环境管理一定是面向服务器的。服务器很少是windows环境,大部分是Linux环境,你所学习的内容无法帮助你解决工作的环境管理问题。

所以首先我要反思的就是我花了太多时间在windows的环境管理和设计上。所有的工作都应该基于Linux,面向服务器

过早设计项目

但现在回过头来看,当年设计的项目其实都非常的愚蠢,就是你在学习了一些基本的代码知识之后,然后你用它去实现完成的项目往往包含着:

  • 大量的重复;
  • 没有做到一处修改,处处生效;
  • 非常长的if else;
  • 相同函数的复制粘贴。

随着年纪的增长,你对项目的结构会有相当大的变化。

争议讨论

这里也许有人有争议,心想如果你之前不做那些愚蠢的项目,你后面怎么能够量变引起质变呢?

当然有可能在代码的一次又一次重构过程中堆积的能力是一次又一次的提升。会对设计模式的理解更加的深刻。

所以这里是一个保留的争议项。

专注

许多有价值的项目都有着大量的成熟产品的替代,在发展过程当中也有可能有别人会来抢你的饭碗,应该坚持找一个方向持续不停的把它做下去。即便大厂和你相似,你也能够聚焦某一个细分领域,把它做得出彩。

项目理念

项目不在于多,而在于精细、深入。

很多人在大学期间就开始尝试构建自己的影响力,比如说做自媒体或者是做技术科普或者做产品,那么我的方法是写博客,那这种影响力其实相对比较低的。他也和我自身的性格有关,我认为我只有在某一个领域走得非常深入,我才有资格向别人传授一些东西。

现在看来,其实你并不用做到像Python核心开发者那样才可以去教一些Python内容,你只要把某一个基础的东西讲的很清楚,你就可以去做自己的影响力内容,比如说很简单的列表。你可以用动画的方式把它呈现出来,你可能没有那么专业,但特别好懂。

低代码工作流平台

所有的数据通信都是通过网络来完成的,也就是一个再简单的图片拼接,这样用opencv几行代码就能完成的工作,你也需要配置一台服务器,然后给他写上各种参数,无疑是增加了一些成本的。

有一定成本的免费服务往往不可靠,在低代码平台当中想要编写一个健壮的工作流,需要额外增加许多节点。 而一旦有一个节点失效,他就会整个工作流崩溃。

低代码工作流不可靠,玩一下可以。别当真了。

风险

谁承担风险,谁做主要决策。避免不对称的风险,是我们的必修课。如果你承担大部分风险,但是仅获取少部分利润。

例如公司希望你调整自己的职业发展(你承担大部分风险),但是利润并没有额外分配给你,这就是风险不匹配。

世界是熵增的

加速的变化,混沌的信息 如果不加干预,不施改变,熵增不停。

如何干预?设置信息过滤系统。

  • 1.可信度(屏蔽不加核实仅转载的媒体)
  • 2.传播度(关注跨行业出圈的信息)
  • 3.相关度(聚焦自身领域的信息,实时整理,确保知识成体系)

对信息要使用自主筛选机制,即:我需要什么,而不是使用视屏流或被动接受信息推送

生活

南方装修建议

入户设计

入户一侧是洗手间、浴室,方便放伞、宠物淋雨后及时洗澡、客人借用厕所。 内置伞架,脏衣篮

入户另一侧是鞋架,换鞋凳

再进一步便是客餐厅

厨房设计

封闭式回型厨房适用于中餐加工+岛台(可以用作餐桌+桌游桌),工具使用排排挂钩。

客厅设有电脑连接大屏电视,供游戏娱乐。

客厅、卧室外通阳台。大阳台方便晾晒大量衣物。

AI眼镜的一些坏处

使用限制

进一些不让携带电子设备的场所例如:考场、涉密单位可能会有一些隐患

下雨的时候会担心它烧坏

耳朵不舒服,需要准备两种眼镜腿

普通话

我每天都在说普通话,应该考个证书证明,查了一下才发现,普通话一级的通过率不到2%。

于是我找了一个一级甲等的老师,每次1个小时的课程,加课后3个小时的练习。通过5次授课提升到了普通话一级。可以满足省级播音员及出镜记者的要求了,以后参与线下活动也更有话语权了。

旅行

湛江

在一个记不清的小吃街里,出于好奇买了一个炸虾饼,饼又硬又嚼不动,虾更是毫无新鲜可言,于是我得出了虾饼很难吃的结论。

上周去了趟湛江,在一家舌尖上的中国打卡店铺里吃海鲜面,同事发现有虾饼可以卖,想尝一下。老板从桶里抓出三只鲜虾,用特别的器皿混着面糊在油锅中晃荡。随后沥油切块。面饼酥脆,虾仁Q弹鲜香。

美食感悟

原来,难吃的不是虾饼。

澳门

口岸到机场有免费的往返接驳车,叫"发财车"。一到MGM就看到了一辆劳斯莱斯小金人。Peoebe在车上很害怕,一路上问我会不会拉到赌场里就不让出来了,我说这里又不是东南亚。

进去赌场,phoebe 被要求查验身份,一问知道如果年纪太小不让进。这里赌博的地方叫娱乐区。连成片的赌场穷奢极欲。这些娱乐场不仅提供西式博彩,还融合酒店、购物、表演等元素,大量的艺术装置随处摆放。

赌场外连着其他赌场,连成一片的澳门威尼斯人、巴黎人、伦敦人建了对应城市的模拟地标:大本钟、埃菲尔铁塔、水道。

观察发现

赌博的人有帅哥美女,有老头大叔。并不都是想象中肥头大耳的暴发户。服务人员也并不都是美女荷官,也有大爷大妈。

去的第一个赌场有个机器最低3.8币一局,威尼斯人最低20块,涉及到真人发牌的最少1000块一局,多的3000。赌场里有免费的软饮,不同的赌场有不同的香水味,非常浓厚。

官也街的手信店很大方,试吃都是一大块一大块的。

即便不博彩,也是很好的购物采风的好地方。

香港

香港随处可见东南亚、欧美国家的游客,比例要比北上广深更多,这次没备现金,不过全程主要消费区都支持微信支付宝。

由于刚下车时乘务员听不懂普通话,所以后面问路啥的都是优先说英语。路边还能看到外国人摆摊儿,外国人开的便利店,真有出国的感觉了。有些店似乎看出来我们是大陆游客(这么明显吗),也会主动说普通话。

城市观察

路上很热也很晒,但是当地人似乎刻意美黑,少有人打伞。路上很干净,我刻意数了一下,一个商品市场只有2个垃圾。

这里吸烟、乱扔垃圾罚款都很高。最低工资7400港币,公交车吸烟罚款5000港币。等价于北上广深吸烟罚款1500+

两人一日游花费

项目费用备注
高铁(往返2人)300人民币直接到市中心
小巴5×2港币划算
香港故宫门票136港币划算
地铁10×2+5×2港币
天星小轮6.5×2港币
二层公交(往返2人)44港币中环到赤柱广场海边
水溶C15港币
果粒橙11港币
牛角包18港币
肯德基58港币实惠
富豪雪糕13港币还好
云南米线62港币好吃
总花费

以上410港币+300人民币,约677人民币

· 4 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

加速

info

本文仅仅探讨AI编程这个领域,现在的AI往往代指含有图像视频生成的多模态AGI。

2021年,我第一次接触AI编程:GitHub Copilot,当时AI的能力主要体现为:输入注释或部分代码,即可生成整段优质的代码。

2025年,氛围编程让更多的人可以在不了解细节的情况下完成项目的搭建。

4年过去了,给我感觉是:AI加速了整个社会的差距

  • 在学校,优秀学生与垫底学生的效率相较从前更大了。

之前差生至少需要通过搜索引擎查找资料,再整理加工才能完成作业。而现在只要问题描述足够具体那差生只需要两次复制粘贴就能完成作业,差生提交完的第二天,都复述不出作业的要求。

  • 在公司,2个初级程序员的工资约20k,反而不如聘请一个15k的中级程序员配合成本不到1k的AI助手

AI能力线以下的工作岗位将更加稀缺。这意味着毕业生需要在某个领域达到超越AI的水平。这种要求客观上进一步加速了学习周期的延长,把普本计科学生推向继续教育。

思维

那为什么AI能完成的事情还要去学习呢?

我最近很喜欢出一些最潮流的技术项目,其一是较为有趣,其二是更能锻炼学生思维。

学习的过程大脑神经元应该是活跃的,如果很顺利的吸收了某个知识,那么大概率很快就会忘记。

如果通过发问、追问、试错、反思、总结,曲折的吸收了某个知识,神经元被激活的更加充分,理解也会更强。

如果你直接AI生成了答案,记忆效果会大大降低,只用AI生成你不想记忆的内容

因此,给自己试错的时间,给大家试错的时间。

我们人类的学习是有结构的,要想会 AI 不能完成的事,要先学那部分 AI 也能完成的事

AI不是领导

AI会有一些愚蠢的低级错误,例如在cmd命令行中,这个命令报错:

PS C:\Users\allen> bcdedit /delete /f {f7f65faa-5515-11ef-b3e2-d8359383915d}

指定的删除命令无效。

运行"bcdedit /?"获取命令行帮助。

参数不正确。

即使是当前最先进的AI模型也无法正确解答,例如claude-4.5-sonnet-thinkingdeepseek-r1gorkgpt5-high

但如果你系统学习过终端命令,很快就能意识到问题所在:{}在PowerShell中是特殊字符(表示脚本块),需要加上双引号:

bcdedit /delete /f "{f7f65faa-5515-11ef-b3e2-d8359383915d}"

和公司新来的实习的大学生一样,有一腔热情,部分也愿意学习,但是往往没有系统的学习缺少DEBUG的直觉。

人应该系统的学习相关的知识,不要通过AI编程补充所有的知识性的细节,不要过度依赖其DEBUG能力,当一次无法成功时,后续追问的成功率会大打折扣,立刻人工接管。

推荐调试流程:

  1. 理解问题:翻译错误信息+自主思考
  2. 验证思路:与AI交流你的分析

他人的想法

在部分领导和自媒体眼中,AI近乎无所不能,认为有了AI加持就像拥有神笔的马良。这种认知会导致他们分配超出实际负荷的工作量。

为了赶工期,你不得不变本加厉地使用AI,陷入无暇系统思考的恶性循环

和一个赌徒不停的拉动老虎机的拉杆一样,你输入提示词然后等待,期待下次AI给出答案是终极大奖。

我们都知道赌徒的下场。

· 5 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

好久不见,今天分享一下我的网站是如何做智能DNS解析的案例。

现在看到这个网站被部署在一个 GitHub 仓库,我域名解析指向了 GitHub,因此可以通过域名直接访问这个网站。

但是目前在中国大陆访问比较慢,于是我采购了一台中国大陆的服务器。目标:

  • 在大陆的 IP 地址访问,解析到大陆服务器。
  • 在境外访问,解析到 GitHub。

项目原本的增量更新逻辑不变:即通过 GitHub Actions,每次变更自动更新不一致的文件。

实现这个需求的关键在于使用智能DNS解析,也就是根据访问者的地理位置将域名解析到不同的 IP 地址。

域名解析修改

在阿里云服务选择云解析 DNS。相关配置如下:

主机记录记录类型解析线路记录值
wwwCNAME境外你的Github用户ID.github.io
@CNAME境外你的Github用户ID.github.io
wwwA中国地区我的服务器IP
@A中国地区我的服务器IP

服务器配置

在服务器上,需要部署一个Web服务器(如 Nginx 或 Apache),并配置它来服务项目静态文件。大部分服务器都是开箱即有。我使用的是宝塔面板,添加PHP项目,简单的配置了一下域名和SSL证书。找到项目指向的文件夹/www/wwwroot/jiangmiemie.com,后续将 GitHub 生成的静态资源传输到这里即可。

如何将项目文件同步到这台服务器上。这里计划使用rsync增量同步,使用ssh链接。

rsync

rsyncscp 更适合同步大量文件,因为它可以只传输有变化的部分。

我的这台服务器是阿里云系统(不是 Ubuntu),需要使用命令:yum install -y rsync进行安装。

ssh

需要在防火墙中打开的端口:2280443

info

虽然我们可以直接通过账号密码登录 ssh,但是账号密码权限太高,因此设置一个仅用于部署的 ssh 密钥。

# 生成密钥对(在服务器上)
ssh-keygen -t rsa -b 4096 -f ~/.ssh/github_deploy_key -N ""

# 查看生成的文件
ls -la ~/.ssh/
# 会看到:
# github_deploy_key (私钥)
# github_deploy_key.pub (公钥)

# 将公钥添加到authorized_keys
cat ~/.ssh/github_deploy_key.pub >> ~/.ssh/authorized_keys

# 设置正确权限(过低没有权限,过高ssh会认为文件不安全)
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chmod 600 ~/.ssh/github_deploy_key

# 测试密钥是否工作(在服务器上)应该能直接连接,不需要密码
ssh -i ~/.ssh/github_deploy_key root@localhost

# 显示私钥内容(复制到GitHub Secrets)
cat ~/.ssh/github_deploy_key

GitHub Workflow 修改

完整配置如下,主要变更内容为Setup SSHDeploy to server

因为GitHub Actions是所有人都能看到的,因此为了安全,你需要把服务器 IP 地址、连接信息等配置为密钥变量。下面代码中${{ secrets.SSH_PRIVATE_KEY }}这样格式的都是密钥变量。

把你的 IP 地址和刚刚终端显示的私钥复制到 GitHub Secrets。

info

在你的 GitHub 仓库页面,进入 Settings -> Secrets and variables -> Actions

点击 New repository secret

Name 填写一个有意义的名称,比如我设置的是SSH_PRIVATE_KEYIP

  • IP的 Secret 框中,仅粘贴你的 IP 地址。例如:192.168.10.11

  • SSH_PRIVATE_KEY的 Secret 框中,粘贴从-----BEGIN OPENSSH PRIVATE KEY----------END OPENSSH PRIVATE KEY-----完整内容。

name: Build HTML
on: push
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: |
npm install
npm run build
- uses: peaceiris/actions-gh-pages@v3
if: github.ref == 'refs/heads/main'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./build
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

- name: Deploy to server
run: |
mkdir -p ~/.ssh
ssh-keyscan -H ${{ secrets.IP }} >> ~/.ssh/known_hosts
rsync -avz --exclude='.user.ini' ./build/ root@${{ secrets.IP }}:/www/wwwroot/jiangmiemie.com
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SERVER_IP: ${{ secrets.IP }}
  • ssh-keyscan -H ${{ secrets.IP }} >> ~/.ssh/known_hosts

表示将服务器的公钥添加到 known_hosts 文件中。

  • rsync -avz --exclude='.user.ini' ./build/ root@${{ secrets.IP }}:/www/wwwroot/jiangmiemie.com

表示将./build/文件夹同步到/www/wwwroot/jiangmiemie.com文件夹。

参数作用详细说明
rsync远程同步工具比 scp 更智能,只传输变化的部分
-a归档模式保留文件权限、时间戳、符号链接等所有属性
-v详细输出显示传输过程的详细信息
-z压缩传输传输时压缩数据,减少网络带宽
--exclude='.user.ini'排除文件跳过服务器自动生成的系统文件
./build/源目录本地构建好的静态文件目录
root@IP:/path/目标地址服务器上的部署目录

每次你推送代码到 GitHub,工作流会自动构建并将变化同步到服务器,一次修改,处处生效!

· 45 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

本章节通过 ROS2 官方提供的 Gazebo 虚拟仿真,实现视觉运动算法仿真。

info

什么是视觉运动控制算法?

运动控制算法主要是通过传感器,将采集到的数据,转化为运动控制指令。

传感器包含:视觉、重力、力反馈、加速度、测距等多种类型。特斯拉的自动驾驶使用的方案中视觉是最主要的信息来源。

运动控制从易到难有:履带结构、差速结构、阿克曼结构、全向结构、足腿结构。

info

为什么要虚拟仿真?

目前较为先进的腿足结构机器人想要完成高难度动作,例如:后空翻、前空翻、韦伯斯特空翻。一旦失败会对本不充裕的原型机造成严重的损坏。

所以通过仿真软件训练,有如下好处:

  • 可以在低风险的情况下完成算法的调试和优化。
  • 对于复杂算法,可以同时启动多个仿真软件,进行并行训练,提高训练效率。
warning

虚拟仿真需要一定的算力。本地使用前,首先确保你的电脑包含显卡。

  • ✔推荐使用原生Ubuntu最大限度发挥硬件性能。非原生环境下可能出现消息异常、时间异常等问题。

  • 高算力的电脑可以使用wsl安装Ubuntu。

  • ❌使用VM虚拟机会产生较大的延迟。

info

用机器学习中的概念来类比,Gazebo 好比是 PyTorch,ROS2 好比是 Python。

你希望使用 PyTorch 开始深度学习,所以要查看最新版 PyTorch 适配的 Python 版本(3.10+),需要英伟达显卡才能加速,再查看 Python 支持的操作系统(Mac、Linux、Windows)。

搞清楚之后,再从系统开始,安装 Python,最后 PyTorch 和显卡驱动安装成功。谋定而后动。

ROS2框架

ROS 2 需要依赖于 Ubuntu 系统,其微控制器相较于普通 MCU 成本更高。主要用于高级机器人。

通信优势

丰富的通信类型

ROS 2 节省自己搭建网络、定义通信类型、管理连接的时间。

通信模式作用特点应用场景
话题:发布-订阅 (Publish-Subscribe)单向、异步数据流高效、解耦,适用于传感器数据流、状态更新传感器数据流(如相机图像、激光雷达数据)、机器人状态更新(如位置、速度)
服务:服务端-客户端 (Service-Client)同步、请求-响应阻塞式通信,用于需要明确结果的任务触发特定动作(如抓取物体)、查询物体 ID
动作 (Actions)长时任务处理异步、支持进度反馈和取消移动机器人到指定位置、机械臂执行复杂任务
参数 (Parameters)动态配置节点行为非实时通信,用于启动时或运行时的配置调整调整 PID 控制器增益、修改传感器发布频率

自动发现机制

同为 ROS 2,在同一网段下的不同设备可以自动发现

info

自动发现前提:指要求中继设备允许子设备自由通信。

中继设备可能是路由器、或者手机的热点、随身WIFI等。

允许子设备自由通信有利有弊,如果你在商场或者咖啡馆连接他们的WIFI,你的电脑可能提醒你:你的网络活动可能被其他设备发现。自由通信会带来一定的安全隐患。因此并不是所有的路由器都默认允许允许子设备自由通信。

有的设备会有防火墙,临时关闭防火墙:sudo ufw disable,另外要允许UDP多播端口(默认7400~7500)。

如果一切正常,你应该可以通过 ping <其他机器的 IP> 并联通成功。

此时你可以通过 ros2 运行节点发布消息,其他设备的接收者会收到你的消息。

隔离机制

如果你同个网段下,有无人机和机器狗。你想将其编队为:空军 + 陆军;又或者 5 台无人机 + 机器狗为一组。

ROS 2 提供了通信隔离机制

在同个编队的所有设备终端中执行:export ROS_DOMAIN_ID=1。(值范围为 0~101,0 为默认值)

这个命令是设置了一个环境变量。ROS 2 通过判断这个环境变量来识别是否为一组。不同 ID 节点无法通信。

因此如果你想将其分为两组,只需要在一半的设备上改为非 0 的,相同的 ID 即可。

多设备跨域通信

如果你的设备分别在两个城市,可以通过配置一台公网下的服务器(也可以在两个网段下,配置一个都能访问的设备作为中继),将两个城市的设备连接到这台服务器上。这种通信方式被称为Fast-DDS

在服务器上运行:fastdds discovery --server-id 0

服务器开放11888端口。sudo ufw allow 11888/tcp

在设备上运行:export ROS_DISCOVERY_SERVERS=<服务器IP>:<服务器端口>。(端口默认11888)

设置ROS中间件为rmw_fastrtps_cpp(别人写好的包):export RMW_IMPLEMENTATION=rmw_fastrtps_cpp 然后重新监听:ros2 daemon stop,再重新启动:ros2 daemon start

模块仓库

ROS 2 有大量写好的机器人相关的包,包也称:模块、功能包、库。

安装相机标定: sudo apt install ros-jazzy-camera-calibration

安装 SLAM: sudo apt install ros-jazzy-slam-toolbox

你会发现你只需要安装一个包,就可以获得一个功能。和 Python 通过 pip 安装包一样。安装后即可实现"一行代码"实现某个功能。

ROS 2安装

安装过程中注意:ROS 2 的版本往往与 Ubuntu 的版本有关。查看 https://docs.ros.org/ 可获得目前主流的版本。

设置编码

sudo apt update && sudo apt install locales
sudo locale-gen en_US en_US.UTF-8
sudo update-locale LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8
export LANG=en_US.UTF-8

添加源

sudo apt update && sudo apt install curl gnupg lsb-release 
sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key -o /usr/share/keyrings/ros-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu $(source /etc/os-release && echo $UBUNTU_CODENAME) main" | sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null

安装ROS 2

sudo apt update

sudo apt upgrade

# 如果你是 Ubuntu 24.04,那么安装 Jazzy 版本
sudo apt install ros-jazzy-desktop

设置环境变量

# 立刻执行脚本
source /opt/ros/jazzy/setup.bash

# 之后每次开启终端执行执行脚本
echo " source /opt/ros/jazzy/setup.bash" >> ~/.bashrc

你也可以使用Docker简化这个过程。

复杂的功能需要很多依赖,系统中默认的 Python3 出于安全考虑,可能不让你在全局环境中安装包。代码的提示给了很多选项,例如安装在虚拟环境中,删除安全文件。加上关键字,你应该学会通过翻译软件,翻译报错信息。它让你看某个文件,也请学会使用翻译软件,翻译文件内容。

error: externally-managed-environment

× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
python3-xyz, where xyz is the package you are trying to
install.

If you wish to install a non-Debian-packaged Python package,
create a virtual environment using python3 -m venv path/to/venv.
Then use path/to/venv/bin/python and path/to/venv/bin/pip. Make
sure you have python3-full installed.

If you wish to install a non-Debian packaged Python application,
it may be easiest to use pipx install xyz, which will manage a
virtual environment for you. Make sure you have pipx installed.

See /usr/share/doc/python3.12/README.venv for more information.

note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages.
hint: See PEP 668 for the detailed specification.

根据提示,得到的解决方式之一:pip3 install 你的包 --break-system-packages

或者可以把externally managed文件删除。

sudo apt install -y python3-pip

sudo pip3 install rosdepc --break-system-packages

sudo rosdepc init

rosdepc update

节点基操

节点是通信的基本单位,节点可以变成:发布者、订阅者、服务端、客户端、动作、参数。

创建节点

创建节点前可以先创建一个目录

mkdir -p ~/workspace/src

如何在 ROS2 中创建一个功能包呢?我们可以使用这个指令:

ros2 pkg create --build-type <build-type> <package_name>

ros2 命令中:

  • pkg:表示功能包相关的功能;
  • create:表示创建功能包;
  • build-type:表示新创建的功能包是 C++ 还是 Python 的,如果使用 C++ 或者 C,那这里就跟 ament_cmake,如果使用 Python,就跟 ament_python;
  • package_name:新建功能包的名字。
# 创建 Python 功能包
ros2 pkg create --build-type ament_python learning_node

# 创建 C++ 功能包
ros2 pkg create --build-type ament_cmake learning_node

只需执行其中一个命令即可,如果你会 Python,就执行创建 Python 节点的命令。命令创建一个名为 learning_node 文件夹。

这里我推荐大家创建节点名的时候,使用有辨识性的名字。例如:ros2 pkg create --build-type ament_python learning_node

info

注意:对于节点同名,ROS 2 在编译时的处理方式不是使用第一个节点或最后一个节点,而是会报错。

编写代码

接着在learning_node文件夹我们就可以准备编写代码了,此时你的路径应该是:

~/workspace
|_src
|_learning_node/
|_learning_node
|_ __init__.py
|_package.xml
|_setup.py
|_setup.cfg
|_test/
|_resource/

此时你就可以编写代码了,在 __init__.py 同级下创建一个 .py 文件。

符合文件命名规则前提下,你想叫什么都可以,但是最好有一定的含义。

如果你的功能是打印 Hello World,那么你就可以叫 node_helloworld.py

Python 的函数通常是需要调用的,例如 abc() 函数,你需要在代码中输入 abc(),才会调用这个函数。和 C++ 有且仅有一个 main 函数不同,那么如何确定哪个函数是入口?更进一步思考:不同的指令对应不同的函数。

因此有了 setup.py 最下方的入口点定义。编写完成后记得修改 setup.py 最下方的入口点。

详细内容参可以参考现成的仓库:

info

拉取 Git 仓库,可免去自己从 0 开始手敲代码。如果你喜欢自己从 0 开始抄写代码,也可以跳过本部分。

Git 安装:sudo apt install git

拉取命令:git clone https://gitee.com/guyuehome/ros2_21_tutorials.git

这个命令会把仓库克隆到本地。执行后,你应该能在当前文件夹下看到名为 ros2_21_tutorials 的文件夹,内含多个子文件夹。每个子文件夹对应一个功能包。因为 ROS2 是模块化的,不同的功能被拆解为不同的功能包后期更好维护。

注意:如果你之前在 src 里创建了一些 Python 节点之后,又把 ros2_21_tutorials 文件夹也放置在 src 里,那么你需要检查你的创建的节点名,是否与 ros2_21_tutorials 内已有节点重名。

编译

# 安装 colcon(此步全局仅在第一次执行时需要执行,如果失败会导致 colcon build 命令不可用)
sudo apt install python3-colcon-ros

# 进入工作空间
cd ~/workspace

# 编译工作空间所有功能包
# 不论其是直接在src下,还是在src下的子文件夹下
colcon build

# 当你只有一个包更改,不希望全编译,仅编译learning_node
colcon build --packages-select learning_node

colcon build 命令会将所有功能包,编译成可执行文件。

编译成功后,就可以在工作空间中看到自动生产的build、log、install文件夹了。

  • src,代码空间,未来编写的代码、脚本,都需要人为的放置到这里;
  • build,编译空间,保存编译过程中产生的中间文件;
  • install,安装空间,放置编译得到的可执行文件和脚本;
  • log,日志空间,编译和运行过程中,保存各种警告、错误、信息等日志。

我们把编译后,含有build、log、install、src文件夹的workspace称为工作空间

如果是 Python 这样无需编译的,会将代码直接拷贝到 install 文件夹下。

编译过程可能出现的报错:

  • 节点重名:对于节点同名,ROS 的处理方式不是使用第一个节点或最后一个节点,而是会报错。应该删除多余节点。确保你 src 文件夹下,所有的 xml 文件中,节点的名称是唯一的。

  • 语法错误:如果代码语法错误,则会编译失败,需要手动解决。

  • 缺少依赖包:如果你严格跟随参考资料且为新系统,首先检查你是否安装过程网络异常。如果是曾用系统,检查是否含有多个 Python 环境。

# 删除缓存
rm -rf build/ install/ log/

# 修改环境变量PATH的值,把系统默认的环境提到最前
export PATH="/usr/bin:$PATH"

# PYTHON_EXECUTABLE 是一个 CMake 变量,用于指定构建过程中应该使用哪个 Python 解释器
export PYTHON_EXECUTABLE=/usr/bin/python3 && colcon build --cmake-args -DPYTHON_EXECUTABLE=/usr/bin/python3

在不更换工作空间的情况下,以下命令仅在第一次执行时需要执行。如果你的工作空间路径不是~/workspace,请将~/workspace替换为你的工作空间路径。

# 立刻执行脚本
source ~/workspace/install/setup.sh

# 之后每次开启终端执行执行脚本
echo "source ~/workspace/install/setup.sh" >> ~/.bashrc

运行

运行脚本:ros2 run learning_node node_helloworld

查看正在运行的 node:ros2 node list

rclpy

rclpy 模块,ROS 2 Python 标准接口(ROS 2 canonical API with Python)

官网:https://docs.ros.org/en/rolling/p/rclpy/index.html

受限于篇幅,只简单介绍其中初始化部分

入门体验

下面是一个标准的初始化代码 init 支持传入多种参数。

import rclpy                                     # ROS2 Python接口库
from rclpy.node import Node # ROS2 节点类
import time

def main(args=None): # ROS2节点主入口main函数
rclpy.init(args=args) # ROS2 Python接口初始化
node = Node("node_helloworld") # 创建ROS2节点对象并进行初始化

while rclpy.ok(): # ROS2系统是否正常运行
node.get_logger().info("Hello World") # ROS2日志输出
time.sleep(0.5) # 休眠控制循环时间
node.destroy_node() # 销毁节点对象
rclpy.shutdown() # 关闭ROS2 Python接口
初始化

rclpy.init(*, args: List[str] | None = None, context: Context | None = None, domain_id: int | None = None, signal_handler_options: rpyutils.import_c_library.SignalHandlerOptions | None = None)→ InitContextManager

直接初始化比上下文初始化拥有更多的参数。

Parameters:

  • args – 命令行参数列表。
  • context – 要初始化的上下文。如果为 None,则使用默认上下文。
  • domain_id – ROS 域 ID。
  • signal_handler_options – 指示要安装的信号处理程序。如果 None,则在初始化默认上下文时将安装 SIGINT 和 SIGTERM。【类似于 Python 的信号(signal)机制,是操作系统发出的异步事件。而非异常,异常是程序内部发生的同步事件。】

Returns:

  • 一个 InitContextManager,可以与 Python 上下文管理器一起使用进行清理。
循环执行

ROS 2 中的 Executor(执行器)是用来管理和运行 ROS 2 节点中所有回调函数的核心组件。你可以把它看作是一个调度器,它决定了节点里的各种任务(例如接收新消息、处理服务请求、定时器触发)何时执行。

Executor 负责调度和执行节点中的回调函数。一个 ROS 2 节点可以有多个回调函数,比如:

  • 订阅者的回调函数:每当收到一个新消息时被调用。

  • 服务的请求回调函数:每当收到一个服务请求时被调用。

  • 定时器的回调函数:每隔一定时间被调用。

节点本身并不会自动运行这些回调函数,它需要一个 Executor 来 "spin"(自旋),也就是不断地检查是否有就绪的任务并执行它们。ROS 2 将节点和执行器分开,提供了灵活性。你可以选择不同的 Executor 来适应你的应用需求:

  • SingleThreadedExecutor:这是最简单的执行器,它在一个单独的线程里顺序执行所有就绪的回调函数。

  • MultiThreadedExecutor:这个执行器使用一个线程池,可以同时执行多个回调函数,这对于需要高并发处理的节点非常有用。

rclpy.spin(node: Node, executor: Executor | None = None)→ None:执行工作,直到与执行器关联的上下文关闭。

  • node – 要添加到执行器以检查工作的节点。
  • 要使用的执行器,如果是 None,则为全局执行器。

rclpy.spin_once(node: Node, *, executor: Executor | None = None, timeout_sec: float | None = None)→ None:执行一项工作或等待超时到期。

只要该回调在超时到期之前准备就绪,提供的执行器就会执行一个回调。如果未提供执行器(即 None),则使用全局执行器。如果全局执行器具有部分完成的协程,则完成的工作可能是针对提供的节点以外的节点。

  • node – 要添加到执行器以检查工作的节点。
  • executor – 要使用的执行器,如果是 None,则为全局执行器。
  • timeout_sec – 等待几秒钟。如果为 “无” 或“负数,则永久阻止”。如果为 0,请不要等待。

rclpy.spin_until_future_complete(node: Node, future: Future[Any], executor: Executor | None = None, timeout_sec: float | None = None)→ None : 执行工作,直到Future完成。

  • node – 要添加到执行器以检查工作的节点。
  • 要等待的 future 对象。直到 future.done() 返回 True 或与执行器关联的上下文为 shutdown。
  • 要使用的执行器,如果是 None,则为全局执行器。
  • 等待几秒钟。如果为 None 或负数,则阻止直到未来完成。如果为 0,请不要等待。
关闭

rclpy.shutdown(*, context: Context | None = None, uninstall_handlers: bool | None = None)→ None

关闭以前初始化的上下文(如果尚未关闭)。这也将关闭全局执行器,如果已关闭会报错。

  • context – 要失效的上下文。如果为 None,则使用默认上下文
  • uninstall_handlers – 如果为 None,则在关闭默认上下文时将卸载信号处理程序。如果为 True,则将卸载信号处理程序。如果为 False,则不会卸载信号处理程序。和前面signal_handler_options对应上。

rclpy.try_shutdown(*, context: Context | None = None, uninstall_handlers: bool | None = None)→ None

关闭以前初始化的上下文(如果尚未关闭)。这也将关闭全局执行器,如果已关闭不会报错。

  • context – 要失效的上下文。如果为 None,则使用默认上下文
  • uninstall_handlers – 如果为 None,则在关闭默认上下文时将卸载信号处理程序。如果为 True,则将卸载信号处理程序。如果为 False,则不会卸载信号处理程序。和前面signal_handler_options对应上。

上下文

上下文管理器 Context 与直接使用 rclpy 基本一致,但背后其实是线程管理,每个独立的上下文管理器都是独立的线程。

如果使用 rclpy.init(args=args) 则无法完成创建 2 个不同的 domain_id 的节点。

from rclpy.node import Node                      # ROS2 节点类
from rclpy.context import Context # ROS2 上下文类
import time

def main(args=None): # ROS2节点主入口main函数
# 使用Context上下文管理器的正确方式
context = Context()
context.init(args,domain_id=1)
node = Node("node_helloworld", context=context) # 创建节点并关联上下文

context2 = Context()
context2.init(args,domain_id=2)
node2 = Node("node_helloworld", context=context2) # 创建节点并关联上下文

while context.ok() and context2.ok(): # 检查上下文是否正常
node.get_logger().info("{}".format(context.get_domain_id())) # ROS2日志输出
node2.get_logger().info("{}".format(context2.get_domain_id())) # ROS2日志输出
time.sleep(0.5) # 休眠控制循环时间
context2.shutdown()
context.shutdown()

使用上下文管理器 Context 的 with 语法可以自动 init(不支持参数),并在 with 语句结束时调用 shutdown,代码更加简洁。

类似于 Python 的 with openclose

from rclpy.node import Node                      # ROS2 节点类
from rclpy.context import Context # ROS2 上下文类
import time

def main(args=None): # ROS2节点主入口main函数
# 使用Context上下文管理器的正确方式
with Context() as context:
node = Node("node_helloworld", context=context) # 创建节点并关联上下文
while context.ok(): # 检查上下文是否正常
# 输出当前的domain id ,默认是0
# 修改domain id方式1 :修改当前终端变量
# export ROS_DOMAIN_ID=1
node.get_logger().info("{}".format(context.get_domain_id()))
time.sleep(0.5) # 休眠控制循环时间
初始化

context.init(args: List[str] | None = None, *, initialize_logging: bool = True, domain_id: int | None = None)→ None

为给定上下文初始化 ROS 通信。

  • args – 命令行参数列表。
  • initialize_logging – 是否初始化整个过程的日志记录。默认值是初始化日志记录。
  • domain_id – 要用于此上下文的域 ID。如果为无(默认值),则使用域 ID 0。
状态检察

context.get_domain_id()→ int:获取上下文的 domain ID。

context.ok()→ bool:检查上下文是否未关闭。

跟踪

track_node(node: Node)→ None:跟踪与此上下文关联的节点。当上下文被销毁时,它将销毁它跟踪的每个节点。

untrack_node(node: Node)→ None:如果一个节点在上下文之前被销毁,我们不再需要跟踪它是否销毁了上下文,所以在此处将其删除。

关闭上下文

context.shutdown()→ None:关闭此上下文,会等待正在进行的操作完成,然后清理资源。好比执行系统关机,如果已关闭会报错。

on_shutdown(callback: Callable[[], None])→ None:添加关机时调用的回调。

try_shutdown()→ None:尝试关闭此上下文(如果尚未关闭),如果已关闭也不会报错。

context.destroy()→ None:销毁上下文,立即释放资源,不等待正在进行的操作。好比直接断电。

通信示例

ROS2的基础通信类型的示例代码都在ros2_21_tutorials仓库中。拉取到src文件夹中,参考上文编译、运行即可。

话题

  • 运行话题的发布者节点 : ros2 run learning_topic topic_helloworld_pub
  • 运行话题的订阅者节点 : ros2 run learning_topic topic_helloworld_sub

可以看到发布者循环发布“Hello World”字符串消息,订阅者也以几乎同样的频率收到该话题的消息数据。

服务

  • 运行服务的服务端节点 : ros2 run learning_service service_adder_server
  • 运行服务的客户端节点 : ros2 run learning_service service_adder_client 2 3

动作

  • 运行动作的服务端节点 : ros2 run learning_action action_move_server
  • 运行动作的客户端节点 : ros2 run learning_action action_move_client

参数

等价于机器学习中的超参数即:在开始训练前设置的参数,例如:学习率、隐藏层大小、迭代次数等等。

ROS 机器人系统中的参数是全局字典,可以运行多个节点中共享数据。
  • 运行节点:ros2 run learning_parameter param_declare
  • 在另外终端设置参数:ros2 param set param_declare robot_name turtle

Gazebo

Gazebo 是一款开源的 3D 仿真软件,用于模拟机器人在复杂环境中的行为。它提供了丰富的物理引擎和传感器模型,可以模拟机器人的运动、碰撞、传感器数据等。

# 安装 gz
sudo apt-get install ros-jazzy-ros-gz

# 测试(参数 empty.sdf 表示空场景)
# 测试能否正常启动且无卡顿,gz_args:=empty.sdf 表示使用空场景
ros2 launch ros_gz_sim gz_sim.launch.py gz_args:=empty.sdf

# 我的 Gazebo 版本
"""
Gazebo Sim, version 8.9.0
Copyright (C) 2018 Open Source Robotics Foundation.
Released under the Apache 2.0 License.
"""

gazebosim 有大量下载好的模型(models)与场景(worlds)可以使用。

想简单点就选一个自带摄像头的机器人模型(model),如果有中意的机器人模型没摄像头的,可以自己加一个摄像头。

选赛道类的,有明显的车道线,可以较好的测试算法。如果场景过于简单可以适当的添加一些模型,例如:路灯、树木、箱子等。

从结构上来说,场景(world)包含 N 个模型(model),模型包含 M 个传感器(sensor)。

虚拟仿真的世界观和 Blender 的世界观类似,一个世界需要有物理引擎、光源、模型等等。首先我们需要简单了解下这样的世界观。

SDF 格式中世界可接收的子元素:http://sdformat.org/spec?ver=1.4&elem=world

SDF 格式中模型可接收的子元素:http://sdformat.org/spec?ver=1.4&elem=model

Gazebo 的传感器库:https://gazebosim.org/libs/sensors/

SDF 格式中传感器可接收的子元素http://sdformat.org/spec?ver=1.4&elem=sensor

这种结构化的文档可读性要比传统的纯文档观感好很多。

URDF

URDF(Unified Robot Description Format)是一种用于描述机器人模型的 XML 格式。

一个两轮机器人运动示例 官方教程

通过这个部分的学习,你可以学会如何操纵你的小车运动。

其中 pose 标签有 6 个参数,官方文档认为比较简单没讲,这里提一下:

  • 前三个参数表示三维空间坐标(取值范围为空间内即可)
  • 后三个参数表示绕某个轴的角度(控制物体面朝什么方向)
方向正值含义颜色标识
X 轴前后方向向前(North)🔴 红色
Y 轴左右方向向左(West)🟢 绿色
Z 轴上下方向向上🔵 蓝色
角度旋转方向常用值
Roll绕 X 轴旋转翻滚通常为 0
Pitch绕 Y 轴旋转俯仰通常为 0
Yaw绕 Z 轴旋转左右转头0, 1.57, 3.14, -1.57

Yaw 角度对照表

Yaw (弧度)角度朝向示意
0朝向 +X(前)
1.5790°朝向 +Y(左)
3.14180°朝向 -X(后)
-1.57-90°朝向 -Y(右)

传感器

基于不同的目的,我们会选择不同的传感器结合不同的算法来实现。

传感器应用场景优势劣势
激光雷达SLAM建图、避障精度高,不受光照影响价格贵,无颜色信息
摄像头目标识别、视觉SLAM成本低,信息丰富受光照影响,计算量大
深度相机室内SLAM、3D重建提供RGB+深度,实时性好仅室内可用,范围有限(5m)
IMU姿态估计、传感器融合高频输出,不依赖环境存在漂移,需融合其他传感器

机器人专业可以参考 官方文档教程,其中使用的是 IMU 传感器、接触式传感器和激光雷达传感器。

人工智能专业可以下载我设计的 简易地图

ros_gz_bridge

此部分代码来自官方文档 https://gazebosim.org/docs/latest/ros2_integration/

数据流向

  • 摄像头 → /camera 话题 → 你的视觉算法节点
  • 你的算法节点 → /cmd_vel 话题 → 机器人运动控制

这个项目中涉及到 2 种通信信息格式的转换:

从 ROS2_Gazebo 流出的传感器数据,需要转为 Python 能处理的数据格式。

内置摄像头插件:会自动将摄像头数据发布到 /camera 话题

启动后可以通过 rviz2 查看接收到的图片。

从算法节点流入 ROS2_Gazebo 的控制信息,需要是 ROS2_Gazebo 能理解的数据格式。

运动控制插件gz::sim::systems::DiffDrive 插件会监听 /cmd_vel 话题来控制机器人运动

我们把需要执行的命令写入到 Makefile 里,便于后续执行。

Makefile
.PHONY: world sim img cam vel bridge go left right stop rv node start

# 启动完整世界
world:
gz sim -r world.sdf


# 桥接摄像头和控制话题 (simple.sdf 的 /camera和/cmd_vel)
bridge:
ros2 run ros_gz_bridge parameter_bridge /camera@sensor_msgs/msg/Image@gz.msgs.Image /cmd_vel@geometry_msgs/msg/Twist]gz.msgs.Twist

start:
colcon build --packages-select learning_node
ros2 run learning_node node_helloworld

# 单独桥接摄像头 (simple.sdf 的 /camera)
cam:
ros2 run ros_gz_bridge parameter_bridge /camera@sensor_msgs/msg/Image@gz.msgs.Image
# 启动 rviz2
rv:
rviz2

# 单独桥接控制话题 (simple.sdf 的 /cmd_vel)
vel:
ros2 run ros_gz_bridge parameter_bridge /cmd_vel@geometry_msgs/msg/Twist]gz.msgs.Twist

# 前进
go:
ros2 topic pub -1 /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 1.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}"
ros2 topic pub -1 /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}"

# 左转
left:
ros2 topic pub -1 /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.5, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.5}}"
ros2 topic pub -1 /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}"

# 右转
right:
ros2 topic pub -1 /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.5, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: -0.5}}"
ros2 topic pub -1 /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}"

# 停止
stop:
ros2 topic pub -1 /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}"

调试命令

# 查看 GZ 中的话题
gz topic -l

# 查看 ROS2 中活跃的话题
ros2 topic list

# 查看图像话题数据
ros2 topic echo /camera/image
# 查看速度控制话题
ros2 topic echo /model/my_robot/cmd_vel

算法节点

tip

你是否需要去了解一个算法?

从功利角度来说:

  • 当你需要改进这个算法时,需要了解它的工作原理和实现细节。
  • 当你需要调参时,需要了解参数背后的意义。

如果某个算法默认参数表现足够,其实可以先不用知道它是怎么实现的。

下面代码实现了一个视觉运动控制节点,从 /camera 话题订阅图像数据,并处理后发布到 /cmd_vel 话题。

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image
from cv_bridge import CvBridge
import cv2
import numpy as np
from geometry_msgs.msg import Twist

class VisualMotionController(Node):
def __init__(self):
super().__init__('visual_motion_controller')

# 订阅摄像头话题 (simple.sdf 中是 /camera)
self.image_subscription = self.create_subscription(
Image,
'/camera',
self.process_and_control,
10
)

# 发布控制话题 (simple.sdf 中是 /cmd_vel)
self.velocity_publisher = self.create_publisher(
Twist,
'/cmd_vel',
10
)

self.bridge = CvBridge()
self.move_speed = 0.5
self.turn_speed = 0.5
self.step = 0 # 计步器
self.get_logger().info('视觉运动控制节点已启动')


def process_and_control(self, msg):
"""处理图像并生成控制命令"""
try:
# 将ROS图像消息转换为OpenCV格式
cv_image = self.bridge.imgmsg_to_cv2(msg, 'bgr8')
cv2.imshow('car',cv_image)
cv2.waitKey(1)
self.step += 1

# 这里可以添加你的视觉处理算法
# 例如:检测障碍物、识别路径等
# gray = cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY)
# edges = cv2.Canny(gray, 50, 150)
# 在这里编写你的运动控制算法

# 简单的控制逻辑示例
cmd = Twist()
if self.step < 100:
# 前进100帧
cmd.linear.x = self.move_speed
cmd.angular.z = 0.0
self.get_logger().info(f'前进中... 步数: {self.step}')
elif self.step < 160:
# 左转60帧
cmd.linear.x = 0.0
cmd.angular.z = self.turn_speed
self.get_logger().info(f'左转中... 步数: {self.step}')
else:
# 重置计步器
self.step = 0

self.velocity_publisher.publish(cmd)

except Exception as e:
self.get_logger().error(f'处理图像时出错: {str(e)}')


def main(args=None):
"""主函数"""
rclpy.init(args=args)

# 创建视觉运动控制节点
controller = VisualMotionController()

try:
# 启动节点
rclpy.spin(controller)
except KeyboardInterrupt:
controller.get_logger().info('节点被用户中断')
finally:
# 发送停止指令
stop_cmd = Twist()
controller.velocity_publisher.publish(stop_cmd)
# 清理资源
controller.destroy_node()
rclpy.shutdown()

if __name__ == '__main__':
main()

启动命令:

# 编译工作空间(以learning_node为例)
colcon build --packages-select learning_node

# 启动节点(以learning_node为例)
ros2 run learning_node node_helloworld

项目目标是在一个凹字形的地图上完成循迹运动,同时注意避开斑马线上的行人。

不是一个很难的项目,但是一个和实际项目流程一致要独立完成思路设计、算法选型、参数可视化的项目。

任务分析与思路:

预处理

预处理是基本流程,和数据分析前先数据清洗一样。有固定的流程和套路,任何一本Opencv教材都包含这部分内容。

感兴趣区域

好处:

  • 减少无关信息的干扰
  • 加快计算速度

地平线以上的内容其实和我们关系不大,只有检测行人的时候才会有影响。可以在检测到斑马线的时候计算完整图片。

所以我们先设置 roi(感兴趣区域)为图像的下面的 50%

def extract_roi(image ,roi_start_ratio=0.5):
"""提取感兴趣区域(ROI)

Args:
image: 输入的BGR图像
roi_start_ratio: 感兴趣区域。(0表示整张都感兴趣、0.5表示下半部分感兴趣)

Returns:
roi_image: ROI区域图像
"""
# 获取图像尺寸
height, width = image.shape[:2]

# 计算ROI起始行(从图像中部开始,关注前方地面)
roi_start = int(height * roi_start_ratio)

# 提取ROI区域(图像下半部分)
roi_image = image[roi_start:height, 0:width]

return roi_image

灰度化与二值化

这一步一般是为后面的边缘检测做铺垫,简单的算法也可以直接基于灰度化和二值化的结果做处理。

二值化的算法就很多了,我们的世界是有光源和树木的投影的,另外计算速度要快,所以排除 固定阈值法Otsu's 方法自适应高斯法

这里 全局平均值法自适应均值法 和我们可以都写出来,后面看情况选择。


def preprocess_and_binarize(bgr_image, method='mean', **kwargs):
"""完整的预处理和二值化流程

invert: 是否反向二值化(True=低于阈值变白,False=高于阈值变白)

Args:
bgr_image: 输入的BGR彩色图像
method: 二值化方法,可选 'mean', 'adaptive'
**kwargs: 各方法的额外参数
- invert: 是否反向二值化
- block_size: 自适应方法的邻域大小
- C: 自适应方法的常数偏移

Returns:
binary_image: 二值化结果
"""
# Step 1: 灰度化
gray_image = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2GRAY)

# Step 2: 根据选择的方法进行二值化
invert = kwargs.get('invert', False)

if method == 'adaptive':
# 选择二值化类型
thresh_type = cv2.THRESH_BINARY_INV if invert else cv2.THRESH_BINARY

# 自适应均值法:每个像素的阈值由其邻域的均值决定
binary_image = cv2.adaptiveThreshold(
gray_image,
255, # 最大值
cv2.ADAPTIVE_THRESH_MEAN_C, # 使用邻域均值
thresh_type, # 二值化类型(正向或反向)
11, # 邻域大小(必须是奇数),越大对光照变化越不敏感
2 # 常数偏移从均值中减去的常数,用于微调阈值
)
elif method == 'mean':
thresh_type = cv2.THRESH_BINARY_INV if invert else cv2.THRESH_BINARY
_, binary_image = cv2.threshold(gray_image, gray_image.mean(), 255, thresh_type)
else:
raise ValueError(f"未知的二值化方法: {method}")

return binary_image
去噪

这个项目中都是纯色,只有斑马线附近有异常色块。考虑到实际摄像头会有噪点。做个简单的开运算(先腐蚀后膨胀,用于去除图像中的小噪声)

def denoise_binary_image(binary_image, kernel_size=(5, 5)):
"""使用形态学开运算去除二值图像中的噪声

Args:
binary_image: 输入的二值化图像(0或255)
kernel_size: 结构元素大小,默认为(5, 5)

Returns:
denoised_image: 去噪后的二值图像
"""
# 创建结构元素(矩形核)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, kernel_size)

# 开运算 = 先腐蚀后膨胀
# 作用:去除小的白色噪点(孤立的亮点)
denoised_image = cv2.morphologyEx(binary_image, cv2.MORPH_OPEN, kernel)

return denoised_image

可视化

预处理完成之后就可以可视化展示了,这里我觉得可以展示感兴趣的原图、去噪后的图。

def visualize_preprocessing(roi_image, binary_image):
"""可视化预处理的各个阶段

Args:
roi_image: 感兴趣区域的原图(ROI)
denoised_image: 去噪后的二值化图像
"""
# 显示ROI区域原图
cv2.imshow('ROI', roi_image)

# 显示去噪后的结果
cv2.imshow('ROI-II', binary_image)

cv2.waitKey(1)

运动算法设计

这里我学习 CV 的时候学习了边缘检测、轮廓检测、光流法。我自己可以想到直接计算车道像素中心、像素逐行扫描。

OpenCV 的算法 API 有成百上千种(没开玩笑),这里我记住了常见场景的主流算法。

之前我们常说:"talk is cheap, show me your code."

现在 AI 时代,人人都能写代码,写好提示词变得更重要了,所以是:"code is cheap, show me your talk."

这里是我给 AI 的提示词:

  • 头脑风暴一下车道线跟随算法,适合弯道较多,有斑马线的。简单列出原理、优缺点。

以下是 Claude-4.5-sonnet 的答案:


### 🎯 适合弯道多+斑马线场景的算法(按推荐度排序)

**⭐⭐⭐ 推荐方案1:滑动窗口法 + 多项式拟合**
- **原理**:从图像底部开始,用小窗口逐层向上搜索黑色像素 → 记录窗口中心 → 用二次/三次多项式拟合曲线
- **优点**:
- ✅ 对弯道适应性极好(多项式天然支持曲线)
- ✅ 对斑马线有很强抗干扰性(逐层搜索,局部异常不影响整体)
- ✅ 可以处理车道线断裂
- ✅ 业界成熟方案(自动驾驶常用)
- **缺点**:
- ❌ 实现复杂度较高
- ❌ 计算量大(需要多个窗口迭代)
- **适用场景**:复杂环境、弯道、遮挡、斑马线

---

**⭐⭐⭐ 推荐方案2:像素列扫描法 + 边界追踪**
- **原理**:逐列扫描图像 → 统计每列的黑色像素分布 → 识别左右边界 → 计算中心线 → 过滤异常列(斑马线)
- **优点**:
- ✅ 实现简单,计算量适中
- ✅ 对弯道适应好(每列独立处理)
- ✅ 可以检测车道宽度变化
- ✅ 容易过滤斑马线(通过连续性检查)
- **缺点**:
- ❌ 需要假设车道在视野中
- ❌ 对噪声敏感(需要平滑处理)
- **适用场景**:中等复杂度,对实时性要求高

---

**⭐⭐ 推荐方案3:直方图投影法 + 峰值追踪**
- **原理**:垂直投影ROI → 找到左右车道的双峰 → 逐层向上追踪峰值移动 → 拟合曲线
- **优点**:
- ✅ 对光照不敏感
- ✅ 可以明确区分左右车道
- ✅ 斑马线在垂直投影中容易识别(横向特征)
- ✅ 计算效率高
- **缺点**:
- ❌ 急弯时左右车道可能重叠,峰值不明显
- ❌ 需要车道相对对称
- **适用场景**:车道清晰、对称性好、中等弯道

---

**⭐⭐ 推荐方案4:分区域重心法 + 轨迹连接**
- **原理**:将ROI分成多个水平带 → 每个带计算黑色像素重心 → 连接各带重心形成轨迹 → 过滤异常点(斑马线)
- **优点**:
- ✅ 对弯道适应好(分段处理)
- ✅ 实现简单,是重心法的改进版
- ✅ 可以检测局部异常(斑马线导致的重心跳变)
- ✅ 计算量小
- **优点**:
- ❌ 仍然对噪声敏感
- ❌ 需要设计异常点过滤规则
- **适用场景**:资源受限、需要快速原型

---

**⭐ 推荐方案5:边缘检测 + 分段霍夫变换**
- **原理**:Canny边缘检测 → 将ROI分成多段 → 每段独立做霍夫直线检测 → 连接直线段近似曲线 → 过滤斑马线(横向线)
- **优点**:
- ✅ 经典方法,鲁棒性好
- ✅ 可以用线段方向过滤斑马线(横向vs纵向)
- ✅ 分段处理可适应弯道
- **缺点**:
- ❌ 参数调节复杂
- ❌ 计算量大
- ❌ 对断裂车道线处理困难
- **适用场景**:车道线清晰、对准确度要求高

---

**⭐ 推荐方案6:形态学骨架提取 + 中轴线追踪**
- **原理**:二值化 → 形态学细化(skeletonization) → 提取黑色区域的中轴线 → 追踪中轴线
- **优点**:
- ✅ 直接得到车道中心,无需计算
- ✅ 对弯道自然适应
- ✅ 斑马线会被细化但不影响主路径
- **缺点**:
- ❌ 细化算法计算量大
- ❌ 对断裂敏感
- ❌ 可能产生分支(需要后处理)
- **适用场景**:车道连续、形状复杂

---

### 🔥 针对你的场景(弯道多+斑马线)最佳组合方案

**方案A:滑动窗口法-最优效果**

二值化 → 去噪 → 从底部开始滑动窗口 →
逐层找车道像素 → 多项式拟合 →
选取前视点 → Pure Pursuit控制


**方案B:分区域重心法-平衡方案** ⭐ 推荐快速实现

二值化 → 去噪 → ROI分成5-10个水平带 →
每带计算重心 → 过滤异常点(斑马线) →
拟合平滑曲线 → 跟随中心点

**方案C:像素列扫描法-实时性强**

二值化 → 去噪 → 逐列扫描 →
找左右边界 → 连续性检查过滤斑马线 →
计算中心 → 控制

如果以上方法都不合适,可以选择寻找新的算法和算法调参。现在的目的是验证方案是否可行,只有大致可用我们才进一步追求理解这个代码。

我们接着输入提示词:

  • 帮我实现:滑动窗口法 + 多项式拟合。要求:函数式编程,添加function docstring,无需逐行注释。
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image
from cv_bridge import CvBridge
import cv2
import numpy as np
from geometry_msgs.msg import Twist

def extract_roi(image ,roi_start_ratio=0.5):
"""提取感兴趣区域(ROI)

Args:
image: 输入的BGR图像
roi_start_ratio: 感兴趣区域。(0表示整张都感兴趣、0.5表示下半部分感兴趣)

Returns:
roi_image: ROI区域图像
"""
# 获取图像尺寸
height, width = image.shape[:2]

# 计算ROI起始行(从图像中部开始,关注前方地面)
roi_start = int(height * roi_start_ratio)

# 提取ROI区域(图像下半部分)
roi_image = image[roi_start:height, 0:width]

return roi_image

def preprocess_and_binarize(bgr_image, method ,invert):
"""完整的预处理和二值化流程

otsu方法会自动计算最佳阈值,适合双峰分布的图像(如黑色车道+灰色地面)

adaptive自适应均值法适应局部光照变化,适合有阴影和光照不均的场景

invert: 是否反向二值化(True=低于阈值变白,False=高于阈值变白)

Args:
bgr_image: 输入的BGR彩色图像
method: 二值化方法,可选 'mean', 'adaptive'
**kwargs: 各方法的额外参数
- invert: 是否反向二值化
- block_size: 自适应方法的邻域大小
- C: 自适应方法的常数偏移

Returns:
binary_image: 二值化结果
"""
# 灰度化
gray_image = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2GRAY)


if method == 'adaptive':
# 选择二值化类型
thresh_type = cv2.THRESH_BINARY_INV if invert else cv2.THRESH_BINARY

# 自适应均值法:每个像素的阈值由其邻域的均值决定
binary_image = cv2.adaptiveThreshold(
gray_image,
255, # 最大值
cv2.ADAPTIVE_THRESH_MEAN_C, # 使用邻域均值
thresh_type, # 二值化类型(正向或反向)
11, # 邻域大小(必须是奇数),越大对光照变化越不敏感
2 # 常数偏移从均值中减去的常数,用于微调阈值
)
elif method == 'mean':
thresh_type = cv2.THRESH_BINARY_INV if invert else cv2.THRESH_BINARY
_, binary_image = cv2.threshold(gray_image, gray_image.mean(), 255, thresh_type)
else:
raise ValueError(f"未知的二值化方法: {method}")

return binary_image

def denoise_binary_image(binary_image, kernel_size=(5, 5)):
"""使用形态学开运算去除二值图像中的噪声

Args:
binary_image: 输入的二值化图像(0或255)
kernel_size: 结构元素大小,默认为(5, 5)

Returns:
denoised_image: 去噪后的二值图像
"""
# 创建结构元素(矩形核)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, kernel_size)

# 开运算 = 先腐蚀后膨胀
# 作用:去除小的白色噪点(孤立的亮点)
denoised_image = cv2.morphologyEx(binary_image, cv2.MORPH_OPEN, kernel)

return denoised_image

def visualize_preprocessing(roi_image, binary_image):
"""可视化预处理的各个阶段

Args:
roi_image: 感兴趣区域的原图(ROI)
denoised_image: 去噪后的二值化图像
"""
cv2.imshow('ROI', roi_image)
cv2.imshow('ROI-II', binary_image)
cv2.waitKey(1)


def find_lane_base_positions(binary_image, n_windows=9, edge_detection=True):
"""通过直方图找到左右车道线的起始位置

Args:
binary_image: 二值化图像(白色为车道线)
n_windows: 滑动窗口数量
edge_detection: 是否检测边缘模式(适合宽道路)

Returns:
left_base: 左车道线起始x坐标(None表示未检测到)
right_base: 右车道线起始x坐标(None表示未检测到)
"""
histogram = np.sum(binary_image[binary_image.shape[0]//2:, :], axis=0)
midpoint = histogram.shape[0] // 2

if edge_detection:
threshold = np.max(histogram) * 0.3
white_pixels = np.where(histogram > threshold)[0]

if len(white_pixels) == 0:
return None, None

left_candidates = white_pixels[white_pixels < midpoint]
right_candidates = white_pixels[white_pixels >= midpoint]

left_base = np.min(left_candidates) if len(left_candidates) > 0 else None
right_base = np.max(right_candidates) if len(right_candidates) > 0 else None
else:
left_half = histogram[:midpoint]
right_half = histogram[midpoint:]

left_base = np.argmax(left_half) if np.max(left_half) > 0 else None
right_base = np.argmax(right_half) + midpoint if np.max(right_half) > 0 else None

return left_base, right_base


def detect_road_edges(binary_image):
"""检测宽道路的左右边缘

Args:
binary_image: 二值化图像(白色为道路)

Returns:
left_edge_x: 左边缘x坐标数组
left_edge_y: 左边缘y坐标数组
right_edge_x: 右边缘x坐标数组
right_edge_y: 右边缘y坐标数组
"""
height, width = binary_image.shape
left_edge_x, left_edge_y = [], []
right_edge_x, right_edge_y = [], []

for y in range(height):
row = binary_image[y, :]
white_pixels = np.where(row > 127)[0]

if len(white_pixels) > 10:
left_edge_x.append(white_pixels[0])
left_edge_y.append(y)
right_edge_x.append(white_pixels[-1])
right_edge_y.append(y)

return (np.array(left_edge_x), np.array(left_edge_y)), (np.array(right_edge_x), np.array(right_edge_y))


def sliding_window_detection(binary_image, n_windows=9, margin=80, min_pixels=50):
"""使用滑动窗口法检测车道线像素

Args:
binary_image: 二值化图像(白色为车道线,0或255)
n_windows: 滑动窗口数量
margin: 窗口宽度的一半(像素)
min_pixels: 重新定位窗口所需的最小像素数

Returns:
left_lane_pixels: 左车道线像素坐标 (x_coords, y_coords),未检测到则为 ([], [])
right_lane_pixels: 右车道线像素坐标 (x_coords, y_coords),未检测到则为 ([], [])
window_info: 窗口信息列表,用于可视化 [(x_center, y_center, width, height), ...]
"""
height, width = binary_image.shape
window_height = height // n_windows

nonzero = binary_image.nonzero()
nonzero_y = np.array(nonzero[0])
nonzero_x = np.array(nonzero[1])

left_base, right_base = find_lane_base_positions(binary_image, n_windows, edge_detection=True)

left_current = left_base
right_current = right_base

left_lane_indices = []
right_lane_indices = []
window_info = []

for window in range(n_windows):
win_y_low = height - (window + 1) * window_height
win_y_high = height - window * window_height

if left_current is not None:
win_xleft_low = left_current - margin
win_xleft_high = left_current + margin
window_info.append(('left', win_xleft_low, win_y_low, win_xleft_high, win_y_high))

good_left_indices = ((nonzero_y >= win_y_low) & (nonzero_y < win_y_high) &
(nonzero_x >= win_xleft_low) & (nonzero_x < win_xleft_high)).nonzero()[0]
left_lane_indices.append(good_left_indices)

if len(good_left_indices) > min_pixels:
left_current = int(np.mean(nonzero_x[good_left_indices]))

if right_current is not None:
win_xright_low = right_current - margin
win_xright_high = right_current + margin
window_info.append(('right', win_xright_low, win_y_low, win_xright_high, win_y_high))

good_right_indices = ((nonzero_y >= win_y_low) & (nonzero_y < win_y_high) &
(nonzero_x >= win_xright_low) & (nonzero_x < win_xright_high)).nonzero()[0]
right_lane_indices.append(good_right_indices)

if len(good_right_indices) > min_pixels:
right_current = int(np.mean(nonzero_x[good_right_indices]))

left_lane_indices = np.concatenate(left_lane_indices) if left_lane_indices else np.array([])
right_lane_indices = np.concatenate(right_lane_indices) if right_lane_indices else np.array([])

left_x = nonzero_x[left_lane_indices] if len(left_lane_indices) > 0 else np.array([])
left_y = nonzero_y[left_lane_indices] if len(left_lane_indices) > 0 else np.array([])
right_x = nonzero_x[right_lane_indices] if len(right_lane_indices) > 0 else np.array([])
right_y = nonzero_y[right_lane_indices] if len(right_lane_indices) > 0 else np.array([])

return (left_x, left_y), (right_x, right_y), window_info


def fit_polynomial(lane_pixels, degree=2):
"""对车道线像素进行多项式拟合

Args:
lane_pixels: 车道线像素坐标 (x_coords, y_coords)
degree: 多项式阶数,默认为2(二次多项式)

Returns:
poly_coeffs: 多项式系数 [a, b, c, ...],使得 x = a*y^2 + b*y + c
如果拟合失败则返回 None
"""
x_coords, y_coords = lane_pixels

if len(x_coords) < degree + 1:
return None

poly_coeffs = np.polyfit(y_coords, x_coords, degree)
return poly_coeffs


def calculate_lane_center_line(left_coeffs, right_coeffs, image_height):
"""根据左右车道线的多项式系数计算中心线

Args:
left_coeffs: 左车道线多项式系数 [a, b, c]
right_coeffs: 右车道线多项式系数 [a, b, c]
image_height: 图像高度

Returns:
center_coeffs: 中心线多项式系数 [a, b, c]
center_points: 中心线采样点 [(x, y), ...]
"""
if left_coeffs is None or right_coeffs is None:
return None, []

y_vals = np.linspace(0, image_height - 1, image_height)

left_x = np.polyval(left_coeffs, y_vals)
right_x = np.polyval(right_coeffs, y_vals)
center_x = (left_x + right_x) / 2

center_coeffs = np.polyfit(y_vals, center_x, 2)

sample_points = 50
y_samples = np.linspace(0, image_height - 1, sample_points, dtype=int)
x_samples = np.polyval(center_coeffs, y_samples)
center_points = list(zip(x_samples.astype(int), y_samples))

return center_coeffs, center_points


def calculate_control_command(center_coeffs, image_width, image_height,
lookahead_ratio=0.7, k_p=2.0,
v_straight=1.0, v_curve=0.5, curve_threshold=0.0005):
"""基于中心线多项式系数计算运动控制指令

Args:
center_coeffs: 中心线多项式系数 [a, b, c],x = a*y^2 + b*y + c
image_width: 图像宽度
image_height: 图像高度
lookahead_ratio: 前视点位置比例(0-1),越大越远
k_p: 比例增益
v_straight: 直道速度 (m/s)
v_curve: 弯道速度 (m/s)
curve_threshold: 曲率阈值,超过此值认为是弯道

Returns:
linear_velocity: 线速度 (m/s)
angular_velocity: 角速度 (rad/s)
lateral_error: 横向偏差(像素)
curvature: 曲率
"""
if center_coeffs is None:
return 0.0, 0.0, 0.0, 0.0

lookahead_y = int(image_height * lookahead_ratio)
lookahead_x = np.polyval(center_coeffs, lookahead_y)

image_center_x = image_width / 2
lateral_error = lookahead_x - image_center_x
normalized_error = lateral_error / (image_width / 2)

a, b, c = center_coeffs
curvature = abs(2 * a)

if curvature > curve_threshold or abs(normalized_error) > 0.2:
linear_velocity = v_curve
else:
linear_velocity = v_straight

angular_velocity = -k_p * normalized_error
angular_velocity = np.clip(angular_velocity, -2.0, 2.0)

return linear_velocity, angular_velocity, lateral_error, curvature


def visualize_lane_detection(original_image, binary_image, left_pixels, right_pixels,
left_coeffs, right_coeffs, center_coeffs, window_info,
lookahead_point=None, lateral_error=0.0,
linear_vel=0.0, angular_vel=0.0):
"""可视化车道线检测结果

Args:
original_image: 原始彩色图像
binary_image: 二值化图像
left_pixels: 左车道线像素 (x_coords, y_coords)
right_pixels: 右车道线像素 (x_coords, y_coords)
left_coeffs: 左车道线多项式系数
right_coeffs: 右车道线多项式系数
center_coeffs: 中心线多项式系数
window_info: 滑动窗口信息
lookahead_point: 前视点坐标 (x, y)
lateral_error: 横向偏差
linear_vel: 线速度
angular_vel: 角速度

Returns:
vis_image: 可视化图像
"""
vis_image = cv2.cvtColor(binary_image, cv2.COLOR_GRAY2BGR)
height = vis_image.shape[0]
width = vis_image.shape[1]

for window in window_info:
lane_type, x_low, y_low, x_high, y_high = window
color = (0, 255, 0) if lane_type == 'left' else (255, 0, 0)
cv2.rectangle(vis_image, (x_low, y_low), (x_high, y_high), color, 2)

left_x, left_y = left_pixels
right_x, right_y = right_pixels

if len(left_x) > 0:
for x, y in zip(left_x, left_y):
cv2.circle(vis_image, (int(x), int(y)), 2, (0, 255, 255), -1)

if len(right_x) > 0:
for x, y in zip(right_x, right_y):
cv2.circle(vis_image, (int(x), int(y)), 2, (255, 255, 0), -1)

y_vals = np.linspace(0, height - 1, height).astype(int)

if left_coeffs is not None:
left_fit_x = np.polyval(left_coeffs, y_vals).astype(int)
for i in range(len(y_vals) - 1):
cv2.line(vis_image, (left_fit_x[i], y_vals[i]),
(left_fit_x[i+1], y_vals[i+1]), (0, 255, 0), 3)

if right_coeffs is not None:
right_fit_x = np.polyval(right_coeffs, y_vals).astype(int)
for i in range(len(y_vals) - 1):
cv2.line(vis_image, (right_fit_x[i], y_vals[i]),
(right_fit_x[i+1], y_vals[i+1]), (255, 0, 0), 3)

if center_coeffs is not None:
center_fit_x = np.polyval(center_coeffs, y_vals).astype(int)
for i in range(len(y_vals) - 1):
cv2.line(vis_image, (center_fit_x[i], y_vals[i]),
(center_fit_x[i+1], y_vals[i+1]), (0, 0, 255), 4)

image_center_x = width // 2
cv2.line(vis_image, (image_center_x, 0), (image_center_x, height), (255, 255, 255), 2)

if lookahead_point is not None:
lx, ly = lookahead_point
cv2.circle(vis_image, (int(lx), int(ly)), 10, (255, 0, 255), -1)
cv2.arrowedLine(vis_image, (image_center_x, int(ly)), (int(lx), int(ly)),
(255, 0, 255), 3)

font = cv2.FONT_HERSHEY_SIMPLEX
cv2.putText(vis_image, f'Error: {lateral_error:.1f}px', (10, 30),
font, 0.6, (255, 255, 255), 2)

turn_info = "LEFT" if angular_vel > 0 else "RIGHT" if angular_vel < 0 else "STRAIGHT"
cv2.putText(vis_image, f'Angular: {angular_vel:.3f} ({turn_info})', (10, 60),
font, 0.6, (255, 255, 255), 2)

cv2.putText(vis_image, f'Speed: {linear_vel:.2f} m/s', (10, 90),
font, 0.6, (255, 255, 255), 2)

return vis_image


class VisualMotionController(Node):
"""基于视觉的车道线跟随控制节点

功能:
- 接收摄像头图像
- 滑动窗口法检测车道线
- 多项式拟合车道线
- 计算运动控制指令
- 发布速度控制命令
"""

def __init__(self):
super().__init__('visual_motion_controller')

# 订阅摄像头话题
self.image_subscription = self.create_subscription(
Image,
'/camera',
self.process_and_control,
10
)

# 发布控制话题
self.velocity_publisher = self.create_publisher(
Twist,
'/cmd_vel',
10
)

self.bridge = CvBridge()

# ========== 图像处理参数 ==========
self.roi_start_ratio = 0.5

# ========== 运动控制参数 ==========
self.lookahead_ratio = 0.7
self.k_p = 2.0
self.v_straight = 1.0
self.v_curve = 0.5
self.curve_threshold = 0.0005

# ========== 调试模式 ==========
self.debug_mode = False


def process_and_control(self, msg):
"""处理图像并计算运动控制指令(主回调函数)

Args:
msg: ROS2图像消息
"""
try:
# Step 1: 图像格式转换
cv_image = self.bridge.imgmsg_to_cv2(msg, 'bgr8')

# Step 2: 提取ROI
roi_image = extract_roi(cv_image, self.roi_start_ratio)

# Step 3: 图像预处理和二值化
binary_image = preprocess_and_binarize(roi_image, method='mean', invert=True)

# Step 4: 去噪
denoised = denoise_binary_image(binary_image)

# 可视化
visualize_preprocessing(roi_image, denoised)

# Step 5: 滑动窗口检测车道线
left_pixels, right_pixels, windows = sliding_window_detection(denoised)

# Step 6: 多项式拟合
left_fit = fit_polynomial(left_pixels)
right_fit = fit_polynomial(right_pixels)

# Step 7: 计算中心线
center_fit, center_pts = calculate_lane_center_line(
left_fit, right_fit, denoised.shape[0]
)

# Step 8: 计算运动控制指令
linear_vel, angular_vel, lateral_error, curvature = calculate_control_command(
center_fit,
denoised.shape[1],
denoised.shape[0],
lookahead_ratio=self.lookahead_ratio,
k_p=self.k_p,
v_straight=self.v_straight,
v_curve=self.v_curve,
curve_threshold=self.curve_threshold
)

# Step 9: 计算前视点用于可视化
lookahead_point = None
if center_fit is not None:
lookahead_y = int(denoised.shape[0] * self.lookahead_ratio)
lookahead_x = np.polyval(center_fit, lookahead_y)
lookahead_point = (lookahead_x, lookahead_y)

# Step 10: 可视化
vis = visualize_lane_detection(
roi_image, denoised,
left_pixels, right_pixels,
left_fit, right_fit, center_fit,
windows,
lookahead_point=lookahead_point,
lateral_error=lateral_error,
linear_vel=linear_vel,
angular_vel=angular_vel
)
cv2.imshow('Lane Detection', vis)
cv2.waitKey(1)

# Step 11: 发布控制命令
cmd = Twist()
if self.debug_mode:
cmd.linear.x = 0.0
cmd.angular.z = 0.0
self.get_logger().info('调试模式:运动已暂停', throttle_duration_sec=2.0)
else:
cmd.linear.x = linear_vel
cmd.angular.z = angular_vel
self.velocity_publisher.publish(cmd)

except Exception as e:
self.get_logger().error(f'处理图像时出错: {str(e)}')
import traceback
self.get_logger().error(traceback.format_exc())

def main(args=None):
"""主函数"""
rclpy.init(args=args)

# 创建视觉运动控制节点
controller = VisualMotionController()

try:
# 启动节点
rclpy.spin(controller)
except KeyboardInterrupt:
controller.get_logger().info('节点被用户中断')
finally:
# 发送停止指令
stop_cmd = Twist()
controller.velocity_publisher.publish(stop_cmd)
# 清理资源
cv2.destroyAllWindows()
controller.destroy_node()
rclpy.shutdown()


if __name__ == '__main__':
main()

注意:由于不同设备算力不同,因此对于低算力设备来说,运算时间过长可能会冲出车道线,需要降低运动速度。

· 9 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

思考

需要避免的变化

随着年龄的增长,有两种变化需要避免:

  • 固执自己的世界观,忽视其他年龄段人群的需求与视角。
  • 夸夸其谈自己的想法,却对事情的真相一无所知。

教育供应商

原本用于考核学生的标准被用来考核指导老师,导致老师倾向于选择提供答案的厂商。机构与教师的核心目标是消课,提供适合长度的 PPT/教案。

在 AI 时代,可以采用 AI 智能课的形式辅助学生学习:将电脑屏幕二分,一侧显示课件,另一侧提供 AI 助手,便于学生提问。这样,机构和教师的工作量转变为考核学生及处理问题。

飞轮

为了实现某个场景学习技术 这个场景强化技术 取得的收益会持续提供正向奖励并提供新的场景

譬如英语。为了看懂国外的科技文献,学习了英语。因此获得了出国留学的机会。留学的经历又会进一步强化英语。不断向前,最终形成了正向循环。

常见的飞轮还有:技术、建模、驾车

维度

有些事情对生活帮助很大,但是对工作帮助很小。 譬如读书、旅游、运动。

有些事情对生活帮助很小,但是对工作帮助很大。 譬如技术、项目、方案。

换个角度,有的技术改变对自己提升很大,但是对产品帮助很小。 譬如异步、K8S、Docker。你花了大部分时间,但是大部分情况下用户感知不到。

在选择做事的时候,选择每个维度都能收益的,才是最优解。一些技术革新不易推动或者手下的人忙活许久产品没有起色的原因,可能就是没有让每个人的每个维度都收益。

整理

项目的高度重复性促使我创建一个通用的第三方库。在开发中,代码的简洁易读是首要任务。

2024 年做了很多碎片化的工作,这里花了一周的时间把可以复用的功能整理到 etool 工具库中了。

2024 年出游了国内的一些 4A、5A 景区。

搬回了深圳,换了新家,花了很多心思装点这个家。

我喜欢未知,愿意接受新事物。 在别人的车上,别人放什么歌我就听什么歌。不会想着切自己的音乐。聚餐吃饭,除了很特别的食物(如折耳根)我都可以接受并愿意尝试。随手拿起一本书,一部电影,我都愿意发现它们有价值的一面。

我觉得人生是一种经历,我会期待更丰富的经历。 走路会更喜欢去没去过的路线、没吃过的小吃,同样一部很好看的电视剧或者综艺,我也最多会刷两遍,当然对应的,我会在第一次看它们的时候,就全神贯注,努力记住并体会它们的优秀。

我会把有价值的经历长久记录下来。 譬如读书、方案、照片。并且定期回看至少每年都会回顾、复读。确保经历对自己的长久影响,不让这些有价值的记忆在角落蒙尘。

我会定期整理收藏夹。 一段时间内(一个月)不清空收藏夹就停止新知识的学习,以此来不让自己成为收藏夹大师。

我做事喜欢全神贯注。 吃饭过程中想到一个方案会马上放下食物,去完成这个方案。我觉得饭每天都吃,但灵感不是每刻都有。

我讨厌无意义的制度。 因为我珍惜并享受我的生命,我也热爱智能家居并做了很多研究,因为我觉得这在节省我的时间,让我能全身心投入到生活中。

我会记大账,不喜欢记细账。 譬如这个月最主要花销是哪些,不会在意今天这顿饭多吃一点少吃一点

我只能学习当下能学以致用的知识。 因为过于长远的未来总是不确定的,当下 5 年以内的未来是确定的。

学习

这里重点的学习了大模型相关的知识,并尝试了使用大模型来解决一些问题。

2025 年,我想放慢更新博客的频率,每个季度更新一次,并尝试写一些有深度的项目和文章,毕竟项目在优不在多。

日常

学习建模技术

最近我间歇性学习建模技术,发现自己更倾向于选择中间款软件,它能快速生成及格的作品。

一些阅读摘要

  • “愿意自由换取保障的人,既得不到自由,也得不到保障。”——哈耶克

  • “只有金钱会向穷人伸手,而权力不会。”——哈耶克

  • “如果允许人类自由迁徙,那么人流的方向,就是文明的方向。”——哈耶克

房间布网

网络知识 核心知识 1: 信号在穿墙后会衰减 核心知识 2:路由器的信号芯片优于大部分手机/电脑的天线

主路由器发射穿墙信号,墙另一侧,无线中继路由器接收到的信号大于设备 A 大于设备 B。于是,可以让设备 A、设备 B 连接无线中继路由器,实现与无线中继路由器一样的网速。

猫尿清洗说明

猫尿具有留存时间长,渗透性高

若猫尿在沙发枕套上,需要拆开枕套、枕芯。

枕套使用专用除猫尿清洗剂+汰渍/奥妙,浸泡一夜+洗衣液洗净,自然阴干。

枕芯被污染需要直接更换,尺寸为枕套的尺寸+5cm(80cm50cm 的枕套,配 85cm55cm 的枕芯)

购车后

买车了,买完车除了要交保险之外,还需买一些必要的物品:行车记录仪、实习车标、挪车号码牌、车载除甲醛香薰、手机导航支架。

侧方位停车复习:开至肩膀与库中齐平,向车库反向打成 45 度,倒车回方向直至左右两边距离合适停正。

通勤学习

指定学习计划是利用碎片化时间的首要目标,阅读书籍、观看 TED 演讲或自己的收藏会让学习过程更有成就感。应避免泛信息输入(如新闻、推荐刷屏),而应选择适当的半精准信息输入(如读书、博客、GitHub 等)。通勤后及时整理记录。

随着职业地位的提升,不必要的内耗逐渐减少。高中时非常在意学校的排名,而如今在大厂面前都统一成了“双非”。想要进入世界一流的企业,需要有相应的世界排名。只有世界级的排名与项目才能证明你的价值。

每个阶段有不同的评价标准。从学生时期的院校评价,到工作初期的薪资待遇,再到中年的事业与家庭评价,均体现出人生的不同阶段。

平淡生活的解药

平淡生活源于外部世界缺乏新输入。当工作多年没有变化,内部的平淡便开始显现,只有不断成长,才能支撑更远的目标与更好的生活。

如果我们把感情看作 0 和游戏。那么总会会有人因长期利益受损而对这段关系感到不满。

因此,只有当一方的所求不是另一方的付出时,0 和才会被打破。即:某一行为是二者都收益的。

企业与员工关系亦是如此,企业提供平台员工发挥,员工将在平台创造的利润与企业分成。

最近我发现迷你工作室非常适合我。迷你工作室通常由 1-5 人组成,项目可以是软件、游戏或网站。

成立工作室是为了开发票,能接到更多的项目。在 AI 时代,有两种发展方向:加入大公司研究通用人工智能,或选择中小型公司做垂类人工智能,专注于特定领域的模型开发。

除了开发人工智能模型外,还可以拓展其应用,如语音控制家庭设备。技术的爆发得益于高速通信和丰富的硬件资源。

经济下行时创业或找工作的挑战包括投资回报难、企业扩张减慢及岗位的消失,传统岗位正在逐步被淘汰,需尽早转型。

电脑新配

最近新买了一台台式机,主要用于编程(高速大内存)、建模跑模型(显存优先)和数据分析(高速硬盘)。

对于大模型来说,显存优先。32B 的大模型本地需要 20G、常规的文生图模型本地需要 24G。

消费级显卡性价比更高,3090 Ti 支持 Nvlink 很适合后期组双卡,或升级5090。

配件描述价格 (元)
显卡微星 超龙 RTX3090Ti 24G8099
CPUAMD 锐龙 R9 7950X 16 核 32 线程3695
硬盘英睿达固态硬盘 1T M.2 PCIE5.01767
内存光威内存 龙武 32G*2 64G 6400 C32 马甲条1330
主板华硕 TUF GAMING B650M PLUS 蓝牙 WIFI DDR51170
电源航嘉 MVP1200 额定 1200W 白金全模组 黑色1258
机箱及风扇航嘉 GX750A 掠夺者 黑色 ATX 360 + 链力无光风扇*6300
水冷散热钛钽 SJ-A080 360WH295
屏幕乐视显示器 32 英寸 1K 75hz699
键盘Lofree 洛斐小浪蓝牙机械键盘(旅行版)599
鼠标Razer 雷蛇炼狱蝰蛇 V2X 极速版双模无线电池笔记本蓝牙电竞鼠标249

工具推荐

Windows 11 的 PowerToys 是一个非常强大的工具,它可以帮助你更高效地使用 Windows 系统。找到鼠标、窗口永久置顶、截图定格等功能。

Windows平台的显示键盘按键的产品

软件名称费用特色功能推荐指数
Carnac免费C#开发,极快、极小、无BUG⭐⭐⭐⭐⭐
Keyviz免费界面最美观,动画效果好,windows下有一些显示BUG⭐⭐⭐⭐⭐
NohBoard免费全键盘虚拟键盘显示,高度可定制⭐⭐⭐⭐
showKeyBoard免费功能最全面,支持统计分析,界面丑,快,无BUG⭐⭐⭐⭐⭐
KeyCastOW免费较小体积,绿色便携⭐⭐⭐⭐
· 8 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

该项目适合作为小型的项目原型,适合教学和练手。最初这个项目的灵感源于我的个人需求,我需要一个工具来查看主流话题,同时又不想下载一堆 APP 来接收推送。

项目开源地址:https://github.com/jiangyangcreate/AI-Practice-Collection/SocialMood

项目目录结构

SocialMood/
├── docs/ # 文档和静态页面目录
│ └── index.html # 数据可视化的静态页面,展示词云图等图表
├── src/ # Python代码主目录
│ ├── _dataclean.py # 数据清洗脚本,负责数据清洗和处理
│ ├── _model.py # 模型脚本,负责情绪分析
│ ├── _pyechart.py # 数据可视化脚本,负责数据可视化
│ ├── _database.py # 数据库操作模块,处理SQLite数据存储
├── README.MD # 项目说明文档,包含项目介绍和使用说明
├── .gitignore # Git忽略文件配置,用于排除不需要版本控制的文件
├── .nojekyll # 用于GitHub Pages的配置文件
├── requirements.txt # Python依赖包列表
├── setup.py # 项目依赖安装脚本,负责安装必要的包和模型
├── run.py # 项目运行脚本,负责运行爬虫和数据处理
├── news.db # SQLite数据库文件,存储新闻数据

使用流程

# 安装依赖
cd python
python setup.py

# 运行数据抓取,生成静态网页
python run.py

依赖安装

所需依赖可以通过 setup.py 下载安装。因为有些模块不是pip就算安装好的。

setup.py
import subprocess
import sys
import nltk


def install_requirements():
print("正在安装依赖...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"])

def install_browser():
print("正在安装浏览器...")
subprocess.check_call([sys.executable, "-m", "playwright", "install"])

def download_nltk_data():
print("正在下载NLTK词典文件...")
nltk.download('vader_lexicon')

def download_model():
try:
import ollama
ollama.pull('qwen2.5')
except Exception as e:
print(f"下载模型失败:{e}")

def main():
try:
install_requirements()
install_browser()
download_nltk_data()
download_model()
print("所有安装步骤已完成!")
except subprocess.CalledProcessError as e:
print(f"安装过程中出现错误:{e}")
sys.exit(1)

if __name__ == "__main__":
main()

主要功能

该系统的主要功能包括:

  • 抓取热搜数据:从微博、抖音、B站等平台抓取热搜数据。

这里也可以通过API获取,爬取注意不要变成DDOS攻击。

  • 数据处理:使用 Pandas 进行数据清洗与处理。

使用pandas主要是处理一些文本型的数据,譬如10万要换算为100000。

使用jieba分词用于后续词云图生成,需要剔除一些单字与标点符号。当然,最近b站很喜欢在标题中加空格,所以要先去空格再分词。

有些数据的热度值还没计算出来,可以使用幂律分布的线性回归填补热度缺失值。这里使用指数回归、普通线性回归效果都不好。

幂律分布常见的样子:个人UP主粉丝排名前几名粉丝差距有百万,像指数分布。但是后续排名的up粉丝差距就很小,接近线性分布。如果我随机扣掉一个排名的up主的粉丝数,让你预测,你可以预测多准。我面对的大概就是这样的一个问题。

下面是我的解决方案:

src/_dataclean.py
def estimate_missing_heat(self):
"""估算缺失的热度值"""
features = ["排名"]

target = "处理后热度"

def impute_group(group):
X = group[features]
y = group[target]

# 如果没有缺失值,直接返回原始组
if not y.isnull().any():
return group

# 幂律分布拟合(通过对数变换实现)
X_train = X[y.notnull()]
y_train = y[y.notnull()]

# 将 X_train 转换为浮点数类型
X_train = X_train.astype(float)

# 对X和y进行对数变换,处理可能的零值
log_X_train = np.log(X_train + 1)
log_y_train = np.log(y_train + 1)

lr = LinearRegression()
lr.fit(log_X_train, log_y_train)

# 获取拟合的系数
slope = lr.coef_[0]
intercept = lr.intercept_
# print(f"拟合的幂律方程为: y = {np.exp(intercept):.2f} * x^{slope:.2f}")

# 预测缺失值
X_missing = X[y.isnull()]
if not X_missing.empty:
# 将 X_missing 转换为浮点数
X_missing = X_missing.astype(float)
log_X_missing = np.log(X_missing + 1)
log_predictions = lr.predict(log_X_missing)
predictions = np.exp(log_predictions) - 1 # 反向变换

# 确保预测值不小于0
predictions = np.maximum(predictions, 0)

# 确保预测值符合排名顺序
for i, pred in enumerate(predictions):
rank = X_missing.iloc[i]["排名"]
higher_ranks = y[(X["排名"] < rank) & y.notnull()]
lower_ranks = y[(X["排名"] > rank) & y.notnull()]

if len(higher_ranks) > 0:
pred = min(pred, higher_ranks.min())
if len(lower_ranks) > 0:
pred = max(pred, lower_ranks.max())

predictions[i] = pred

group.loc[y.isnull(), target] = predictions

return group

# 对每个信息来源分别进行缺失值填充
self.df = self.df.groupby("信息来源").apply(impute_group)

# 删除仍然为None的行(如果有的话)
self.df = self.df.dropna(subset=["处理后热度"])
self.df["处理后热度"] = self.df["处理后热度"].astype(int)
  • 情绪分析:监测和分析公众情绪。算出单条标题的情绪数值之后,标准化到 (-1,1) 这个区间之中。最后通过热度与排名计算出对社会的情感影响力。正数数则是积极影响,负数则是负面影响。

如果你的电脑性能还不错,可以使用本地模型作为情绪分析的核心,根据自己的设备选择模型大小。

src/_model.py
class Model:
@classmethod
def get_sentiment_score(cls,text):

prompt = "请仅对用户提供的句子进行情感评分,并返回介于-1到1之间的分数。-1表示非常负面,1表示非常正面,0表示中立。无需提供其他信息或上下文。"

messages = [
{"role": "system", "content": prompt},
{"role": "user", "content": text}
]
response: ChatResponse = chat(model='qwen2.5', messages=messages)
return float(response.message.content)

数据存储

系统使用 sqlite3 保存中间数据,此部分为基本的增删改查,相关代码在 _database.py 中,数据文件为 news.db,包含两张表:

  • news:清洗后的可用数据。
  • raw_html:原始网页数据。

这样可以保证速度的同时,可以保持文件夹的整洁,如果数据量大可以直接平滑的迁移到正式数据库中。

数据可视化

接下来绘制一些图表,包括:

  • 各个平台的情绪红绿图。
  • 公众情绪的涨跌折线图。
  • 每日全网词云图。

通过pyecharts的脚手架,导出为静态网页。这里为了代码直观,封装为了类。

为了保证协调统一,这里将所有图表的绘制都封装到了一个类中,都使用pyecharts。table的绘制使用pyecharts的components的Table类,这个类默认会将超链接转义,查看源代码发现内容有一个参数 escape_data 用于设置是否转义。但是没有被暴露出来,已经提交了PR,如果你的escape_data=False报错,可以自己修改源代码。

src/_pyechart.py
from pyecharts.charts import Bar, Line, Pie, WordCloud, Page
from pyecharts.components import Table
from pyecharts import options as opts
import os
class ChartGenerator:
def create_table(self,data) -> Table:
"""Generate a table."""
table = Table()
headers = data.columns.tolist()
rows = data.values.tolist()
table.add(headers, rows,escape_data=False)
return table

def create_bar_chart(self, data , y_label="Sentiment",title="正负面情绪") -> Bar:
"""Generate a bar chart with positive, negative, and absolute sentiment scores."""

x_data, y_data_positive, y_data_negative, y_data_absolute = zip(*data)
bar = (
Bar()
.add_xaxis(x_data)
.add_yaxis("积极", y_data_positive, stack="stack1", category_gap="50%")
.add_yaxis("消极", y_data_negative, stack="stack2", category_gap="50%")
.add_yaxis("绝对值", y_data_absolute, stack="stack3", category_gap="50%")
.set_global_opts(
title_opts=opts.TitleOpts(title=title, pos_left="center"),
xaxis_opts=opts.AxisOpts(name="来源"),
yaxis_opts=opts.AxisOpts(name="情绪得分"),
legend_opts=opts.LegendOpts(pos_top="10%", pos_right="10%"),
)
)
return bar

def create_line_chart(self,data,y_label="情绪得分",title="绝对情绪变化折线图") -> Line:
"""Generate a line chart."""
x_data, y_data = zip(*data)
line = (
Line()
.add_xaxis(x_data)
.add_yaxis(y_label, y_data, is_smooth=True)
.set_global_opts(
title_opts=opts.TitleOpts(title=title, pos_left="center"),
xaxis_opts=opts.AxisOpts(name="日期"),
legend_opts=opts.LegendOpts(is_show=False)
)
)
return line

def create_pie_chart(self, data_pairs, title="信息来源占比") -> Pie:
"""Generate a pie chart."""
pie = (
Pie()
.add("", data_pairs)
.set_global_opts(
title_opts=opts.TitleOpts(title=title, pos_left="center"),
legend_opts=opts.LegendOpts(pos_bottom="0%") # Move legend to the bottom
)
.set_series_opts(label_opts=opts.LabelOpts(formatter="{b}: {d}%"))
)
return pie

def create_wordcloud(self, words, title="词云") -> WordCloud:
"""Generate a word cloud."""
wordcloud = (
WordCloud()
.add("", words, word_size_range=[20, 100],shape="circle")
.set_global_opts(title_opts=opts.TitleOpts(title=title, pos_left="center"))
)
return wordcloud

def render_charts(self, charts, output_file=None):
"""Render multiple charts to a single HTML file."""
if output_file is None:
output_file = os.path.join("docs", "index.html")

page = Page(layout=Page.SimplePageLayout)
page.add(*charts)
page.render(output_file)


代码设置

随着时间的推移,爬虫部分的代码可能需要自己修改。你也可以在main函数中,将debug设为True,这样不会真的爬取,而是调用本地的已爬取的网页。生成后的内容在docs/index.html

· 13 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

作为开发者,最讨厌的事情莫过于多平台适配,在手机端由于大家型号不同,编个 APP 通过蓝牙控制显然是不方便的,于是做了一个蓝牙小程序来与 ESP32 通信。

界面设计

这里我想设计成方块按键的格式,所以创建一个矩阵,然后在矩阵对应的位置添加上按钮。非常的简单,只是有一些差异需要注意。

WXML 标签

蓝牙小程序标签与 HTML 略有不同,以下是小程序标签(即 WXML 标签)与 HTML 略有不同的标签的对比表:

wxml标签HTML 标签描述
<view><div>用于容器和布局,类似于 HTML 中的 <div>
<text><span>用于文本显示,类似于 HTML 中的 <span>
<button><button>用于创建按钮,与 HTML 中的 <button> 功能相同。
<image><img>用于显示图片,类似于 HTML 中的 <img>,但属性有所不同。
<navigator><a>用于页面导航,类似于 HTML 中的 <a> 标签。
<picker>N/A用于多种选择器,HTML 中无直接对应的标签。
<scroll-view>N/A用于可滚动的视图区域,HTML 中无直接对应的标签。
<swiper>N/A用于滑动视图容器,HTML 中无直接对应的标签。
<map><iframe>用于展示地图,类似于 HTML 中嵌入地图的方式。
<swiper-item>N/A<swiper> 配合使用,HTML 中无直接对应的标签。
<rich-text>N/A用于展示富文本,HTML 中无直接对应的标签。
<block>N/A无实际渲染效果,类似于 HTML 中的 <template>

JavaScript

以下是微信小程序的 JavaScript 与网页的 JavaScript 的对比表格:

特性/功能微信小程序 JS网页 JS描述
全局对象wxwindow微信小程序中使用 wx 对象来调用特定的 API,而在网页 JS 中,所有的全局对象都挂载在 window 对象下。
API 调用基于 wx 对象提供的 API,如 wx.request()wx.navigateTo()使用浏览器提供的 API,如 fetch()window.location微信小程序有一套独立的 API,专门用于微信环境下的开发,无法直接使用标准的浏览器 API。
页面与组件管理通过小程序的 PageComponent 函数定义页面和组件通过 HTML 文件和 JavaScript 结合使用前端框架或直接操作 DOM微信小程序使用特殊的 PageComponent 函数来定义页面和组件,网页 JS 则通过 DOM 结合 JavaScript 实现页面和组件管理。
数据绑定使用 this.setData() 进行数据绑定和更新通常使用 innerHTMLtextContent 或前端框架(如 React 的 setState微信小程序使用 this.setData() 来绑定和更新数据,而在网页 JS 中,常通过直接操作 DOM 或使用前端框架来更新数据。
生命周期函数提供页面与组件的生命周期函数,如 onLoadonShow通过事件绑定或框架提供的生命周期函数(如 React 的 componentDidMount微信小程序有特定的生命周期函数供开发者使用,而网页 JS 通常需要结合框架或事件来处理生命周期管理。
路由与导航使用 wx.navigateTo()wx.redirectTo() 等方法进行页面跳转通过改变 window.location 或使用 history.pushState() 进行路由小程序的路由机制是由微信管理的,开发者需要使用专门的 API 进行导航,而网页 JS 可以直接操作 URL。
模块化使用 require() 和模块化文件系统使用 ES6 import/export 或 CommonJS 模块系统小程序内置的模块化系统与 Node.js 类似,使用 require() 导入模块,而网页 JS 中可以使用 ES6 模块或 CommonJS 模块系统。
网络请求使用 wx.request() 发起 HTTP 请求使用 fetch()XMLHttpRequest 发起 HTTP 请求微信小程序提供了 wx.request() 方法用于网络请求,而网页 JS 通常使用 fetch()XMLHttpRequest
文件系统访问通过 wx.getFileSystemManager() 访问文件系统通过 File API、Blob、FileReader 等访问文件小程序提供了 wx.getFileSystemManager() 接口来管理文件系统,而网页 JS 可以使用浏览器提供的 File API。
样式与布局使用 WXML 和 WXSS 定义页面结构和样式使用 HTML 和 CSS 定义页面结构和样式微信小程序使用 WXML 和 WXSS 分别来代替 HTML 和 CSS,专门为小程序定制。
事件处理事件绑定使用 bindtapcatchtap 等绑定事件事件绑定使用 addEventListener 或内联 onclick微信小程序的事件处理是通过特定的属性绑定事件,而网页 JS 可以直接使用标准的事件绑定方法。
调试与工具使用微信开发者工具进行调试使用浏览器的开发者工具进行调试小程序开发和调试通常在微信开发者工具中进行,而网页开发则依赖于浏览器提供的开发者工具。
存储提供 wx.setStorage()wx.getStorage() 进行数据持久化存储使用 localStoragesessionStorage 进行数据存储微信小程序的存储 API 类似于浏览器的 localStorage,但使用 wx 提供的 API 进行调用。
原生 API 调用不支持直接调用浏览器或操作系统的原生 API可以使用浏览器 API 或通过插件访问系统 API微信小程序无法直接调用浏览器或操作系统的原生 API,而网页 JS 则可以直接使用这些 API。
平台限制运行于微信环境,仅支持在微信客户端中运行运行于浏览器环境,可以在任何支持的浏览器中运行小程序只能在微信客户端中运行,而网页 JS 则可以在任何现代浏览器中运行。

页面代码

界面部分因为手机的尺寸实在太多,所以我创建一个矩阵,然后把有按键的地方加上边框实现规则布局。中间输入设备名称。

在正常的网页开发过程之中。缺省是非常常见的,也就是某一个属性大门不去填写的时候浏览器会默认给一个属性。但是在小程序开发过程之中,他并不会给一个默认的属性或者是说他给的默认属性与浏览器的不同。这就会导致在模拟的时候看到的界面是一个样子(因为使用的是浏览器渲染),真机调试的时候又是另一个样子。因为在手机上跑的服务是小程序自己的编译后的,跑在手机上的小程序环境。

所以我们需要把必要的属性全部都填写上。

<scroll-view class="scrollarea" scroll-y type="list">
<view class="container">
<text>蓝牙连接状态:{{status}}</text>
<div class="button-lines">
<input type="text" placeholder="请输入设备名称" bindinput="onDeviceNameInput" />
</div>
<!-- 3x7 矩阵布局 -->
<view class="button-grid">
<view class="row">
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendUp" bindtouchend="handleTouchEnd"></button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="connectDevice">连接</button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendA">A</button></view>
<view class="cell"></view>
</view>
<view class="row">
<view class="cell"><button bindtouchstart="sendLeft" bindtouchend="handleTouchEnd"></button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendRight" bindtouchend="handleTouchEnd"></button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendB">B</button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendD">D</button></view>
</view>
<view class="row">
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendDown" bindtouchend="handleTouchEnd"></button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendData">文件</button></view>
<view class="cell"></view>
<view class="cell"><button bindtouchstart="sendC" >C</button></view>
<view class="cell"></view>
</view>
</view>
</view>
</scroll-view>

样式代码

/**index.wxss**/
page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scrollarea {
flex: 1;
overflow-y: hidden;
}
.container {
padding: 20px;
}
.button-lines {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
input{
border: 1px solid #ccc;
padding: 8px;
margin-right: 5px;
width: 50%;
height: 30%;
}
.button-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}

.row {
width: 100%;
display: flex;
justify-content: space-between;
}

.cell{
width: 12%;
height: 50px;
margin: 5px;

display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}

.cell button{
border: 1px solid #ccc;
}
button {
width: 100%;
height: 100%;
box-sizing: border-box;
}

逻辑设计

考虑到不同设备的蓝牙名称不同,因此我在页面的中间设计一个输入框,输入对应的设备名称(大小写敏感)后,点击连接按钮,即可触发搜索接口。为了让自己知道是否已经连接上,我在输入框的上面添加了一个状态显示,考虑到部分用户不能理解红色绿色的默认含义,我使用了中文来描述连接状态。

底部做了一些按键发送数据的功能,包括:中文上下左右、英文ABCD、还有大文件一键传输(我设置了范围为txt和py)

tip

网页开发中,浏览器的渲染主线程会在解析DOM树的时候给所有HTML节点根据权重添加上属性,而小程序中,一旦缺省关键的属性,在开发界面会正常显示,上真机就会异常,这点尤其需要注意。

蓝牙设备的搜索、连接等功能由微信的API接口提供,其中蓝牙的权限上,如果使用的是:仅在使用中允许,在部分安卓手机上,会出现切后台再返回时蓝牙权限丢失的情况。因此改为:每次使用时询问权限。目前在官方论坛上留言了,我更倾向于是安卓设备的问题。

另外安卓中蓝牙权限与位置权限关联,因此仅开启蓝牙权限依然无法使用。

微信的蓝牙接口搜索到的设备便不再出现,假设我周边存在设备A、设备B、设备C

如果我首先输入了设备B,蓝牙搜索API根据信号强弱依次返回:设备A、设备B(判定成功,建立连接)

此时我再输入设备A,点击连接,就会出现搜不到设备的情况,当然这里是可以优化的,设置一个点击按钮:刷新。不过我右上角点击重新进入小程序也是可以的,所以这里就不是很有必要加这个逻辑判断。

蓝牙设备的连接非常简单,根据参考文档一步一步来即可,需要注意的是,发送中文时可能会乱码,JS原生的解码又不能用,所以我导入了一个包import TextEncoder from './miniprogram-text-encoder'来自动判断文本是中文还是英文执行对应的转化。

既然蓝牙可以通信,传输中文和英文,那么是不是可以传本书过去?首先尝试直接传输,发现接收方只收到了前20字节,后续数据丢失。那么修改程序,将文件分片、每次发20个字节,发送完成之后在发送一个END标记。

和之前发送数据的代码写在一起,就变成了这样:


import TextEncoder from './miniprogram-text-encoder'

Page({
data: {
status: '未连接',
deviceId: null,
serviceId: null,
characteristicId: null,
deviceName: 'None' // 默认设备名称
},
onLoad() {
this.initBluetooth();
},
onDeviceNameInput(e) {
console.log(e.detail.value);
this.setData({
deviceName: e.detail.value,
})
;
},
initBluetooth() {
const that = this;
wx.openBluetoothAdapter({
success(res) {
console.log('初始化蓝牙适配器成功');
that.startBluetoothDevicesDiscovery();
wx.showToast({
title: '蓝牙权限成功',
icon: 'success',
duration: 2000
});
},
fail(res) {
console.log('初始化蓝牙适配器失败', res);
wx.showToast({
title: '蓝牙权限失败',
icon: 'error',
duration: 2000
});
}
});
},
startBluetoothDevicesDiscovery() {
const that = this;
console.log(that.data.deviceName, '57');

// 如果 deviceName 是 "None",不进行蓝牙设备搜索
if (that.data.deviceName === "None") {
console.log('设备名称为 "None",不进行蓝牙设备搜索');
return;
}

wx.startBluetoothDevicesDiscovery({
success(res) {
console.log('开始搜索蓝牙设备');
that.onBluetoothDeviceFound();
},
fail(res) {
console.log('搜索蓝牙设备失败', res);
}
});
},
onBluetoothDeviceFound() {
const that = this;
wx.onBluetoothDeviceFound((devices) => {
devices.devices.forEach(device => {
console.log('发现设备名称:', device.name); // 打印所有发现的设备名称
if (device.name === that.data.deviceName) {
wx.showToast({
title: '发现蓝牙设备',
icon: 'success',
duration: 2000
});
that.createBLEConnection(device.deviceId);
}
});
});
},
createBLEConnection(deviceId) {
const that = this;
wx.createBLEConnection({
deviceId: deviceId,
success(res) {
console.log('连接蓝牙设备成功');
that.setData({
status: '已连接',
deviceId: deviceId
});
that.getBLEDeviceServices(deviceId);
},
fail(res) {
console.log('连接蓝牙设备失败', res);
}
});
},
getBLEDeviceServices(deviceId) {
const that = this;
wx.getBLEDeviceServices({
deviceId: deviceId,
success(res) {
console.log('获取服务成功:', res.services);
for (let i = 0; i < res.services.length; i++) {
if (res.services[i].isPrimary) {
that.getBLEDeviceCharacteristics(deviceId, res.services[i].uuid);
return;
}
}
}
});
},
getBLEDeviceCharacteristics(deviceId, serviceId) {
const that = this;
wx.getBLEDeviceCharacteristics({
deviceId: deviceId,
serviceId: serviceId,
success(res) {
console.log('获取特征值成功:', res.characteristics);
for (let i = 0; i < res.characteristics.length; i++) {
if (res.characteristics[i].properties.write) {
that.setData({
serviceId: serviceId,
characteristicId: res.characteristics[i].uuid
});
return;
}
}
}
});
},
connectDevice() {
this.startBluetoothDevicesDiscovery();
},
sendData() {
const that = this;
// 选择本地 TXT 或 PY 文件
wx.chooseMessageFile({
count: 1,
type: 'file',
extension: ['txt', 'py'],
success(res) {
const filePath = res.tempFiles[0].path;
const fileName = res.tempFiles[0].name;

// 读取文件内容为 ArrayBuffer
wx.getFileSystemManager().readFile({
filePath: filePath,
success(readRes) {
const fileBuffer = readRes.data;
console.log(readRes.data)
const chunkSize = 20; // 每次发送20字节
const totalChunks = Math.ceil(fileBuffer.byteLength / chunkSize);

// 发送文件名称和分片数
const fileInfo = `${fileName}|${totalChunks}`;
const fileInfoBuffer = that.stringToArrayBuffer(fileInfo);
wx.writeBLECharacteristicValue({
deviceId: that.data.deviceId,
serviceId: that.data.serviceId,
characteristicId: that.data.characteristicId,
value: fileInfoBuffer,
success(res) {
console.log('文件信息发送成功');
},
fail(res) {
console.error('文件信息发送失败', res);
}
});

// 逐块发送文件数据
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, fileBuffer.byteLength);
const chunk = fileBuffer.slice(start, end);
const progress = ((i + 1) / totalChunks) * 100;

// 发送当前块数据
wx.writeBLECharacteristicValue({
deviceId: that.data.deviceId,
serviceId: that.data.serviceId,
characteristicId: that.data.characteristicId,
value: chunk,
success(res) {
console.log(`数据发送成功: ${i + 1}/${totalChunks} (${progress}%)`);
if (i === totalChunks - 1) {
// 发送结束标志
const endBuffer = that.stringToArrayBuffer('END');
wx.writeBLECharacteristicValue({
deviceId: that.data.deviceId,
serviceId: that.data.serviceId,
characteristicId: that.data.characteristicId,
value: endBuffer,
success(res) {
console.log('所有数据发送完成');
}
});
}
},
fail(res) {
console.error(`数据发送失败: ${i + 1}/${totalChunks}`, res);
}
});
}
},
fail(err) {
console.error('文件读取失败', err);
}
});
},
fail(err) {
console.error('文件选择失败', err);
}
});
},

// 将字符串转换为 ArrayBuffer
stringToArrayBuffer(str) {
const base64 = wx.arrayBufferToBase64(new TextEncoder().encode(str).buffer);
return wx.base64ToArrayBuffer(base64);
},

// 发送控制消息
sendMessage(message) {
const that = this;
const buffer = that.stringToArrayBuffer(message);
wx.writeBLECharacteristicValue({
deviceId: that.data.deviceId,
serviceId: that.data.serviceId,
characteristicId: that.data.characteristicId,
value: buffer,
success(res) {
console.log(`消息发送成功: ${message}`);
},
fail(res) {
console.error(`消息发送失败: ${message}`, res);
}
});
},
// 松开按钮时发送消息
handleTouchEnd() {
this.sendMessage('释放');
},
sendUp() {
this.sendMessage('上');
},
sendDown() {
this.sendMessage('下');
},
sendLeft() {
this.sendMessage('左');
},
sendRight() {
this.sendMessage('右');
},
sendA() {
this.sendMessage('A');
},
sendB() {
this.sendMessage('B');
},
sendC() {
this.sendMessage('C');
},
sendD() {
this.sendMessage('D');
}

安卓系统有一个"运行时允许权限",该权限在不可复现的场景下会出现后台程序还在运行,但权限未授予。可以改为本次使用允许,让每次使用时都询问获取权限。

后话

这里是完整代码

程序的优化是无穷无尽的,所以这里我只实现了最少的功能,如果项目对你有帮助,不用问我,直接拿去用。

· 10 min read
Allen
normal software engineer
此内容根据文章生成,仅用于文章内容的解释与总结

购买了一款云台摄像头,你可以在淘宝搜这个关键词知道它长什么样子。

它默认能通过 RS232 协议控制云台转动,但是现在新的主板已经没有这种圆形的接口了,基本都是 USB。

他也可以通过遥控器控制,但是我想尝试自己编写逻辑代码通过键盘控制。

效果是通过监听键盘上下左右等事件,调用对应云台运动的指令,运动到合适角度之后可以按下空格停止云台运动,按下 ESC 退出控制程序。

也可以按下某个按键如数字1,直接运动到预设角度。

咨询商家后,商家提供了 RS232 协议的指令集,所以这里通过 python 的 serial 库尝试通过 USB 口对其云台调用。

RS232 协议说明书

CommandCommand PacketComments
Stop8x 01 06 01 VV WW 03 03 FFVV: Pan Speed
Left8x 01 06 01 VV WW 01 03 FFWW: Tilt Speed
Right8x 01 06 01 VV WW 02 03 FFYYYY: Pan Position
Up8x 01 06 01 VV WW 03 01 FFZZZZ: Tilt Position
Down8x 01 06 01 VV WW 03 02 FF
UpLeft8x 01 06 01 VV WW 01 01 FF
UpRight8x 01 06 01 VV WW 02 01 FF
DownLeft8x 01 06 01 VV WW 01 02 FF
DownRight8x 01 06 01 VV WW 02 02 FF
Absolute Position8x 01 06 02 VV WW 0Y 0Y 0Y 0Y 0Z 0Z 0Z 0Z FF
Relative Position8x 01 06 03 VV WW 0Y 0Y 0Y 0Y 0Z 0Z 0Z 0Z FF
Home8x 01 06 04 FF
Reset8x 01 06 05 FF

这里有一些复合指令,譬如 UpRight:向上的同时向右,如果是手柄控制比较好,键盘控制比较鸡肋,所以这里我们实现:上下左右、暂停、复位、绝对定位这几个能用到与可能会用到的。

基数转换

这里的绝对定位和相对定位部分,出现了0Y 0Y 0Y 0Y0Z 0Z 0Z 0Z,我希望传入一个 10 进制的角度,譬如0、90、180,怎么映射到其中呢?这就体现我们学完二进制之后的敏感度了,把 20 转成 2 进制的过程是:

20 ÷ 2 = 10 余数: 0
10 ÷ 2 = 5 余数: 0
5 ÷ 2= 2 余数: 1
2 ÷ 2=1 余数: 0
1 ÷ 2= 0 余数: 1
按余数倒序排列: 10100

这里我们观察范例,对数据做了拆分,即如果需要把10进制映射到16进制上,譬如17转成16进制是11,那么应该变成0101。每个位置之间插入0

所以可以写出如下代码:

def calculate_pan_position_bytes(pan_pos_value):
HEX_VALUES = [4096, 256, 16, 1] # 定义常量
pan_pos_ints = []
for i, value in enumerate(HEX_VALUES):
pan_pos_ints.append(pan_pos_value // value)
pan_pos_value %= value
# 转换为2位16进制字符串
pan_pos_strs = [f"{i:02X}" for i in pan_pos_ints]
return "".join(pan_pos_strs)
# 将17转化为16进制,应该是11,拆分加0,应该是0101
# 前方补0到总长度为8位,结果与我们预期一致
print(calculate_pan_position_bytes(17))
# 00000101

接下来通过代入0到6000这样的数值传输给串口后发现,只能向左转。

0对应居中,4500对应向左转180,数字再大也是转到底。

4500比180 = 25比1,所以我们输入角度,乘以25就得到了对应的信号值。

根据手册说明水平转动范围为355度,一半则是177.5度,与肉眼观察基本一致,Z轴的范围是上下各21度。

刚刚只能向左转,那么向右转的答案就呼之欲出了,要么是补码(异或运算后加1),要么是首位为符号位。我们添加上限位和映射,先用补码试试完成这个函数(结果直接成了)。

def calculate_pan_position_bytes(pan_pos_value, axis_type):
"""
计算轴(旋转)的位置字节。

参数:
pan_pos_value (int): 位置值,
axis_type (str): 轴的类型 ('y' or 'Y' for Y-axis, others for Z-axis)

返回:
pan_step_str (str): 计算得到的平移位置字节,格式为十六进制字符串。
"""
if axis_type.lower() == "y":
pan_pos_value = max(-177.5, min(pan_pos_value, 177.5)) # 限制取值范围
else:
pan_pos_value = max(-21, min(pan_pos_value, 21)) # 限制取值范围

pan_pos_value = int(pan_pos_value * 25) # 将角度转换为步长
pan_direction = "-" if pan_pos_value < 0 else "+" # 设定旋转方向
pan_pos_value = abs(pan_pos_value) # 取绝对值

HEX_VALUES = [4096, 256, 16, 1] # 定义常量

pan_pos_ints = []
for i, value in enumerate(HEX_VALUES):
if pan_direction == "+":
pan_pos_ints.append(pan_pos_value // value)
else: # 异或操作
pan_pos_ints.append((pan_pos_value // value)^ 0xF)
if i == 3 : # 最后一个数字,取反后加1
pan_pos_ints[-1] = pan_pos_ints[-1]+1
pan_pos_value %= value

# 转换为2位16进制字符串
pan_pos_strs = [f"{i:02X}" for i in pan_pos_ints]
return "".join(pan_pos_strs)

代码目标效果

希望具体的指令都可以通过 Python 函数来实现,同时暴露出所有可能需要修改的参数。最后关联键盘事件。例如:

camera_control.py
import keyboard
from usbcamera import *
from usbcamera import move_to_absolute_position
"""
设备 "/dev/ttyUSB0" 的云台旋转至绝对定位:
Y轴转到180度,速度为9.
Z轴转到30度,速度为10
"""
move_to_absolute_position(vv=9, ww=10, Y=180, Z=30, device="COM16")

# 关联键盘事件和控制函数
keyboard.on_press_key("up", lambda _: turn_up(device="COM16"))
keyboard.on_press_key("down", lambda _: turn_down(device="COM16"))
keyboard.on_press_key("left", lambda _: turn_left(device="COM16"))
keyboard.on_press_key("right", lambda _: turn_right(device="COM16"))
keyboard.on_press_key("enter", lambda _: move_home(device="COM16"))
keyboard.on_press_key("space", lambda _: turn_stop(device="COM16"))
# 按下数字1则转动到水平最左,垂直最下,可以根据自己需要多预设几个目标角度。
keyboard.on_press_key("1", lambda _: move_to_absolute_position(vv=10, ww=10, Y=180, Z=-30, device="COM16"))

# 让脚本保持运行状态以捕获事件
keyboard.wait("esc") # 按 'esc' 键退出

信号机制

  • 当收到左转信号时,摄像头会持续左转,直到到达限位位置或接收到新指令。

  • 如果想要提前结束左转,可以在发送左转信号一定时间后发送停止指令,摄像头收到停止指令时会停止。

  • 每个云台旋转操作会持续一定时间,如果在旋转期间收到其他指令,会终止旧指令,执行当前指令。

逻辑代码

通常在 Windows 系统上,串口名称通常是 COMx(如 COM1、COM2),而在 Linux 系统上通常是/dev/ttyUSBx(如/dev/ttyUSB0)。

usbcamera.py
#!/usr/bin/env python3
# coding:utf-8

import serial
import serial.tools.list_ports
import time

# VISCA命令集
commands = {
"stop": "81010601{vv}{ww}0303FF",
"left": "81010601{vv}{ww}0103FF",
"right": "81010601{vv}{ww}0203FF",
"up": "81010601{vv}{ww}0301FF",
"down": "81010601{vv}{ww}0302FF",
"upleft": "81010601{vv}{ww}0101FF",
"upright": "81010601{vv}{ww}0201FF",
"downleft": "81010601{vv}{ww}0102FF",
"downright": "81010601{vv}{ww}0202FF",
"absolute_position": "81010602{vv}{ww}{Y}{Z}FF",
"relative_position": "81010603{vv}{ww}{Y}{Z}FF",
"home": "81010604FF",
"reset": "81010605FF",
}


def send_visca_command(command, device):
"""
通过串口向摄像机发送VISCA命令。

参数:
command (str): 要发送的VISCA命令,格式为十六进制字符串。

返回:
response (bytes): 从摄像机接收到的响应。
"""
try:
ser = serial.Serial(device, 9600, timeout=1) # 初始化串口
command_bytes = bytearray.fromhex(command) # 将命令转换为字节
ser.write(command_bytes) # 发送命令
response = ser.read_all() # 读取响应
ser.close() # 关闭串口
return response
except:
ports_list = list(serial.tools.list_ports.comports())
if len(ports_list) <= 0:
print("未发现端口")
else:
for comport in ports_list:
if "USB" in str(comport):
print("发现USB端口:", comport.device, comport.description)


def calculate_pan_speed_bytes(pan_speed_value):
"""
计算轴(旋转)的位置字节。

参数:
pan_speed_value (int): 速度值,0-16

返回:
pan_step_str (str): 计算得到的平移位置字节,格式为十六进制字符串。
"""

pan_speed_value = max(0, min(pan_speed_value, 16)) # 限制取值范围

# 转为2位16进制
return f"{pan_speed_value:02X}"


def calculate_pan_position_bytes(pan_pos_value, axis_type):
"""
计算轴(旋转)的位置字节。

参数:
pan_pos_value (int): 位置值,
axis_type (str): 轴的类型 ('y' or 'Y' for Y-axis, others for Z-axis)

返回:
pan_step_str (str): 计算得到的平移位置字节,格式为十六进制字符串。
"""
if axis_type.lower() == "y":
pan_pos_value = max(-177.5, min(pan_pos_value, 177.5)) # 限制取值范围
else:
pan_pos_value = max(-21, min(pan_pos_value, 21)) # 限制取值范围

pan_pos_value = int(pan_pos_value * 25) # 将角度转换为步长
pan_direction = "-" if pan_pos_value < 0 else "+" # 设定旋转方向
pan_pos_value = abs(pan_pos_value) # 取绝对值

HEX_VALUES = [4096, 256, 16, 1] # 定义常量

pan_pos_ints = []
for i, value in enumerate(HEX_VALUES):
if pan_direction == "+":
pan_pos_ints.append(pan_pos_value // value)
else: # 异或操作
pan_pos_ints.append((pan_pos_value // value)^ 0xF)
if i == 3 : # 最后一个数字,取反后加1
pan_pos_ints[-1] = pan_pos_ints[-1]+1
pan_pos_value %= value

# 转换为2位16进制字符串
pan_pos_strs = [f"{i:02X}" for i in pan_pos_ints]
return "".join(pan_pos_strs)


def create_command(command_key, vv=10, ww=10, Y=None, Z=None):
"""
创建VISCA命令。

参数:
command_key (str): 命令键名。
vv (str): 水平方向速度,取值范围为0-16
ww (str): 垂直方向速度,取值范围为0-16
Y (str): 控制水平旋转的位置。
Z (str): 控制垂直旋转的位置。

返回:
command (str): 格式化后的VISCA命令字符串。

异常:
ValueError: 当命令需要Y和Z参数时,若未提供,则抛出异常。
"""
if command_key in ["home", "reset"]:
return commands[command_key]
if command_key in ["absolute_position", "relative_position"]:
if Y is None or Z is None:
raise ValueError("Y和Z为位置命令,必须提供")
return commands[command_key].format(
vv=calculate_pan_speed_bytes(vv),
ww=calculate_pan_speed_bytes(ww),
Y=calculate_pan_position_bytes(Y, "y"),
Z=calculate_pan_position_bytes(Z, "z"),
)

return commands[command_key].format(
vv=calculate_pan_speed_bytes(vv),
ww=calculate_pan_speed_bytes(ww),
)


# 控制函数示例
def turn_stop(vv=0, ww=0, device="/dev/ttyUSB0"):
return send_visca_command(create_command("stop", vv, ww), device)


def turn_left(vv=10, ww=10, device="/dev/ttyUSB0"):
return send_visca_command(create_command("left", vv, ww), device)


def turn_right(vv=10, ww=10, device="/dev/ttyUSB0"):
return send_visca_command(create_command("right", vv, ww), device)


def turn_up(vv=10, ww=10, device="/dev/ttyUSB0"):
return send_visca_command(create_command("up", vv, ww), device)


def turn_down(vv=10, ww=10, device="/dev/ttyUSB0"):
return send_visca_command(create_command("down", vv, ww), device)


def move_home(device="/dev/ttyUSB0"):
return send_visca_command(create_command("home"), device)


def move_to_absolute_position(vv=10, ww=10, Y=0, Z=0, device="/dev/ttyUSB0"):
return send_visca_command(create_command("absolute_position", vv, ww, Y, Z), device)

后话

硬件相比软件来说,资料比较少,所以编写过程主要靠经验。

猜测轴旋转的角度和 4 个参数对应关系是最有意思的过程,有趣的功能背后全是数学。