所有文章

Stripe在线支付功能的工作流程和安全机制解析

在 Web 世界,在线上支付流程中处理用户的信用卡号就是极高风险的操作。对于要在自己的应用程序中实现海外业务场景的全栈开发者而言,要处理跨国信用卡网络通信、3D Secure 动态认证以及严苛的 PCI-DSS 安全合规审查等。针对这个问题, Stripe 应运而生,其伟大之处在于,它通过极致优雅的 RESTful API 和基于 Webhook 的异步事件驱动模型,将极其混乱的现实资金流转,封装成了高度可预测的、开发者友好的接口。在这种情况下,开发者自己的服务器就可以永远不需要触碰、传递或保存用户的真实信用卡号(明文),只需要向 Stripe 发送指令,Stripe 处理完后返回一个 Token 告知支付业务指令的执行结果,极大的简化了这个支付业务的复杂程度。 Stripe在线支付业务的执行流程解释 下图通过在一个Android APP上利用Stripe完成在线支付的流程图来解释Stripe在线支付的完整流程。整个支付流程可以分为四个阶段: - 阶段1:建立支付会话 - 首先是在Android APP上,由用户点击支付购买商品/服务的按钮启动整个支付流程,此时APP首先向开发者自己的后台Spring Boot服务器发起一个支付购买的请求。 - 后台服务器收到请求后,在自己的数据库PostgreSQL上创建一个新的购买订单,因为此时支付还尚未启动,所以这个订单的状态是Pending。 - 然后后台服务器向Stripe Cloud发起请求,创建一个支付会话请求。 - Stripe服务器收到以后,会给后台服务器返回这个会话的URL和token ID。 - 然后后台服务器再把以上的URL和token ID原样通过与APP之间的长连接转发给APP。 - 阶段2:客户端与Stripe服务器之间的支付环节 - 经过第一阶段后,APP就收到了Stripe服务器的支付会话URL和token,此时APP就访问以上URL直接与Stripe的服务器之间会话完成真正的支付流程,这一步对于开发者的APP以及后台服务器而言都是完全透明的。 - 阶段3:支付完成后给后台服务器的通知 - 当用户在APP中完成与Stripe的支付流程后,APP UI处于等待支持确认的页面,此时Stripe服务器向开发者的后台服务器发送支付确认信息。 - 后台服务器收到支付确认后,修改PostgreSQL数据库中的购买订单状态为PAID,在数据库中修改用户的权益信息。 - 最后后台服务器再通过websocket或者向APP的轮询请求发回购买完成的确认信息,APP刷新UI显示,给用户展示出来已经购买的商品或者服务信息。 !stripe-architecture-flow.png Stripe服务器通知支付事件的Webhook机制 在以上的流程中,开发者的后台服务器向Stripe服务器发起请求创建支付会话请求(以上流程图的第3步)后,在后续的支付会话中,Stripe 服务器并不会与后台服务器之间开启和维持一个长连接会话(这样太浪费Stripe服务器的内存了),而是通过一个Webhook的机制在用户完成支付后,由Stripe服务器主动发起请求向开发者后台服务器注册的WebHook URL发出通知,告知支付完成的状态。 因此,为了支持这个Webhook机制,在开发者注册自己的Stripe账号时,需要在Stripe的开发者后台控制台里,把自己的服务器公网地址提前注册进去,这样后续在用户完成支付以后,Stripe服务器才能够知道WebHook的URL,并向其发出支付完成通知。 所以,开发者后台服务器与Stripe服务器之间的通信始终都是标准的RestFul无状态通信模式,每次开发者的后台服务器向Stripe服务器发起请求创建一个支付会话请求后,Stripe服务器返回一个支付会话的URL和Token ID以后,这个连接就关闭了。后续等待支付完成后,Stripe 服务器再通过这个Webhook的机制向开发者后台服务器发回事件完成通知。 以下是用户完成付款后,Stripe服务器向 WebHook URL 主动发起支付事件完成的流程图,其中包含了加密认证信息来确保通信的安全性,同时针对可能发生的 WebHook 通信失败的情况,Stripe 服务器也会在一段时间内重发事件告知的请求,以确保整个支付流程和通知的机制得到完整闭环处理: !stripe-server-webhook.png 前端与Stripe服务器之间的通信模式:Checkout vs Payment Element 对于前端(APP,Web)而言,在开启支付会话请求并且从后台服务器得到Stripe服务器返回的会话URL和Token ID以后,真正的支付流程是在前端和Stripe服务器之间的通信来完成的。此时前端与Stripe服务器之间的通信模式主要可以分为两种:Checkout和Payment Element。 Checkout模式 Checkout模式的运行机制为,当后台服务器调用 Stripe API 创建支付会话后,Stripe 服务器会生成一个逻辑订单,并动态渲染一个专属于这笔订单的 SPA(单页面应用)网页,这个网页会部署在 Stripe 的域名下(通常是 checkout.stripe.com)。然后把这个Checkout URL返回给开发者的后台服务器并转发给前端。 在这种情况下,开发者只需要在自己的 App 或前端网页中,通过重定向 (Redirect) 或打开内置浏览器来访问这个 Checkout URL即可。 Stripe 在这个页面里注入了非常强大的功能。如果它探测到用户用的是 Safari,它会自动展示 Apple Pay;如果是 Chrome,展示 Google Pay;它甚至会自动根据用户的 IP 切换多国语言和本地货币(比如在欧洲自动显示 iDEAL 支付)。而这些复杂的探测逻辑,不需要开发者写一行代码,全部由 Stripe服务器自动处理。 Payment Element 原生模式 该模式的运行机制为,用户在支付页面中不离开开发者的域名或 App,需要开发者自己在 Web 页面或者 APP 的前端用 HTML/CSS 或者 Android 的原生组件画一个美观的结账页面(包含购物车、收货地址等),也就是说支付页面是由开发者自己设计并呈现给用户的。 当然,为了确保支付流程是安全隔离的,在这个前端支付页面需要用户输入信用卡号的那部分区域,必须通过集成 Stripe 的前端 SDK,向 Stripe 申请渲染一个 Payment Element 组件。这个组件在 Web 上本质上是一个跨域的 (在 App 里则是一个安全视图)。 在这种设计的情况下,即使支付页面的输入框由开发者自行设计,用户敲击键盘输入银行卡号,是直接输入到了 Stripe 的 iframe/安全视图里,并直接发送给 Stripe 的金库,开发者自己实现的外层 JavaScript 或 Android 代码无法读取这些明文密码,这样就可以完美绕过 PCI 合规审查,并确保整个支付环节的流程及其安全性完全由Stripe负责。 以上两种模式的处理流程图如下图所示: !stripe-checkout-vs-element.png 那么对于Stripe来讲,它是如何区分这两种模式呢? 答案是:Stripe 根本不关注前端究竟使用了哪种模式,它只关心在支付请求发起的时候,开发者的后台服务器向 Stripe 服务器发起请求的时候采用的究竟是Checkout 模式的 API 还是 Payment Element的API。 - 使用 Checkout 模式时,后台服务器向 Stripe 发起的 API 请求对应的API是POST /v1/checkout/sessions,参考https://docs.stripe.com/api/checkout/sessions。此时 Stripe server 会生成一个带支付界面的网页,并且把这个网页的URL返回给后台服务器。 - 使用 Payment Element 模式时,后台服务器向 Stripe 发起的 API 请求对应的API是POST /v1/payment_intents,参考https://docs.stripe.com/api/payment_intents。此时 Stripe Server 会在内存里创建一个订单记录,并生成一个一次性密钥返回给后台服务器,此后前端会基于这个密钥与 Stripe Server 通信完成支付。 Stripe的安全通信机制及其密钥 如以上流程图所示,在整个支付业务的通信过程中,涉及到了前端、后端以及Stripe服务器三方,为了确保整个支付业务在通信过程中的安全性,Stripe 提供了三组密钥用于确保通信过程中的身份认证和签名确认。这三组密钥在Stripe 的开发者控制台中生成: - Publishable Key (公钥,以 pk_ 开头):硬编码在前端(App/网页)的代码中,是作用是让前端在与 Stripe 服务器通信的过程中,向 Stripe Server 证明“我是谁家的前端”。 - Secret Key (私钥,以 sk_ 开头):必须且只能安全地存储在后端服务器的环境变量中,绝对不能泄露给前端或提交到 Git 仓库。其作用是在后端调用 Stripe API(如创建订单、退款、查询账单)时,将其放在 HTTP 的 Authorization: Bearer 头部,Stripe Server 据此可以得知这个支付请求来自哪个商户。 - 实际上除了 Secret Key以外,还有一个Restricted Key,功能类似于Secret Key,只不过是Secret key的功能子集,只具有Secret Key的部分权限,如果只需要创建支付会话的功能,就可以使用Restricted Key,避免更高权限的Secret Key泄露。 - Webhook Secret (签名密钥,以 whsec_ 开头):同样只能保存在后端服务器的环境变量中。其作用是专用于验证从 Stripe 发到后端服务器的 Webhook 请求是否被黑客中途篡改。 需要注意的是,整个 Stripe 支付流程业务的所有通信都是在 HTTPS/TLS 链接的基础上进行的,以上的三组密钥都是在 HTTPS 的加密信道中以明文传递,用于进行身份的认证,不会参与数据的加解密运算。 如下图所在,整个支付业务的流程中,分别使用了三组密钥来进行通信角色的身份认证和鉴权: - 环节一:在后台服务器向 Stripe server 发起支付会话请求的通信中,后台服务器需要把SK/RK放在其HTTP消息中,这样Stripe Server收到后,通过查询这个SK/RK的身份归属以及确认其是否有创建会话的权限。 - 环节二:在前端与Stripe Server之间进行的实际支付业务的交互中,前端需要在其HTTP消息中把PK传递给Stripe Server,Stripe Server根据这个PK得到与其通信的前端的身份归属。 - 环节三:前端与 Stripe Server之间的支付流程结束后,Stripe Server要通过Webhook机制向后台服务器发出事件通知,后台服务器收到这个消息以后需要通过Webhook Secret密钥对消息的内容进行验签,确保这个消息是由Stripe Server发过来的。 !68951394-a233-4ed6-9d9b-76fd3a268714.png

RAG系统工作流程的几个问题澄清

本文简单总结了RAG系统的整体工作流程和模块分解,并在其基础上解释了 RAG工作流程以及向量数据量存储与检索向量相关的几个问题。 RAG 系统的基本流程与基本模块组成 下图是一个典型的RAG系统的工作流程分解图: !78e0975c-3248-43c4-a2f6-100c321de106.png 从模块拆解的角度上,RAG系统主要包含有以下几个独立的模块: - Indexing (索引层):将非结构化文档清洗、基于语义拆解为不同长度的chunk、嵌入(Embedding,即转换为向量的操作)并存入向量数据库(Vector DB)。 - Retrieval (检索层):根据用户提问题的 Query 消息计算用户问题文本与向量数据库之间向量的余弦相似度,从向量数据库中召回 Top-K 个相关片段。 - Augmentation (增强层):将从向量数据库中召回的片段与原始的 Query 文本拼接,构建增强后的提示词文本(Prompt)。 - Generation (生成层):把增强后的提示词文本送入 LLM,由 LLM 根据增强后的上下文输出最终答案。 以上工作流程和模块拆解简单直接,本文后续部分对 RAG 系统在工作中的一些基本概念再进行澄清,以确保对整个系统工作的各个方面有更为清晰的理解。 向量数据库中存储向量的维度问题 如上图所示,加入知识库的文档,首先会根据语义被分割为不同长度的 chunk(也就是说,每个chunk包含有不同数量的token),那么如果每个chunk包含的token数量不同的话,他们对应的Embedding 向量的数据维度也就是不同的? _针对以上问题,实际的情况是:无论输入(包括用户输入问题的文本,以及RAG知识库中分解出来的文本Chunk)的文本是长是短(只要在模型的最大上下文窗口内),经过同一个 Embedding 模型处理后,其输出的向量维度(Dimension)永远是固定且相同的。_ 因此,即使两个 chunk 的长度不同,包含的 token 数量不等,经过 Embedding 模型转换以后,转换后的向量维度都是相同而且固定的,这个固定的向量维度都与 Embedding 模型所指定的 token 的维度相同。 在具体的转换流程中,无论是知识库文本的某个 Chunk A 还是 Query,经过模型降维和特征提取以后,输出都是一个 维度的浮点数数组(一维向量)。 也正因为如此,后续才能对两个维度完全相同的向量(即用户输入问题文本对应的向量,以及向量数据库中某个 chunk 对应的向量)进行相似度的计算。 那么,不同长度的文本是如何变成同样长度的向量的? 这是由于底层大语言模型(基于 Transformer 架构)在输出层使用了池化(Pooling)机制。具体实现中包含以下两个步骤: - Token 映射: 不同长度的文本首先被转为 Token 序列,每个 Token 在模型内部会映射为一个隐藏状态向量。此时是一个 的矩阵。N 为Token数量,D 为Token的维度。 - Pooling(降维聚合): 模型会通过某种策略将这个 的矩阵压缩成一个 的向量,代表整段文本的全局语义。这个 Pooling 操作常见的做法包括: - Mean Pooling(平均池化): 将所有 Token 的向量在每一列上求平均值。 - CLS Token: 提取输入序列最前面特殊的 [CLS] (Classification) Token 对应的向量,它被训练用来表征整句话的语义。 向量相似度的计算逻辑 如上所述,无论是用户输入问题的文本 prompt 还是保存在向量数据库中知识库文档文本段落对应的向量,其维度都是相同维度的 。在后续进行两个向量的相似度计算时,主要使用的逻辑是余弦相似度 (Cosine Similarity) 和 内积 (Dot Product)。 余弦相似度衡量的是两个向量在空间中的夹角,以向量 (Query)和向量 (Chunk)为例,它们的维度都是 : > 💡 在自然语言处理或音频检索中,特征向量的模长往往代表文本的长短或音频段的能量大小,而向量的方向才代表真正的语义或特征属性。因此使用以上的余弦相似度计算公式,可实现规模与抽象语义两者之间的的解耦。其中的分子部分 ()实际上就是两个向量之间的内积(Dot Product),用于衡量两个张量在各维度上的绝对重合度。而其中的分母部分 ()是一个标量,作为归一化因子,强制消除因张量范数(模长)的不同而带来的缩放误差。 两个相同维度的向量,经过以上公式计算以后,其结果的范围在 -1 到 1 之间。 - 结果值越接近 1,说明两个向量的夹角越小,向量在空间中接近重合,语义越相似,是 RAG 召回的完美目标。 - 结果值趋近于0,说明向量在空间中相互垂直,可以认为两个向量之间完全无关(即正交),两段文本讨论的是完全不同的主题,特征维度无交叉。 - 结果值接近于-1,说明向量在空间中是背道而驰,语义上是相反/对立的。在某些特定训练的 Embedding 空间中,这种情况代表语义的完全对立(如好与坏的极端)。 然后向量数据库基于这个公式(或其近似的高效算法如 HNSW)对向量数据库中保存的所有向量与用户提问问题的向量进行以上计算,召回计算结果中最匹配的 Top-K 个文档。 而在工程实践中,如果在保存到向量数据库中的向量在入库前,已经对所有的文档向量 进行了 归一化(也就是使其模长为 1),同时对查询向量 也做了相同的归一化操作,那么上面公式中的分母就变成了 1。此时,余弦相似度的计算就简化成了内积的计算: 向量数据库的检索流程 以上部分给出了通过两个向量的余弦相似度计算公式,找到与用户提问问题语义最接近向量的计算过程。但是,如果 RAG 数据库中所保存的内容很多,向量数据库中的记录数量很多的话,如果每次检索都要与所有的数据库进行执行余弦相似度计算的话,那么这个计算量是非常惊人的。 以下详细总结和解释一下在向量高速检索领域应用最主流的HNSW(Hierarchical Navigable Small World,分层可导航小世界)算法。 整体而言,这个所谓的HNSW算法采用了一种多层图(Graph)空间的方式来解决海量向量的计算和搜索问题,在这种多层图结构下,高层网络的节点稀疏,负责快速定位目标节点所在的大致区域,低层网络节点密集,负责执行精细的搜索并准确的定位到目标节点。这样的设计中,绝大多数节点之间并不直接相连,但任两点间只需很少的几步即可找到。 所谓的多层图结构,实际上就是把向量数据库中的每个向量在入库阶段放到不同的层上,高层节点包含有低层的连接信息,这样在进行向量检索的时候,先通过高层节点进行大致定位,然后通过找到的高层节点中的低层节点连接信息下沉到底层,逐步精准定位到距离最近的节点。 每个节点中包含的信息大致使用如下数据结构来进行描述: - Layer 2 的节点(即Max Level = 2): 包含 Layer 0 + Layer 1 + Layer 2 的连接信息。 - Layer 1 的节点(Max Level = 1): 包含 Layer 0 + Layer 1 的连接信息。 - Layer 0 的节点(Max Level = 0): 只包含 Layer 0 的连接信息。 在这种情况下,每次进行向量搜索的时候,先在 Layer 2 的向量列表中找到距离最近的向量 Node-Layer-2,然后拿到向量节点 Node-Layer-2 中保存的 layer_1_neighbors 列表,从中找到 Layer 1 中距离最近的向量 Node-Layer-1,再继续从 Node-Layer-1向量节点中拿到其对应的 layer_0_neighbors 列表,最终从这个列表中找到与目标向量最近距离的 Layer 0 向量。 因此,HNSW 算法的向量搜索流程大致就如下图所述,因为实际的RAG 工作流程中并不只是返回一条记录,而是要返回 Top K 条记录,因此在以上的检索流程中,往往还要在每一层增加一个备选列表,最终返回的结果就是前 K 个距离查询文本向量最近的向量列表: !HNSW_Architecture_Native.png 向量数据库的存储内容 接下来的问题是,对于 RAG 的向量数据库而言,其中保存了文本分割后所有 Chunk 对应的向量。那么在工程实践中,通常这些向量数据库中的每一条记录都保存了哪些信息呢? 通常情况下,一条标准的向量数据库记录至少包含有以下组件: - Primary Key (主键 ID):数据库记录的主键,跟关系型数据库的主键的功能类似,数据格式通常是 Int64 或 String (UUID),作为物理存储和内存索引映射的唯一凭证。 - Dense Vector (稠密向量):这是向量数据库存储记录的核心内容。其数据格式是一个浮点数数组(如 Float32, Float16 或 INT8),对应于各个文本 Chunk 的向量内容。向量数据的检索就是对这个稠密向量与用户输入 prompt 文本对应的向量进行的余弦相似度计算。 - Scalar Metadata (标量/元数据):用于保存伴随该向量存储的一些结构化属性,一般用做属性过滤(例如可以使用该属性区分当前的文本 Chunk 是哪个文本文件中的内容),数据类型通常为 Int, Varchar, JSON 等格式。 可以看到,以上向量数据库中的记录中并不包含原始的文本 Chunk 数据,那么这样的话,后面在进行向量搜索的时候,通过余弦相似度算法搜索到某个向量以后,它如何定位找到这个向量对应的 Chunk 文本以方便与用户输入问题 Prompt 文本进行拼接呢? 针对以上问题,最直接的做法就是:把文本 Chunk 的内容直接作为上述 Scalar Metadata 部分的一个特殊字段(如 VARCHAR 类型),将其与向量一起存储在向量数据库内部。这样后续检索到向量的同时,底层就可以直接把这个向量对应的 Chunk 文本字符串一起返回。 但是,以上方案存在的问题是,向量检索计算的本质是密集的浮点运算,它要求参与计算的特征数组必须驻留在 L3 Cache 或高速 RAM 中。纯文本内容的 Chunk 数据对于向量计算过程毫无用处,但如果把整个向量数据库的内容(包括以上提到的 Chunk 文本内容)一起加载在内存里,会导致严重的内存碎片和计算过程中的 Cache Miss。 因此,目前的主流向量数据库(如 FAISS、Qdrant)在进行向量存储的时候,只会存储 [Primary Key, Dense Vector]部分,而原始的文本 Chunk 内容、Metadata(如来源文档名、页码等)等海量文本信息,会存储在另外一个专门的 Key-Value 数据库(如 RocksDB, Redis)或文档数据库(如 MongoDB, Elasticsearch)中。此时,Primary Key 充当了连接两个异构数据库的外键(Foreign Key),等到向量检索计算找到向量以后,就可以通过 Primary Key 从另外一个数据库找到其对应的 Chunk 文本以及其他的 metadata 。

YOLOv8-Pose模型的网络结构及其输出数据解读

在YOLOv8模型网络架构解读一文中,我已经在对于YOLOv8与YOLOv5模型结构差异的基础上,详细解释了YOLOv8模型的网络结构尤其是检测头部分的设计与计算流程,本文则重点在对比YOLOv8与YOLOv8-Pose网络结构的基础上,对其检测头部分及其输出数据的维度和结构进行详细总结和说明。 回顾标准YOLOv8模型的检测头结构 首先给出结论:YOLOv8-Pose 与标准 YOLOv8 模型在 Backbone 和 Neck 上是完全一致的,其核心架构差异 100% 集中在 Head(检测头)的设计、损失函数的定义以及对应的数据后处理上。所以对YOLOv8-Pose模型的学习,需要建立在对标准的YOLOv8模型有比较清楚的理解的基础上,具体可以参考YOLOv8模型网络架构解读。 标准YOLOv8模型的检测头结构如下图所示。简单来讲,就是整体网络的BackBone部分对输入图像的特征进行提取,Neck部分对不同尺度特征图中所包含的特征信息进行充分的融合,最终把充分融合后的特征信息送入到检测头部分。YOLOv8模型的检测头部分分为两个独立的处理路径,回归头采用CIoU+DFL(抛弃了之前几代延续使用的Anchor-Based的机制)计算和输出检测框的坐标位置,分类头则采用标准的BCE计算各个检测框对应的类别分类。 最终YOLOv8模型的整个检测头输出8400个检测框坐标及其类别信息(置信度直接使用类别信息中的最大值),在后处理阶段则从这8400个检测框中过滤出来最终的有效检测框作为最终检测输出。 !image.png YOLOv8-Pose模型的检测头结构 如上所述,YOLOv8-Pose模型与标准的YOLOv8模型的差异只在于其检测头Head部分: - 标准 YOLOv8 Head: 与YOLOv5等前代版本最大的不同在于采用了解耦头(Decoupled Head),Head部分分为两个并行分支:Box Branch(回归边界框与 DFL)和 Cls Branch(分类)。 - YOLOv8-Pose Head: 在标准的YOLOv8 Head的基础上演变为三分支结构 (Tri-Branch)。在原有 Box 和 Cls 分支的基础上,新增了一个独立的关键点分支 Keypoint Branch。 下图是YOLOv8-Pose模型的检测头结构示意图: !e6de4f91-bf0b-4cbf-bb68-b57d9aa15400.png 可以看到,YOLOv8-Pose模型的输出头的结构中,坐标回归头分支Box Branch和分类分支Cls Branch与标准的YOLOv8模型的检测头部分没有任何差异,区别只在于Pose模型额外增加了一个独立并行的关键点分支KeyPoint Branch。 YOLOv8模型的输出及其过滤 要搞清楚YOLOv8-Pose模型输出数据的过滤逻辑和后处理流程,首先要彻底搞清楚标准YOLOv8模型的后处理流程。 YOLOv8 模型默认是使用COCO数据集来进行预训练的,COCO数据集有80个分类,所以对于标准的YOLOv8模型而言,其坐标回归头输出的每个检测框的坐标位置数据维度为64(具体可参考YOLOv8模型网络架构解读),其类别分类头的输出每个检测框对应的数据维度为80(因为有80个类别),而三个尺度(P3/P4/P5)的特征图总共输出8400个检测框,所以整个检测头的输出维度为 [8400,144]。 在进行后处理的NMS流程之前,坐标回归头的每个检测框的长度为64维度的数据,首先会通过 DFL 的积分操作(Softmax 加权求和)还原成 4 维的绝对坐标(通常是 )。因此,送入后处理阶段(包含 NMS)的模型原始输出张量,其每一个检测框所包含的数据是:4 维坐标 + 80 维类别概率 = 84 维。 至此,YOLOv8模型的输出的维度为 [8400,80+4]。然后,基于以上类别分支输出的80维度的类别数据,对其求 Max,只保留每个检测框的最高类别得分及其索引,把这个最高类别得分作为其置信度。至此的数据维度变成: - 坐标: - 得分: - 类别: 在此后的后处理流程中,主要包含有两个串行的核心步骤和两个阈值(conf_thres和 iou_thres): - 置信度初筛: 基于前述的置信度进行粗筛,去掉所有置信度小于conf_thres阈值的检测框,这样就可以使用极小的算力剔除绝大多数背景框。 - IoU 抑制 (IoU-based NMS): 进一步的精筛过程,这一步是利用几何交并比IoU(如果两个检测框的IoU大于iou_thres阈值,就认为是同一个物体)剔除同一个物体的冗余框。 最终通过以上IoU NMS过滤后的检测框就是模型检测最终输出的结果。 YOLOv8-Pose模型的输出及其过滤 YOLOv8-Pose 模型预训练使用的是 COCO Keypoints (Pose) 数据集。在这个数据集的官方标注中,只有人这一个类别拥有 17 个关键点的完整标注,其他 79 个类别在官方数据集中是没有关键点标注的。因此,Ultralytics 官方在训练 yolov8n-pose.pt 时,其配置文件(如 coco-pose.yaml)中硬编码了 nc: 1(Number of Classes = 1,即仅限人这一个检测类别)。 这就是为什么在官方提供的 YOLOv8-Pose 预训练模型中,Cls 分支的维度是 1,而不是 80 的原因所在。 而关键点分支的输出,在使用 COCO Keypoints (Pose) 数据集的情况下,标注数据中共包含有 17 个人体关键点。每个关键点需要回归 3 个值: - 特征图上的坐标位置 x - 特征图上的坐标位置 y - 可见度置信度 visibility,用于判定该点是否被遮挡或在图像视野外 因此可以看到,关键点分支针对每个检测框输出 17 x 3 = 51维度的关键点数据。 那么,对于YOLOv8-pose模型而言,增加了一个关键点分支的话,模型输出到最终的NMS过滤输出的后处理逻辑是什么呢? 答案是,在YOLOv8-Pos模型的后处理逻辑的运行过程中,整个后处理流程根本不看关键点长什么样,关键点仅仅是被当做边界框的附属数据。框生则点生,框灭则点灭。 也就是说,在YOLOv8-Pos模型的后处理流程中,只有 Box 坐标回归分支和 Cls 类别分支的输出参与置信度初筛和 IoU 计算(这部分流程与标准的YOLOv8模型完全相同),以上的后处理过程最终得到存留框的索引。最终在输出数据的组织中,再利用上一步生成的留存框索引,从原始的 [8400,56] 维张量中,把留存框的坐标、类别、置信度连同它的 51 维关键点一次性全部捞出来。 - 经过后处理流程过滤后得到了3个Person的检测框,也就会同时输出这3个Person框中每个关键点的位置。 所以从根本上讲,YOLOv8-Pos模型的后处理流程与标准的YOLOv8模型没有任何区别,只不过是在最后输出的时候“顺便”把关键点分支的数据一起输出了。 从源码的角度看待YOLOv8-Pose的输出 以下是一段使用YOLOv8-Pose模型对图像数据进行推理的代码: 以上代码中模型的推理结果results ,实际上是一个 Python的列表对象,这个列表的长度等于输入数据的 Batch Size。如果每次调用模型进行推理输入的是单张图像,其推理结果的核心数据就都在 results[0] 中。 results[0] 是一个 ultralytics.engine.results.Results 类的实例,它就像一个集装箱,其内部按任务装载了不同的子对象: - results[0].boxes: 封装了经过 NMS 过滤后的所有边界框信息。 - results[0].keypoints: 封装了跟随边界框存留的所有关键点信息。 - results[0].orig_img: 存储了原始输入的 Numpy 图像(主要用于后续调用 plot 绘制边界框和关键点的底图)。 - results[0].names: 检测类别的映射字典(例如 {0: 'person'})。 其中边界框所包含的主要数据信息及其维度(模型输出的有效检测框数量为N)为: - boxes.xyxy:维度为 [N, 4],对应每个框的绝对坐标,其中左上角 x1, y1,右下角 x2, y2。 - boxes.xywh:维度为 [N, 4],对应每个框的中心坐标与宽高(cx, cy, w, h) - boxes.conf:维度为 [N], 这 N 个框各自的最大类别置信度得分。 - boxes.cls:维度为 [N],这 N 个框各自对应的类别索引。 - boxes.data:维度为 [N, 6],原始的拼接张量,按照 x1, y1, x2, y2, conf, cls 顺序依次排列。 其中关键点数据所包含的主要数据信息及其维度(模型输出的有效检测框数量为N)为: - keypoints.xy:维度为 [N, 17, 2],N 个人,每个人 17 个关键点,每个点对应的绝对坐标 x, y。 - keypoints.xyn:维度为 [N, 17, 2],归一化坐标。x 除以图像宽,y 除以图像高,范围在 [0, 1] 之间。 - keypoints.conf:维度为 [N, 17],N 个人,每个人 17 个关键点的可见度/置信度。 - keypoints.data:维度为 [N, 17, 3],拼接张量,按 x, y, conf 顺序排列(即前述的 51 维数据的重组形态)。

大模型交互中KV Cache的工作原理总结

本文通过描述Agent与大模型之间的交互流程,详细解释Input Token和Cached Token的区别,理解了这两者的区别,也就能够理解这两种token在算力需求和价格上的差异所在。 Input Token和Cached Token的区别 我们从各个大模型厂商购买Token额度的时候,看到有三种不同的价格:输入价格(缓存命中),输入价格(缓存未命中),输出价格。输入和输出Token的概念很好理解,但是这里的缓存命中和缓存未命中的区别是什么呢? !b8e61c06-b89d-4e35-ae1a-41f50eca9547.png 同样的,我们在使用各种Agent,例如Claude Code、Gemini Cli的时候,在结束本轮会话退出时,Agent都会输出本轮会话的输入Token、缓存Token以及输出Token的用量统计数据,那么这里的Input Tokens和Cache Reads之间的区别究竟是什么呢? !28145375-6db3-447c-a161-6ec65851c5d6.png 如果没有Cached Token的话? 首先简化问题,在不考虑任何缓存机制的情况下,Agent 与大模型之间的通信始终是绝对的无状态函数交互。大模型没有任何记忆,每次的交互和调用对它来说都是一次全新的过程。 在Agent(或者与大模型进行对话的Web页面)与LLM大模型之间进行会话的交互过程中,两种的角色分工如下: - Agent(智能体): 运行在本地机器或云服务器上的一段代码,是整个应用会话的业务控制中心,负责维护双方对话历史记录、管理工具(API 接口)以及控制执行流程。 - LLM(大模型): 云端的逻辑推理引擎。它只负责一件事:通过HTTP协议从Agent上接收一段纯文本输入,根据概率预测并输出下一段文本。 正是因为大模型在整个交互过程中没有任何记忆,所以在 Agent 与大模型之间进行会话的过程中,无论整个会话过程会持续多少轮,Agent在每次发起新一轮的会话时,都需要把所有的历史记录(Agent的问题以及大模型的答复)全部积累起来,发给大模型,这样大模型在收到以后才会对从头开始双方的会话记录理解的基础上,针对新的问题返回与之对应的答复。这样随着会话轮数的增加,Agent 发给大模型的历史会话记录自然会如滚雪球一般越来越大: !%E5%AE%8C%E7%BE%8E%E5%AF%B9%E9%BD%90%E7%89%88_%E4%B8%8A%E4%B8%8B%E6%96%87%E6%BB%9A%E9%9B%AA%E7%90%83%E6%95%88%E5%BA%94.png 这样就会带来两个问题: 问题一,带宽与 Payload 的体积爆炸问题: 每一轮交互,Agent 都要把几十 KB 甚至几 MB 的历史会话记录重新通过 HTTP 发送一遍。如果会话记录中包含有代码库或者PDF文件的话,经过很多轮的累计,其所需要的代码和内存空间是灾难性的。 问题二,算力极度浪费(滚雪球效应导致 Input Token 飙升)问题: LLM 本身是无状态的。在第 N 轮对话时,前 N-1 轮的文本 LLM 已经计算过 N-1 次了,但在没有缓存的情况下,它必须机械地再把这些文本的矩阵乘法重新算一次,这个算力就白白浪费了。 针对以上的这两个问题,就很有必要通过在 LLM 服务器端缓存KV Cache的方式来解决了。 KV Cache 如何解决问题? 目前主流的大语言模型都是 Transformer 架构,Transformer 架构中的内部计算流程细节可参考一文彻底搞懂Transformer模型的Encoder结构与计算流程 和一文彻底搞懂Transformer模型的Decoder结构与计算流程 两篇文章。 简单的总结,每个词对应的向量 Token 进入 Transformer 架构中进行注意力运算时,它的词向量 会分别乘以三个不同的权重矩阵 ,从而得到这个词在当前上下文语境中精准语义表达的的 向量。 当模型在工作的过程中要生成下一个词时,它会生成当前的 ,并使用这个当前的 向量,去和前面所有历史 Token 的 进行点积计算(算相关性分数);算完相关性分数后,再把所有历史 Token 的 按照分数加权求和。 在以上的这个注意力计算过程中,历史 Token 的 Q 是没有用到的,但它们的 K 和 V 则会在后面的 Token 生成中反复使用,而 KV Cache 要存的就是:_输入文本在经过每一层注意力运算时,通过矩阵乘法算出来的所有的_ _向量和_ _向量_。 这样下次新一轮会话进来的时候,历史会话中所有 token 的 KV 值就不需要计算了,直接从内存中读取即可。只需要计算新一轮会话内容的 KV 值,然后与历史会话的 KV cache 合并/拼接在一起即可。这样在 LLM 计算的过程中就少了计算历史会话文本所有 token 的这个环节。 通过下面的流程图来对KV Cache 缓存机制的运作流程进行解释: - 阶段一会对整个会话的完整上下文进行一次全量(总共1000个token)的注意力计算,计算完成后会把其结果的 KV Cache 保存起来。 - 阶段二在生成每个词的时候,就不再需要对上下文的文本内容进行全量注意力计算,只需要使用新的 向量与 KV Cache缓存的内容进行计算得到输出,然后把新生成词对应的 和 向量追加了 KV Cache 在后续环节中继续使用。 !LLM%E5%8F%8C%E9%98%B6%E6%AE%B5%E6%8E%A8%E7%90%86_Prefill%E4%B8%8EDecode.png 以上就是在大模型服务器端把 KV Cache 缓存起来能够极大程度上降低对 GPU 算力的需求和浪费。 隐式缓存问题和显式缓存问题 如上所述,通过在服务器端缓存 KV Cache 的方式确实是可以有效的解决算力浪费的问题(即以上的问题二)。但是需要注意的一点是,服务器的内存是有限的而且极其昂贵的资源,海量用户的海量请求需要在服务器端维持大量的缓存,这样也就意味着,这些缓存不可能一直保存,一定有一个时效性。 如果服务器端缓存的 KV Cache 的时间超过了其时间限制(例如Agent或者用户在当前的会话中超过1个小时没有新的请求),这个KV Cache就会被删除。那么,在这种情况(KV Cache超过时效被删除)下,用户又有新的请求发到大模型服务器的话,大模型会如何处理呢? 目前主流的大模型,针对以上问题有两种处理方案:隐式缓存和显式缓存。 1. 隐式缓存 在采用这种架构中,Agent与大模型之间的每一轮交互,Agent 都会老老实实地把所有的历史对话记录、System Prompt 加上最新的对话请求,全部打包成一个巨大的 JSON,通过 HTTP 发送给大模型。 当这个巨大的 JSON 到达云端的大模型服务器后,其 API 网关会快速计算这段文本的前缀哈希值(Hash)。 - 如果它通过这段前缀发现其内存中已经包含了历史会话的 KV Cache(即Cache命中的情况),大模型在后续的运算中就会直接跳过这部分文本的 Transformer 矩阵运算,只对尾部新增的请求内容进行计算。这种情况下只有新增请求部分会被计入Input Token。 - 而如果它通过这段前缀在内存中找不到这个历史会话记录的KV Cache(即Cache失效了),那么大模型就只好把这个 JSON 中包含的完整历史对话记录、System prompt以及最新的对话请求全部完整进行Transformer运算, 因此,在这种模式Cache命中的情况下,网络 I/O 的成本依然很高(因为每次会话仍然需要把完整的会话历史记录全部发上去),只是算力(GPU 的计算时间)被省下来了,所以大模型厂商会通过 Cache Token 的方式对大模型的使用费用打折,大模型计算的响应速度也变快了。 - 这种模式可以解决以上的问题二(算力浪费的问题),未能解决以上的问题一(网络带宽IO的问题)。 2. 显式缓存 另外一种模式则是显式缓存模式。在这种模式下,那些很厚重、不会频繁变动的静态上下文(比如整个项目的软硬件架构文档、长篇的 API 规范文档、技术手册、复杂的 System Prompt),Agent 只需要在最开始上传一次。服务器收到并计算完 KV Cache 后,会返回一个资源标识符(Cache ID)。 在后续 Agent 与大模型之间的反复对话中,Agent 的 JSON Payload 里不再需要包含那些厚重的静态上下文,而只需要填入一串字符串:"cached_content": "caches/12345-abcde",以及双方之间的会话记录即可。 大模型服务器收到 Agent 发过来的请求以后,首先使用请求包中的 Cache ID 对应的KV Cache是否仍然存在于内存中。如果缓存命中,则可以直接使用KV Cache,只对 Agent 发上来的会话记录进行 Transformer 计算,然后把 KV 值拼接起来,生成新的上下文。 但是如果 Cache ID 对应的KV Cache已经失效被删除,此时大模型服务器会直接返回一个 404 错误,并在附带信息中说明 Cached content not found。此时 Agent 就需要把本地缓存的静态上下文的所有资料重新发给大模型,针对所有的上下文重新进行一次密集的 Transformer 运算,然后返回一个新的 Cache ID 给 Agent。 因此,这种显式缓存的工作模式,可以同时解决问题一和问题二,只有在缓存失效的情况下,才需要把比较大的静态上下文发给大模型执行一次完整的Transformer运算。 !%E9%9A%90%E5%BC%8F%E7%BC%93%E5%AD%98vs%E6%98%BE%E5%BC%8F%E7%BC%93%E5%AD%98%E5%AF%B9%E6%AF%94.png

Amazon AWS之Lambda Service学习总结

AWS的 Lambda Service是一种全托管的无服务器(Serverless)计算服务。相比于传统的基于 EC2 和常驻进程的 Java/Python/Node.js 服务,在相同的应用需求下使用 Lambda,开发人员只需要上传 Java、Python 或 Node.js 代码到服务器上,AWS 就会自动准备计算所需要的资源,并在代码执行完毕后立即释放。本文对 AWS 的 Lamda Service 的各种概念进行了总结。 1.AWS Lambda Service介绍 在传统基于 AWS EC2 和的常驻进程的 Java/Python/Node.js 后台服务架构中,我们的研发和运维模型是面向服务器的,也就是说,运维人员需要时刻关注操作系统的安全补丁、JVM 虚拟机运行中的调优、自动负载均衡器(ALB)的配置,以及为了防范突发流量而不得不预留的大量冗余算力。这些计算资源在夜间往往处于极度低效的闲置状态(通过自动负载均衡可以缓解但是无法解决问题),从而造成了巨大的云账单浪费。 AWS Lambda 是亚马逊云科技推出的一种全托管的无服务器计算服务。它的出现,从根本上改变了后端的计算范式:运维人员只需要上传代码(可支持 Python, Node.js, Java, Go, 甚至是基于容器镜像的自定义 Runtime),AWS 就会在毫秒级的延迟时间内自动准备好执行环境,而且在代码执行完毕后立刻释放资源。这种模式既规避了繁琐的运维任务,又可以自动完成计算资源的启动和释放,真正实现按需付费。 !image.png Lambda 核心理念一:事件驱动 Event-Driven 在 Lambda 的世界里,不再有类似 Tomcat 或 Netty 那样一直阻塞监听 HTTP 端口的常驻守护进程。Lambda 的生命周期完全是由事件(Event)唤醒的,这个事件也就是 Lambda 语境中的触发器 Trigger。例如以下是一些摄像头/IoT领域的典型事件/触发器: - 边缘安防摄像头通过 MQTT 协议向 AWS IoT Core 发送了一条越界检测告警的告警消息。 - App 客户端通过 Amazon API Gateway 发起了一次获取设备历史录像列表的 REST API 请求。 - 端侧设备将一段异常运动轨迹的短视频切片成功上传到了 Amazon S3 存储桶。 只有当这些确切的事件发生时,Lambda 才会被拉起执行其对应的代码,并且计费精确到毫秒级别。这意味着当设备全部处于静默期时,云端的计算节点账单将会瞬间归零。 Lambda 核心理念二:无状态 Stateless 这是与传统的基于 EC2 常驻进程的 Java/Python 等后台开发模式差异最大的部分。 由于 Lambda 实例是按需创建且随时可能被销毁的,因此,在 Lambda 的执行函数中绝不能在代码的本地内存或磁盘目录(如 /tmp)中保存任何需要跨请求复用的会话(Session)或业务状态数据。 所有持久化的业务状态,必须被外置化,例如写入到 Amazon DynamoDB(NoSQL 键值存储)、Amazon RDS(关系型数据库)或 Amazon S3 中。 在基于 AWS Lambda 进行 Serverless 服务的网络架构设计中,首先必须牢记并且基于 AWS 官方设定的一些硬性天花板参数来进行服务的设计与构建: - Lambda 服务函数的最大执行时间 (Timeout): 目前为15 分钟(900 秒)。因此,Lambda 绝不适合运行长时间的后台批处理任务,长时间的处理任务可以使用 AWS Fargate 或 AWS Batch。 - 内存与算力配比: Lambda 函数运行的内存配置范围从 128 MB 到 10,240 MB,无法直接选择 vCPU 的核心数,AWS 规定 vCPU 和网络吞吐量与分配的内存大小成正比例线性增加,所以内存的配置直接决定了存储和计算资源。 - 有效载荷 (Payload): 同步调用的请求体中所包含的 Payload 上限为 6 MB,异步调用则为 256 KB。 2.AWS Lambda的工作流程详解 Lambda 在 AWS 系统中的具体实现,采用了类似于 Docker 容器的方式为不同用户的不同应用程序创建独立的运行空间。只不过,为了兼顾公有云下多租户环境下的极致安全隔离,以及无服务器计算所要求的极速启动,AWS 使用 Rust 语言从零开发并开源了所谓的 Firecracker 技术。这是一种基于 KVM 的微型虚拟机(microVM),相较于共享操作系统内核的传统容器,Firecracker 能在几十分之一秒内拉起一个具备完全硬件隔离级别的微型沙箱。简单的理解,就是一种更高级、性能更好的容器。 当我们把代码(例如一段 Python 3.12 写的设备鉴权脚本)部署到云端的 Lambda 环境中,其底层的真实生命周期如下图所示: !be207b75-f9a8-464d-ab69-00110c159d1d.png 由此可见,Lambda容器的运行过程主要可以分为以下三个阶段: - Init (初始化阶段 - 冷启动): 当第一个请求到来,或者现有的实例都在忙碌,AWS 会新分配一个 Firecracker 实例。该实例启动后,首先通过内网从 S3 下载部署包,启动 Python 解释器,并执行Lambda 函数文件除了 def handler(event, context): 以外的所有全局代码(例如 import boto3,或建立数据库连接)。这个阶段非常耗时,被称为冷启动(Cold Start)。对于沉重的 Java JVM 来说,这个阶段甚至可能高达数秒。 - Invoke (调用阶段): AWS 将 HTTP 或者 MQTT 消息的 JSON payload 传递给 Lambda 的 handler 函数并执行其业务逻辑。 - Freeze & Thaw (冻结与复用):handler 函数 return 之后,AWS 并不会销毁这个 Firecracker 实例。相反,它会将这台微型虚拟机的 CPU 挂起(仍然存活在内存中)。此时实例不产生任何计费,但内存中的状态(包括数据库的 Connection 对象)被完整保留。如果短时间(通常 5 到 15 分钟内)有新请求涌入,AWS 会瞬间解冻该实例,跳过 Init 阶段直接进入 Invoke(也就是直接执行 handler 函数)。这就是极速响应的暖启动(Warm Start)。 优化Cold Start问题的Provisioned Concurrency机制 需要注意,以上提到的暖启动的流程,Lambda 的响应速度会非常快,但是如果是冷启动的话,整个容器启动、加载并运行的过程就会比较长,在某些应用场景下,会造成很糟糕的用户体验。为了解决这个问题,AWS 官方推出了 Provisioned Concurrency (预配置并发) 机制。它的核心逻辑是用空间换时间。 在流量真正到来之前,可以通过 API 指令让 AWS 在后台静默分配指定数量的微型虚拟机,并提前跑完耗时的 Init 阶段。这样当真实的设备并发请求进入时,所有的请求将直接被路由到这些已经预热完毕的实例上,这样就可以实现两位数毫秒级的极致响应速度。 但值得注意的是,开启预配置并发后,哪怕没有任何请求,也要为这些处于预热状态的实例按时间持续付费。 这种模式比较典型的应用场景就是,预先识别自己的应用在每天的哪些时间段会出现访问的高峰阶段,然后每天固定在这个时间段来临之前通过开启这个Provisioned Concurrency机制来提升用户访问响应的速度。 3.AWS Lambda Trigger的触发消息队列模式 如上所述,Lambda 仅仅只是计算单元,在真实的业务场景流转中,如何将设备端所发出的请求数据平滑、安全、可靠地路由给 Lambda?根据不同的业务场景,AWS 提供了以下三种截然不同的调用模型(Invocation Models): !7d870224-20f9-4a02-babe-610fcd072937.png 同步调用模式: - 工作机制: 客户端发起请求后,会阻塞连接,直到 Lambda 执行完毕并返回 HTTP 响应。 - 队列特征:在该模式下没有任何内部队列缓冲。 因此如果瞬间涌入 5000 个请求,而当前的 Lambda 并发配额仅为 1000,那么多出的 4000 个请求会立即被系统拒绝,并向前端设备返回 429 Too Many Requests (ThrottlingException)。 - 应对策略: 前端设备的请求必须实现严谨的带抖动的指数退避重试算法。 异步调用模式: - 工作机制: 遵循即发即弃原则。AWS IoT Rules Engine 将事件推送到 Lambda 的内部网关后,立刻收到确认,并将事件放入一个托管在 AWS 内部的队列中,此后 Lambda 从这个队列中异步拉取并执行。 - 队列重试与 DLQ: 如果 Lambda 代码执行中抛出异常,或者遭遇并发限流,Lambda 内部队列默认会重试最多 2 次(时间间隔通常为 1 分钟和 2 分钟)。需要注意: 如果这两次重试均失败,消息将被永久丢弃,所以本质上这个队列机制是不可靠的。因此,为了保证告警或重要信令的不丢失,必须为该 Lambda 绑定一个 Dead-Letter Queue (DLQ,死信队列),通常指定一个 Amazon SQS 队列来捕获最终失败的消息,以便后续告警分析或人工介入补偿。 流式队列触发: 如果端侧设备发出数据的频次很高,由 Lambda 来处理这类高频消息并非是一个良好的设计。 - 合理的设计架构是将 IoT Core 等发的高频消息通过路由先接入 Amazon Kinesis Data Streams 或 Amazon SQS。Lambda 不作为独立的 HTTP 终点,而是运行一个后台轮询器。然后这个轮询器在执行中会根据设定的 BatchSize 和 MaximumBatchingWindowInSeconds(例如:最多等待 1 秒或攒够 500 条记录),将零散的流式数据打包成一个 JSON 数组,然后塞给一个 Lambda 实例处理。 4.Lambda开发和部署的最佳实践 在真实的 IoT 云后台原生开发中,无论是通过 AWS 网页控制台手动修改代码,还是允许开发工程师在本地电脑直接拥有生产环境的部署权限,都是极其危险的。更合理的做法是在团队内部推行基础设施即代码(IaC),并建立严格的 CI/CD 自动化部署流水线。 实际上 AWS 专门开发了一个SAM (Serverless Application Model) 工具用于处理 Serverless 云服务的开发与标准化部署的流程。 对于 Lambda 服务而言,其开发和部署的最佳实践流程大体可以分为以下5个阶段: - 本地 IDE 与代码定义:开发人员在本地 IDE 中不仅要编写 Python 业务逻辑(app.py),还要负责维护一个 template.yaml 配置文件。在这个 YAML 文件中,需要以声明式的方式定义所有的云资源。这个配置文件也就是云端进行部署的系统图纸,这保证了云上环境的绝对可追溯性和不可变性。 - 打包构建 (sam build):用于生成交付物,最终所有的代码和依赖会被归档整理到一个名为 .aws-sam 的本地隐藏文件夹中,准备上云。 - 云端同步 (sam deploy):在这一步,SAM 会自动把刚才打包好的代码和依赖压缩成一个 .zip 文件,然后通过安全通道,将这个 ZIP 包上传到 Amazon S3的一个隐藏存储桶中。 - 编排与部署:前一步上传的不仅是代码 ZIP 包,还包括被翻译过的 template.yaml 文件。AWS 会将这个配置文件交给 AWS CloudFormation(AWS 的基础设施自动化编排引擎),Cloudformation 基于 template.yaml 文件进行云端环境的实际部署。 - 触发执行:完成上一步实际上就已经部署完成了,接下来就可以通过客户端向服务端发出消息进行功能的测试与验证了。 在具体的工程实践中,以上的流程应该与 CI/CD 自动化测试和部署流水线相结合,在软件的整体发布流程上可以做得更加科学和高效:工程师写完 Python 代码和 template.yaml,提交(Git Push)到代码仓库(如 GitLab、GitHub 或 AWS CodeCommit),触发一条自动化的 CI/CD 流水线,由云端的服务器自动执行 sam build 和 sam deploy,经过严格的自动化测试后再发布到线上。 !AWS-SAM-CICD-Pipeline.png 参考资料 - The Ultimate Guide to AWS Lambda | Serverless Framework | Serverless Framework

详解卷积网络中BN层的概念及其计算流程

本文详细总结了在各种卷积神经网络结构中普遍存在的批归一化层(BN,Batch Normalization)存在的理论意义以及在各种计算机视觉网络模型中BN层进行计算的流程。 BN层的概念与存在的意义 自从 2015 年 Google 提出 Batch Normalization(BN)以来,它几乎成为了所有现代 CNN网络结构(如 ResNet, YOLO, EfficientNet 等)的标配层。 !image.png 在BN层出现之前,训练一个几十层的深层神经网络存在的问题是什么? - 模型的训练过程对权重的初始化设置值极其敏感:如果初始权重值设置得稍大或稍小,经过网络多层的传播后,最终输出的数值要么爆炸,要么归零。因此当时的研究员们不得不花费大量精力去研究各种非常复杂的参数初始化方法。 - 学习率必须设得非常小:为了防止网络参数在训练过程中崩溃,训练的学习率只能设置得极其保守(比如 0.0001),这样就导致模型训练时间极长,训练效率很低。 - 梯度消失问题:2015年前后的网络设计中普遍使用 Sigmoid 或 Tanh 等作为激活函数。如果输入的数据过大或过小,很容易就会落入这些激活函数的饱和区(曲线变得极其平缓的地方),从而导致梯度几乎为零,即梯度消失,网络直接停止学习。 出现以上问题的原因在于:一个被命名为内部协变量偏移(ICS)的现象。这个所谓的ICS看起来很复杂,实际上说的就是一个简单的问题:模型在训练的时候,网络第一层的参数稍微更新一点,这点微小的改变,在经过第二层、第三层……一直传递到第一百层时,会被指数级放大。这样的话,对于深层次的网络来说,它每一次接收到的输入数据的统计分布值都在剧烈地、毫无规律地变化中,根本没有办法从这个剧烈变化的数值中学习到任何规律了。 针对以上问题,BN 层裁员了以下直接的方法来解决问题:既然上游传过来的数据分布总是不稳定的,那我们就在每一层前面额外增加一个处理,强制把上游的数据洗牌,统一整理成标准的模样。 具体来讲,无论上一层传过来的数据统计是什么形态,BN 层都会计算当前的完整训练批次数据(即所谓的Mini batch)的均值和方差,强行把输入数据拉回到均值为 0,方差为 1 的标准正态分布的形式。在这种情况下,无论一个网络的层次有多深,每一层都能够清楚的确认,其上一层次发过来的数据的形态一定符合标准的正态分布。 问题是,如果强行把上一层输入的数据变成均值 0 、方差 1的正态分布,那岂不是会破坏掉之前网络层次辛辛苦苦学到的特征区分度吗? 针对以上问题,在进行归一化计算流程的处理以后,还需要增加另外一个线性变换的环节和两个可学习的参数 (Scale) 和 (Shift) 来恢复网络的表达能力,把“究竟是否需要对输入数据归一化”以及“归一化的程度”的选择权交还给了网络自己去学习和决定。具体的计算流程如下所述。 另外一个问题是:BN层一般放在CNN网络中的什么位置呢? 答案是,BN层一般会夹在线性计算(如卷积层)和非线性激活函数(如 ReLU)之间。如下图所示: !image.png 这是因为:卷积计算操作后所产生的数据往往在统计分布上非常散乱。如果在送入激活函数之前,先用 BN 层把数据聚拢到 0 附近,就能够完美地让数据落在激活函数最敏感、最有效的区域,从而获得最大化且健康的梯度反馈。 BN层的计算流程 总体来讲,BN 层的核心算子包含两个阶段:正态归一化(Normalization)计算阶段;线性仿射变换(Affine Transformation)阶段。 在计算流程上,对于一个给定的 Mini Batch 内的特征 ,BN 层会首先求其均值 和方差 ,然后进行归一化计算,归一化计算的结果就是对输入数据进行以0为中心、方差为1的线性映射;最后再通过两个可学习的参数 (Scale) 和 (Shift) ,重新定义一个新的线性映射来恢复网络的表达能力。 > 💡 > 所以说到底,无论 BN 层在训练时看起来多么花哨,BN 层计算的两个阶段都是线性变换。所以在数学本质上,整个 BN 层对输入数据的处理也仍然是一个简单的线性变换 。 整个BN层的计算公式及其计算流程如下所示:前三个公式为归一化计算流程,最后一个公式为线性映射流程。 从数据维度上讲,输入和输出数据的维度没有发生任何变化,语义信息的结构也得到了完整保留。在进行归一化计算时,整个计算过程始终以输入特征图的通道为单位来进行计算,也就是说, 在计算单个通道的均值和标准差时,不仅仅是跨越 张图片,而是要把这 张图片在同一通道下的所有像素点全部汇总在一起计算。 > 💡 为什么要以通道为单位进行归一化计算?这是因为,在 CNN 中,每一个通道代表着一种独立且特定的语义特征(Semantic Feature),按通道独立归一化,是为了保护不同特征之间的物理独立性,避免两个独立的语义特征被强行平均。 下图为标准的CNN网络中BN层进行计算的各个阶段的数据维度状况: - Stage 1:首先输入数据为上一个卷积层所输出的特征图,其维度为 - Stage 2:以通道为单位,取出 张图片某个通道的特征图,对这 张特征图的所有数据求平均值和方差,最终得到 个平均值和方差 - Stage 3:仍然是以通道为单位,对该通道每一张特征图中的像素基于该通道的平均值和方差进行归一化计算,这样每个通道的特征图数据都转换成了满足均值为0,方差为1的标准分布形态 - Stage 4:线性映射阶段,每个通道有一组可在训练过程中学习的参数 和 ,基于这一组参数对该通道的特征图进行线性映射 - Stage 5:最终输出的数据形态仍然保持与输入一致。 !bd0b18312f67600e745c52997774bc43.png 为了进一步让整个计算流程更为清楚,下图以一组张3通道640x640分辨率的图像作为输入,进行完整的 BN 层的计算流程进行说明: - 整个计算过程中,因为输入数据是3通道,所以整个输入批次的数据就有3组均值和方差,同样,也就有三组可学习参数 和 ,分别对应于三个通道。 - 当然下图只是为了进行计算流程的说明,BN 层在CNN网络中极少直接作用于网络的原始输入图像。对于原始图像(),通常会在输入图像数据的预处理阶段,使用固定的常数(如 ImageNet 的全局均值和标准差)直接进行减均值除方差的操作。BN 层真正的舞台,是放置在网络深层的特征图上,对卷积层输出的特征图进行处理。 !4f65a87e3daa101a9c82653891b7ee26.png BN层在推理过程中的行为 需要注意的是:BN 层的计算在训练和推理时的行为是完全不同的。 在训练流程的前向传播过程中,训练代码会基于当前 Mini-batch 的数据计算其真实的统计量 和 。同时,为了后续在推理过程中使用,BN 层在训练时还会默默在后台维护全局的滑动平均均值(running_mean)和滑动平均方差(running_var)。 而在推理(即 model.eval() )模式下,BN 层就不再计算当前 batch 的统计量(每次推理的输入数据可能只有一张图片,推理结果可能会因为 batch size 的不同而波动),而是会直接使用在训练时所积累的 running_mean 和 running_var来进行归一化计算。 同样的,BN 层计算的线性映射阶段的可学习参数 (Scale) 和 (Shift) 到了推理阶段也是固定的,因此整个 BN 层的线性运算就可以完美地、数学上等价地合并(Fusion)到上一层卷积的权重()和偏置()之中。 因此,在ONNX导出的时候,一般会结合使用 ONNX-Simplifier,这个过程会自动把 Conv 和 BN 相融合,这样导出的 *.onnx 模型图中一般就看不到独立的 BatchNorm 节点了。

如何把PT格式的模型转换为TFLite/LiteRT模型?

之前在尝试把Ultralytics的YOLOv8模型运行在Android手机上的过程(参考总结Android Flutter项目中YOLO模型的执行流程)中,直接使用了YOLO模型的export接口即可实现把该模型导出为ONNX、TFLite等格式。但是如何我们在开发过程中遇到的模型没有官方所提供的export接口,如何能够把pt格式的模型文件转换为TFLite,从而方便的部署在Android手机上呢? 因此,本文尝试解决这样的问题:如何把一个通用的 PyTorch 模型权重(.pt)文件,通过模型转换处理,变成在 Android 端侧能跑的 TFLite 模型文件(.tflite)? Google的AI Edge Torch/Litert 针对以上所提出的问题,一个比较流行的解决方案是: - PyTorch (.pt) → ONNX: ONNX 是模型交换的通用格式,所以这一步转换相当于是把模型文件脱离了 PyTorch 的框架限制。 - ONNX → TensorFlow (SavedModel): 这是一个转译过程,从ONNX的生态语言转换为TensorFlow生态,确保目标平台(即Google 手机APP)能正确的识别模型文件中的算子。 - TensorFlow → TFLite: 这有点类似于 Linux 下的 strip 和 objcopy,这一步操作会进行常量折叠、算子精简以及最重要的量化操作(Quantization),把默认的 FP32 的权重参数变成 INT8,以适应移动端 CPU/NPU 的算力和存储方面的限制。 但是,以上这个传统的 PT -> ONNX -> TF -> TFLite 路径并非是一个完美的解决方案。这是由于, PyTorch 坚持使用 NCHW (即所谓的Channel First) 而 TensorFlow 则默认使用 NHWC (即所谓的Channel Last),那么转换工具在以上传统的转换路径中为了保住数学上的等价性,会疯狂插入 Transpose 算子。这在手机 CPU/GPU 上运行过程中,会导致频繁的内存重排,严重拖慢推理速度。 因此,Google 在2024年4月推出了一个AI Edge Torch的工具,用于实现从 PT 模型文件向TFLite格式模型文件的直接转换。这个工具利用了 torch.export(PyTorch 2.x 的核心特性)直接捕获计算图,并将其映射为 TFLite 算子,更重要的是,它可以直接在模型转换过程中对齐推理过程中的数据格式,这样就消除了大量不必要的 Transpose 算子,避免后续推理过程要执行的大量内存重排。 到了2025年,Google 对移动端 AI 生态进行了一次品牌重塑:之前的TensorFlow Lite被重新命名为LiteRT。这主要是因为,Google 认为 TensorFlow Lite 这个名字太受限于 TensorFlow 框架了。现在整个AI模型生态的趋势是 PyTorch 统治科研与训练,而 TFLite 统治移动端部署。因此为了吸引 PyTorch 用户,Google 强调 TFLite 这个 Runtime 是通用的端侧AI部署工具,不只是给 TensorFlow 用的。所以现在新的品牌叫 LiteRT (Lite Runtime)。 !image.png 环境安装 要在PC端把一个Pytorch的模型转换为 TFLite/LiteRT 格式,主要是需要依赖于litert-torch和ai-edge-litert 这两个包。 - litert-torch:对应之前的 ai-edge-torch,负责将 PyTorch 算子映射到 LiteRT。 - ai-edge-litert: 对应之前的 tflite-runtime,是推理引擎,负责在 Android 或 PC 上执行生成的 .tflite (LiteRT) 文件。 截至目前(2026年3月份),litert-torch和ai-edge-litert 这两个包的接口变动仍然非常剧烈,导致其对环境的要求非常苛刻。所以一定要创建一个独立的Python环境,并且提前安装好其指定的torch版本,否则后面安装这两个包的过程中很容易出现包冲突的情况。经过仔细检查,目前比较稳定的环境安装步骤为(在Windows的WSL2环境中安装): 注意:这个模型转换和PC端推理的环境必须安装在Linux环境中,我自己在Windows中安装环境总是会以下错误。从报错信息上看,litert-torch依赖于ai-edge-tensorflow,而ai-edge-tensorflow 目前主要发布在 Google 的 Nightly/Experimental 仓库中,对 Windows 平台的原生支持非常滞后。所以这个转换工具最好还是在Linux或者Windows的WSL2环境中来安装。 模型转换 环境安装好以后,就可以开始把Pytor下的预训练pt格式的模型文件转换为TFLite格式了。我这里直接使用Ultralytics/YOLO项目中的yolov8s.pt这个文件做转换测试。 以上模型导出脚本能够成功的导出 FP32 的模型文件,FP32 虽然精度最高,但由于其对内存带宽和存储空间的占用比较大(对于YOLOv8s这个模型而言,转换后的模型文件大小为44MB),在端侧 CPU/NPU 上运行肯定不是性能最优选,因此接下来还需要进行量化(Quantization)处理。 量化 对于YOLO模型自带export接口而言,量化是一个很简单的事情,只需要设置导出时的fp16、int8等导出格式就可以了。但是使用litert_torch 完成模型导出之后,对TensorFlow Lite模型文件进行量化操作的接口发生的变化非常剧烈,导致现在在网上看到的几乎所有量化参考代码在最新的litert-torch 都无法正确、成功的完成量化操作。 通过学习https://github.com/google-ai-edge/litert的ReadME文件: > > > I have a PyTorch model... > > - Goal: Convert a model from PyTorch to run on LiteRT. > - Path1 (classic models): Use the LiteRT Torch Converter to transform your PyTorch model into the .tflite format, and use AI Edge Quantizer to optimize the model for optimal performance under resource constraints. From there, you can deploy it using the standard LiteRT runtime. > - Path2 (LLMs): Use LiteRT Generative Torch API to reauthor and convert your PyTorch LLMs into Apache format, and deploy it using LiteRT LM. > 可以看到对于目前比较新的LiteRT实现而言,量化需要用一个新的工具ai-edge-quantizer(https://github.com/google-ai-edge/ai-edge-quantizer)。这个工具是 Google 专门为了对LiteRT模型进行 INT8/INT4 权重压缩打造的新一代利器。在最新的 Edge AI 理念中,单纯的文件体积 FP16 截断已经不再流行,权重 INT8 + 激活值浮点 (即所谓的Dynamic Quantization) 才是能在手机等端侧设备上上平衡速度、体积和精度的终极王者。 以上的量化流程中选择了wi8-afp32的模式(即权重参数使用INT8量化,激活值仍然使用FP32量化),最终得到的量化后的模型文件只有11MB的样子,是量化前的FP32模型文件的1/4。 除了以上的wi8-afp32量化模式,ai-edge-quantizer还支持其他的量化模式,如下图所示,Google 的工程师用了一种非常直白的命名约定来定义这些量化模式: - w = Weight(权重参数,即模型训练出来的固定参数) - a = Activation(激活值,即模型在推理时流动的中间特征图) - i8 / i4 = 8位 / 4位整数(Integer) - fp32 = 32位浮点数(Float) !8a772873-2af8-4892-8f32-b1998f10d39e.png 推理测试 基于 LiteRT模型文件已经转换并量化完成,下一步就是在PC端的LiteRT runtime中对其进行推理测试。 如上所述,在 LiteRT生态中,ai-edge-litert包对应于之前的 tflite-runtime,也就是负责在 Android 或 PC 上基于LiteRT模型文件执行前向推理的运行时框架。因此,在PC端进行前向推理的流程大致也就是:加载模型以及推理的图片文件→数据预处理→执行推理→对推理结果执行NMS后处理→把NMS过滤的结果叠加在输入图像上。 参考资料 - AI Edge Torch: High Performance Inference of PyTorch Models on Mobile Devices - Google Developers Blog - Convert Models From Pytorch to TFLite With AI Edge Torch | by David Cochard | ailia Tech BLOG (EN) | Medium - https://github.com/google-ai-edge/litert-torch/blob/main/docs/pytorch_converter/README.md - https://github.com/google-ai-edge/ai-edge-quantizer

TFLite如何处理算子版本和兼容性的问题?

在瑞芯微NPU平台上进行模型板端适配的时候,在进行模型板端转换的时候,ONNX 导出时设置的 Opset 版本,与板端模型转换工具(RKNN-Toolkit2)能够支持的 Opset 版本的一致性非常重要,如果板端模型转换工具不支持onnx文件中的某些算子类型的话,这个转换过程就会报错。那么,TFLite是否也存在 Opset 版本的问题,也就是模型导出为 TFLite 格式的时候也应该有个 Opset Version,这个 Opset 的版本需要与 interpreter 能够支持的 Opset 版本相对应,否则也会出现以上瑞芯微平台模型转换的问题?以及在TFLite的工作流程中,究竟是处理Opset版本一致性问题的?本文尝试总结以上问题的答案。 相关的背景信息可参考: - ONNX的Opset版本: 如何理解ONNX模型导出的Opset版本? - TFLite框架的核心概念和工作流程: 如何理解TFLite/LiteRT的核心概念和框架流程? TFLite的Opset版本信息 针对TFLite框架下,是否也有与ONNX相同的OpSet版本的概念和处理这个问题,简单的答案是:TFLite同样有算子版本的概念,但是与算子版本的处理流程与ONNX有较大的差异。 .tflite 文件本质上是一个序列化后的 FlatBuffer 二进制文件。在这个文件头部,有一个专门的 OperatorCode 数组(也就是所谓的算子注册表)。当模型被导出时,TFLite 转换器会把这个模型文件中用到的所有算子登记在这个表里,并且每个算子独立记录其算子版本号,而不是像ONNX那样有一个统一的算子集版本号。 如下图所示的两者的显著差异:TFLite采用的是细粒度的基于各个算子的独立版本号,模型文件本身没有算子集版本的概念;而ONNX采用的这是全局的算子集版本号,通过模型文件来规范和管理整个算子集的版本。 !%E6%97%A0%E6%A0%87%E9%A2%98-2026-03-26-1350.png 为什么TFLite要采用这种单个算子集别的版本管理模式,而不是采用ONNX这样的全局算子集Opset版本的方式? 最主要的原因是,算子级别的版本管理方式可以更好的向前兼容性,这一点对于软硬件碎片化非常严重的Android生态系统非常重要。假设我们的模型中仅包含有最基础的 Conv2D 和 Relu 算子,使用最新版本的模型转换器导出的时候: - 如果是 ONNX的全局Opset模式:模型转换器会将这个ONNX文件标记为比较新的 Opset 18。这样的话,即使 Conv2D 这个算子在 Opset 1 到 18 里没有发生任何变化,那么一个老版本的 ONNX Runtime 在看到 Opset 18 版本的情况下,可能就会直接报错并拒绝加载。 - 而在 TFLite 这种单算子版本的管理模式中:模型转换器在转换过程中,发现该模型没用到 Conv2D 的新特性,就会在TFlite模型文件里将 CONV_2D 这个算子标记为 Version 1。这种情况下,即使用户的Android手机里包含的是很早版本的 TFLite Interpreter,也能完美加载并运行这个最新导出的模型。 算子版本的匹配 与其他嵌入式平台的模型导出、适配以及板端的加载运行相同,在Android系统上运行TFLite格式的模型,在算子的版本匹配上,需要关注三个环节:模型导出和转换工具的算子版本(Converter),Interpreter支持的算子版本,以及delegate支持的算子版本。只有以上三者的算子版本信息相互匹配,才能确保这个TFlite模型在Android系统上成功的跑起来。 - TFLite 模型转换工具、Interpreter以及Delegate等相关的概念及其在Android系统中的结构,可以参考如何理解TFLite/LiteRT的核心概念和框架流程? 这一篇笔记。 Converter TFLite 模型的 Converter 工具内部有一套算子版本试探的算法,来自动检测模型文件中各个算子的版本信息。例如对于 FullyConnected 这个算子,在没有开启 Keep_num_dims 特性的情况下,Converter 在进行模型转换的时候,就会给这个算子打上 Version 1 的标签;而如果模型的推理计算过程需要开启这个新特性,Converter 才会给该算子打上 Version 2。这就相当于是一个“能用低版本绝不用高版本”的算子版本决定策略,这个策略是保证模型和 App 拥有最广设备兼容性的核心所在。 总结起来,对于Converter而言,无论这个Converter工具有多新,它的工作逻辑都是:在进行模型TFLite格式转换的过程中,它会扫描和试探出来每个算子在模型计算图中所要求的最低版本,然后把这个最低版本作为该算子的版本,以此来尽量保证后续这个算子以及模型能够支持到范围最广的设备。 Interpreter Interpreter 也有自己的版本支持情况,那么它在加载TFLite模型文件和构建模型的计算图的过程中,同样会去扫描各个算子的版本信息,并检查该版本与自己支持版本的适配情况。 Interpreter 在加载 TFLite 模型文件的 FlatBuffer 结构的过程中,提取每个算子的版本信息,与自己内部的 OpResolver(算子注册表)进行比对。对于一个算子而言,如果在TFLite文件中该算子需要的版本是 Version 5,而 Interpreter 的代码库里算子注册表只实现了 Version 4,它会立即抛出 Unresolved builtin op 异常,并拒绝执行。 因此,一个模型究竟能否在Android系统中跑起来,Interpreter 对于算子版本的支持情况非常关键,这就是为什么在使用 Flutter 框架开发Android App 时,应该尽量使用最新版本的 tflite_flutter 插件。 > 💡 Interpreter本身就是一个普通的 C++ 动态链接库(比如 libtensorflowlite_c.so),它是被硬打包进 APK 里的,而不是由 Android 系统底层提供的。这样的好处是,Interpreter与APP里面的模型强绑定,只要APP完整测试通过,就可以确保运行在不同硬件平台和Android系统版本上。因此,如果系统中包含有多个跑不同模型的APP,也就有多个各自独立的Interpreter副本。当然,TFLite 的核心解释器被设计得极其精简,去除了所有训练相关的代码。一个基础的 C++ Interpreter 库通常只有 1MB 到 2MB,对现代 App 的体积影响微乎其微。 在 Interpreter 中实际上包含了其所支持的所有算子的CPU实现,这样的话,如果 Delegate 不支持某个算子的话,Interpreter 就会自动调用其内部实现的CPU算子。 Delegate 而在 Delegate方面,同样有自己所支持的各个算子的版本信息,在Delegate与Interpreter握手的过程中,Interpreter会拿到了Delegate所支持的各个算子的版本信息。在这种情况下,Interpreter 在模型前向推理的算子调度过程中,就能够清楚的知道哪些算子 Delegate 能够支持,哪些算子Delegate不支持必须要放在CPU中执行。 当然,不同于 Interpreter 是内置在Android APP层面中,Delegate 对于算子的支持是固定在Android系统中的。这也很好理解,因为 Delegate 之下的 NPU 驱动的实现需要依赖于SOC厂家的实现,是不可能放在APP层面来解决这个问题的。因此,除非升级整个系统,Delegate层面对算子版本的支持情况就是固定的,Delegate没法支持的算子就只能被Interpreter调度到CPU上去执行。 TFLite前向推理与算子版本相关的完整流程 至此,对于TFLite的算子版本管理,TFLite模型文件导出时的算子版本推定,在Android系统中Interpreter和Delegate进行算子版本匹配和调度的完整流程就呼之欲出了: !TFLite_Architecture_Flowchart.png

总结Android Flutter项目中YOLO模型的执行流程

之前基于 Flutter 实现了一个在Android APP中调用YOLO模型来实现图像目标检测功能,本文的 YOLO 模型的 TFLite 导出以及在 Android Flutter 项目中进行调用的完整执行流程进行总结。 在Android上跑AI模型,标准的做法是导出模型文件为 TFLite 格式,然后在 Android 的 TFLite+Interpreter+NCAPI 框架下执行AI模型的推理流程,与该框架相关的知识可参考 如何理解TFLite/LiteRT的核心概念和框架流程?一文。 YOLO模型的TFLite导出 因为 Ultralytics 项目中的模型导出的实现已经非常完备,所以可以直接在ultralytics项目中通过其export接口导出 tflite 模型文件即可,此处选择导出为half即float16的格式: 此外,后续在Flutter项目中调用以上模型文件进行推理,还需要创建一个与模型对应的label.txt文件,其中包含有模型检出类别对应的类别名称字符串,按顺序每行一个。因为我测试中直接使用的是YOLO的预训练pt文件,预训练模型文件对应的是COCO数据集,所以这里的label.txt中包含的类别名称字符串大致就是如下80个类别: 依赖管理 因为要在 Android Flutter APP 增加跑AI模型的的能力,首先需要在项目根目录下的pubspec.yaml和 android/app 目录下的build.gradle.kts增加相关的依赖项。 flutter项目中的pubspec.yaml是该项目的元数据中心(Metadata Center)和依赖管理器(Dependency Manager)。其中包含的主要内容是:项目的基本配置信息,生产环境的依赖库,开发/测试阶段的依赖库,资源与资产等。对于以上提到的依赖项,每次在构建项目时,都会自动调用 flutter pub get 从pub.dev下载指定依赖库的源代码到本地。 该项目中需要对手机camera和本地图片的图像调用YOLO模型进行检测,所以pubspec.yaml中相关的设置项包括: 因此,对于在 Android 项目中跑YOLO模型而言,以上配置文件最关键的就是其中导入了 flutter_vision 这个插件。该插件内置了 TensorFlow Lite C SDK,并针对 YOLO 这种特定模型,包含了输入输出的 ByteBuffer、维度转换(Tensor Shape)和 NMS(非极大值抑制)等的相关处理,对于应用层程序而言,只需要调用 vision.loadYoloModel() 接口,它就可以在 Android 原生层创建 TFLite 的 Interpreter并执行推理任务。 模型在Flutter中的完整推理流程 与其他板端模型的完整推理流程类似,在Android Flutter项目中调用YOLO模型执行推理任务同样包含模型加载、前向推理以及后处理这三个环节。 模型加载 基于flutter_vision插件对AI模型进行加载的整体流程如下: 整体的模型加载过程很简单,调用flutter_vision的loadYoloModel接口创建一个模型加载的异步任务,传递模型文件和label文件等参数,然后等待这个异步任务执行完成即可。 需要注意的是,flutter_vision 插件只能够支持ultralytics项目export接口直接以tflite格式导出的YOLOv5,YOLOv8,YOLOv11这几个模型,而且参数中指定的模型文件与modelVersion的参数必须设置正确,否则后续的后处理流程会出错: !074e2e0e-d2b9-48e6-b0b2-4a1f06769c23.png 那么如果我们要在 Android APP 中跑其他模型呢?那就只能使用 tflite_v2 插件、flutter_tflite插件或者底层 TensorFlow Lite API。 前向推理和后处理 flutter_vision插件的前向推理过程主要就是调用其yoloOnFrame(针对摄像头的图像)和yoloOnImage接口(针对本地图片)。 以上的推理接口执行中本身已经包含了YOLO不同版本的后处理流程,所以其输出结果是一个的数组。这个数组中的每一项都对应于一个最终检测框的类比和坐标+置信度,如下所示: 应用层拿到以上的这个数组以后,就可以对其中的内容进行解析并在图像上画框标注了。因为 yoloOnFrame 和 yoloOnImage 接口中已经包含了后处理的NMS等环节,所以不需要在应用层再执行后处理了。 参考资料 - flutter_vision | Flutter package

如何理解TFLite/LiteRT的核心概念和框架流程?

本文整理了TFLite的核心概念,与ONNX框架的对比,及其内部各个组件的关键工作流程,尝试建立对这个Android系统下的标准AI推理框架的整体概念和工作流程建立完整的理解。 TFLite是什么?以及与ONNX的比较 Google推出的 TensorFlow Lite 是一个专为移动、嵌入式和边缘设备设计的开源深度学习框架。它能够支持开发者在手机(Android/iOS)、微控制器(MCU)及各类物联网设备上,以极低的延迟和较小的二进制体积运行高性能的推理任务。 !image.png 众所周知,在边缘侧的板端设备上运行各种 AI 模型,已经有一个非常流行的 ONNX 框架,那为什么不在 Android 上直接使用 ONNX?原因主要还是 Facebook的 Pytorch/ONNX 与Google的 TensorFlow 之间生态竞争的问题。 虽然 ONNX Runtime 现在也能够支持在 Android上运行,但在 Android APP 中跑AI Model还是应该首选使用 TFLite,原因如下: - 兼容性的问题:在 Android 系统的实现中,Google 提供了一层 NNAPI (Neural Networks API) 的封装。TFLite 是 Google 主推的端侧机器学习框架,它对 NNAPI 的支持是第一优先级的。如果用 ONNX 的话,在调用不同 SOC 厂家的 NPU 时,可能会遇到各种驱动不兼容或性能折损的情况,兼容性的处理会非常头疼。 - 内存与包体积的问题:要在 Linux 或者 Android 系统中使用ONNX来运行模型,就需要把 ONNX Runtime 内置进去,相关的库文件通常很大(几十 MB),这样就会直接增加 App 的安装包大小和内存占用情况。而如果使用TFLite的话就没有这个问题,TFLite 的核心解释器非常小(只有几百 KB),而且 Android 系统中已经内置了相关的运行时环境。 TFLite模型部署的整体工作流程 将一个复杂的深度学习模型部署到手机上,通常需要经历从训练、精简/格式转换/量化再到板端执行的完整的过程。这个流程对于所有的设备端 AI 框架的工作流程而言都是大致相同的。 例如对于瑞芯微平台上的模型适配而言,其整体的适配流程如下: - 首先在PC/AI服务器端完成模型训练,导出为ONNX格式的模型; - 把以上导出的ONNX模型再利用瑞芯微的RKNN-Toolkit工具转换为RKNN格式的板端模型; - 在瑞芯微的处理器中,板端模型与其运行时库librknnrt.so、应用程序相互配合完成板端的推理任务。 以上的这个流程,对于在手机的的 Android 系统中使用 TFLite 框架来运行 AI 推理的流程是完全相同的,只不过整个过程稍微简化了一些而已: - 模型转换部分:首先把原始的训练好的 PyTorch 模型(通常是 .pt 或 .pth)使用 TFLite Converter 之类的工具转换为扁平缓冲区格式的 .tflite 文件。与其他板端模型转换工具的工作流程类似,在这个转换过程中,它会进行算子融合(将多个连续的操作合并为一个,以减少内存访问次数和量化(将原始模型文件中的 32 位浮点数权重转换为 16 位浮点数 FP16 或者 8 位整数 INT8,在微弱损失精度的前提下可换取数倍的速度提升同时降低对模型大小和显存的需求)等操作。 - 板端推理执行部分:在 Android App 运行时,内置在Android 系统中的 TFLite 解释器(Interpreter)会负责加载模型、管理内存缓存并调度计算算子。 > 💡 注:在模型转换部分,Pytorch 框架下训练出来的模型,一般需要先转换为 ONNX 模型,再利用类似 onnx2tensorflow 之类的工具转换为 tflite 格式。不过 Goggle 在2024年也出了一个 AI edge torch的工具可以实现直接的转换。 !image.png TFLite模型在Android框架下是如何运行的? 从以上的框架图中可以看到,模型转换为 TFLite 格式后再Android APP中的运行,需要依赖于两个关键的组件:Interpreter 和 Android Neural Networks API(NNPI)。 其中的Interpreter (解释器)是 TFLite 模型在系统中运行的核心控制中枢。它负责加载模型,解析模型的计算图(Graph),并管理内存缓冲区。在整个模型的工作流程中,Interpreter 并不直接做复杂的浮点和算子运算,而是负责将模型中定义的计算任务分发出去。 - 其实这个Interpreter有些类似于瑞芯微的librknnrt.so,负责辅助应用程序加载模型,解析模型的计算图和管理内存,然后在接收到输入数据的时候,对模型每一层的计算任务进行调度管理,分配到合适的处理器(CPU/GPU/NPU)上,并根据具体的计算调度情况不同的处理器上在处理器之间管理好内存的拷贝。所以其任务就是:加载并解析模型,分发计算任务,管理不同计算任务和处理器上的内存。 而 NNAPI 则是 Android 系统提供的一个 C API。它的存在是为了屏蔽底层 NPU、GPU、DSP 硬件的碎片化。Google 在 Android 系统中所提供的 NNAPI 只是一个与 Interpreter 相互配合的API调用接口的框架,而对于瑞芯微、高通、联发科等 SoC 厂商,要让 Android 手机能流畅地使用 TFLite 执行模型的推理任务,他们的核心工作就在其SOC的设计中进行 NNAPI 接口的具体实现。 具体来讲,这些 SOC 厂商需要根据 Android NNAPI 模块所提供的 NeuralNetworks.h 标准,编写对应的 NPU 驱动指令。这些驱动指令的关键任务就是实现模型的算子映射(将 Android 定义的 ANEURALNETWORKS_CONV_2D 等标准算子,翻译成自己的 NPU 能理解和执行的具体指令)和内存管理(实现不同类型和区域的内存共享控制,尽量避免模型在执行推理过程中数据在 CPU 和 NPU 之间来回拷贝)。 总结起来,就形成了这样的调用关系和流程: !5a379fcc-72eb-471f-9001-bf892b8a6120.png - 首先是APP的应用程序逻辑代码调用 Interpreter 加载和解析模型文件; - 在 Interpreter 对输入数据执行推理流程的过程中,会通过 Delegate 机制调用 Android系统中的 NNAPI 框架中所定义的API。NNAPI中定义了不同的标准算子,Interpreter 在推理中针对具体不同的算子调用 NNAPI 中与之对应的API接口。 - NNAPI之下则是不同 SOC 针对算子的具体实现,这部分由各个 SOC 厂家根据自己SOC中 CPU/GPU/NPU 的能力来进行实现。相当于 NNAPI 定义了一层标准的算子接口,屏蔽了不同 SOC 厂商在其硬件上实现这些算子的差异性,这样的话,对于应用程序和Interpreter而言,看到的就只是 NNAPI 所定义的标准接口。 Interpreter 调用 NNAPI框架的Delegate机制 以上所描述的 Interpreter 和 NNAPI 框架的调用流程只是一个简化的解释,实际上Interpreter并不是直接调用NNAPI所定义的API接口。如果 Interpreter 直接以硬编码的方式去调用 NNAPI的话,会产生两个问题: - 某些算子可能不支持 (Fallback):NNAPI 并不一定能够支持 TFLite 模型文件中的所有算子。如果直接调用的话,遇到不支持的算子程序就崩溃了。 - 跨平台的兼容性问题:TFLite 所谋甚大,不仅要运行在 Android 上,还能够支持在 Windows、Linux、iOS 上跑,而这些系统中根本就没有NNAPI(例如上面架构图中的iOS APP部分根本就没有 NNAPI)。 所以 Interpreter 在 Android 中对于 NNAPI 框架的调用,采用了一种 delegate 的机制。 本质上,Delegate 就是 Interpreter 为了实现跨平台兼容性而设计的一个抽象插件接口。 - 对上(与Interpreter之间),它通过TfLiteDelegate 定义了一个标准的 C API,通过这个C API屏蔽了不同硬件平台、SOC、NPU的差异性。Interpreter始终只需要调用这个C API接口来实现对下层不同算力的调用,实现完整的推理过程。 - 而对下(各种平台不同的NPU实现),它作为客户端,负责调用各个不同系统和硬件平台上特有的深度学习算子运算的加速库(如 Android 的 libneuralnetworks.so 或 iOS 的 Metal.framework)。 至此,TFLite 框架得到完整结构及其调用流程就比较清楚了: !%E6%97%A0%E6%A0%87%E9%A2%98-2026-03-26-1350.png 参考资料 - Google Play 服务 C 和 C++ API 中的 LiteRT  |  Google AI Edge  |  Google AI for Developers - TFLite基础知识 - VitoYeah - 博客

手部特征点检测模型MediaPipe Hand Landmarker

MediaPipe Hand Landmarker模型简介 Google 的 MediaPipe Hand Landmarker(基于Mediapipe推理框架的手部特征点检测模型)是目前计算机视觉领域最流行、最高效的实时手部特征点检测和追踪解决方案之一。它能够运行在移动设备(Android/iOS)、网页端甚至算力受限的边缘设备上,以实时帧率输出手部的 21 个 3D 关键点(Landmarks)在图像中的坐标位置。 下图是该手部特征点模型软件包可以检测检测到的手部区域内 21 个手指关节坐标的关键点定位索引。该模型是基于约 3 万张真实图片以及叠加在各种背景上的多个渲染合成手部模型进行训练的。 !image.png Google 针对Hand Landmarker模型提供了iOS/Android/Web/Python这四种平台的官方实现,所以如果是在这四种平台中使用这个模型的话,可以直接参考官方的示例代码方便的调用模型来实现应用需求。 那么如果希望把这个模型运行在其他平台和语言,例如在其他的嵌入式平台上使用C/C++语言来调用呢? 答案是完全可以,只不过稍微复杂一点。Hand landmarker模型的运行需要依赖于Google开源的 MediaPipe 推理框架,而这个推理框架的底层本质,是一个以 C++ 为核心底座,以 TensorFlow Lite为推理后端,通过图计算(Graph)管理数据流的流媒体 ML 框架。 MediaPipe本身是开源的,而且对商业应用极其友好,其代码仓库托管在 GitHub 上:https://github.com/google-ai-edge/mediapipe。开源协议采用 Apache 2.0 License,可以自由使用、修改、分发,甚至将其闭源集成到自己的商业软件中,而无需开源业务代码。 所以要使用C/C++语言运行这个模型,就需要先把 MediaPipe 框架交叉编译到自己的系统中,然后在这个框架的基础上调用模型的推理能力。 模型的工作流程及其输出数据解析 MediaPipe Hands Landmarker模型在运行中之所以能做到极高的性能和检测帧率,其核心在于,它采用了一种级联的两阶段网络架构: 1. 手掌检测模型 (Hand Detector): - 该模型是一个极其轻量级的 SSD(Single Shot Detector)架构模型,其任务是在整张图像中快速找到整个手掌的边界框(Bounding Box),返回一个带方向的矩形框(Oriented Bounding Box)。 - 因为在视频图像中,手部的形变极大(手指可以弯曲、交叉),如果直接检测整只手的话难度很高。而手掌是一个相对刚性的结构,正方形特征明显,极其容易被网络捕获。 2. 手部特征点检测模型 (Hand Landmark Detector): - 一旦第一阶段的 Hand Detector 模型找到了手掌的区域,整个系统会将该区域(第一阶段模型返回的矩形框)裁剪并旋转对齐,然后送入第二阶段的 Landmarker 模型,这个模型专门在裁剪后的小图上精确检测出来 21 个 3D 坐标。 - 因为第二阶段模型只需要在裁剪后的局部区域进行推理检测,这样就极大地减少了计算量,过滤了无关的背景干扰,这是该模型能够跑得又快又准的根本原因。 !mediapipe_architecture.png 在模型的接口设计和执行流程中,MediaPipe Tasks API 包含有中三大运行模式(IMAGE、VIDEO、LIVE_STREAM),分别对应于单图、视频文件中的图像帧、带回调的实时流这三种应用场景。相应的也就对应于以下三个 API 调用接口: - detect(image):单图模式 (IMAGE)。 执行流程没有任何记忆,每一张图片输入后都需要先在整个图片中找手,然后再检测手部的21个特征点,适用于处理磁盘里的静态照片集。 - detect_for_video(image, timestamp_ms):视频模式 (VIDEO)。 带有记忆且在执行过程中同步阻塞,在执行推理的过程中其内部会维护了一个 ROI 缓存。上一帧找到了手部区域后,下一帧可直接找到那个区域跑轻量级的 Landmark 检测关键点。适用于处理本地录制好的视频文件。 - detect_async(image, timestamp_ms):异步直播流模式 (LIVE_STREAM)。 带有记忆且异步非阻塞,为摄像头的实时图像量身定制的。主线程发图像给模型推理框架后立刻返回,不等待结果。推理框架中的底层 C++ 线程池算完后,通过 Callback(回调函数)把结果返回给主线程。因此,使用这种模式需要在初始化 HandLandmarkerOptions 的时候传入一个 result_callback 参数,指明哪个函数负责接收 C++ 传回来的异步结果。 下图为通过以上接口对图像中手部及其关键点检测后的返回结果的数据结构: !MediaPipe_Result_Structure.png 以下是对接口返回结果进行解析和调用的代码示例: 模型运行示例 要在本地PC上使用 Python 运行这个模型,需要安装Google的mediapipe支持包以及下载一个hand_landmarker.task的模型包文件。 hand_landmarker.task模型包的下载地址为:https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task 这个hand_landmarker.task模型包文件以task为后缀名,但是本质上是一个压缩包,其中包含有两个模型文件:hand_detector.tflite(手掌检测模型)和hand_landmarks_detector.tflite(手部特征点检测模型)。只需要把这个模型加一个zip后缀名然后打开即可看到: !f15c88bc-9cbc-4f6a-937d-bb545d301cb6.png 以下提供的代码实现了使用opencv对本地PC摄像头的图像进行手部及其特征点检测并标记的应用案例: 参考文档 - https://ai.google.dev/edge/mediapipe/solutions/vision/hand_landmarker?hl=zh-cn - hand_landmarker.ipynb - Colab - mp.tasks.vision.RunningMode  |  Google AI Edge  |  Google AI for Developers

Amazon AWS之Bedrock Service学习笔记

AWS Bedrock的核心功能列表 Amazon Bedrock 是 AWS 提供的一项全托管的 Serverless 服务,能够支持通过统一的 API 访问来自各个领先 AI 公司(如 Anthropic, Meta, Mistral, Amazon 等)的高性能基座模型 (FMs,Foundation Models),方便提供构建生成式 AI 应用的一系列周边功能。 !image.png 以下是 Amazon Bedrock 提供的核心功能模块列表: - 针对基础模型 API (Foundation Models) 提供统一的 Serverless API接口,用于访问业界领先的多模态大模型(包括 Anthropic/Meta/Mistral/Cohere/AI21 Labs 以及 Amazon 自家的 Titan 和 Nova 模型,也包括国内的Deepseek/GLM/Qwen/Kimi等模型)。而且允许用户的应用调用跨多个 AWS 区域动态路由模型推理请求,从而提高可用性并突破单一区域的吞吐量限制。 - 支持原生的知识库应用和全托管的 RAG(检索增强生成)管道,能够自动连接后端数据源(如 S3、Confluence、SharePoint 等),处理文档分块切片,将其转换为向量存储在 OpenSearch 等向量数据库中,并在用户提问时自动进行相似度检索和上下文增强。 - 可支持模型微调 (Fine-tuning),允许使用企业自有的标注数据对部分支持的模型(如 Amazon Titan、Llama、Cohere 等)进行有监督微调,以提升模型在特定领域的准确性。 - 基于安全护栏 (Guardrails)实现独立于模型的安全过滤层,可支持自定义主题拒绝策略,用于对模型生成内容过滤有害信息(仇恨、暴力、性)、拦截越权提示词(Prompt Injection),以及脱敏 PII(个人敏感信息),以确保 AI 输出符合企业合规要求。 Amazon Bedrock与Sagemaker、Q之间的关系与区别 在Amazon AWS 的 AI 生态中,Amazon Bedrock、Amazon SageMaker 和 Amazon Q 这三个服务的定位从表面看起来都是提供了 AI 模型访问方向上的支持,其定位和使用上的差异很容易让人感到困惑。在此对这三个服务在具体使用中的定位进行详细解释。 1. Amazon Q:其定位是开箱即用的企业级定制版本的AI 助手 - 本质上就是完全构建好的、直接面向最终用户和开发者的、企业化定制版本的生成式 AI 助手。 - 其核心特点就是不需要写任何代码就可以直接调用模型,开通即用。可分为几个不同的版本: - Amazon Q Developer (前身为 CodeWhisperer): 主要用于嵌入在 IDE (如 VS Code) 或 AWS 控制台中,辅助写代码、排查 Lambda 报错、或者解释一段复杂的 IoT Rule SQL。 - Amazon Q Business: 可以认为是面向企业内部员工的企业知识库,可以把公司内部的 HR 文档、IT 规范等文档接入进去,员工就可以直接通过 Q Business 所提供的网页端进行问答,自带了严格的权限控制(即只回答员工有权限看的内容)。 - Amazon Q开通以后是按照人头收费的,例如 Amazon Q Business 的 Pro 订阅大约是每个用户每月 20 美元。这样也就意味着它的定位是面向企业内部(To-B/To-E)或开发者个人的效率工具,不适合直接集成给 C 端消费者(To-C)使用。 1. Amazon Bedrock:其定位是大模型 API 的超级网关 (PaaS for GenAI) - 该服务是面向应用开发者(Backend/Full-stack Engineers)的完全托管 Serverless 生成式 AI 服务。 - 其核心理念就是开发人员不需要自行部署模型,也不需要管理服务器,只需要一行代码(调用 API),就能切换使用多个业界顶尖模型。因此,在 AWS 生态中,Bedrock 是开发人员构建自己的 AI 应用的基石,为AI 应用提供模型服务(MaaS)。 1. Amazon SageMaker:其定位是为机器学习模型开发和训练提供的云端支持平台(End-to-End MLOps PaaS) - 该服务是面向模型开发者(数据科学家和算法工程师)的全链路机器学习工作台。 - 使用 SageMaker 进行机器学习AI模型的开发和训练,需要开发者选择具体的 EC2 实例类型(如 ml.p4d.24xlarge),配置 Jupyter Notebook,并编写模型训练脚本(PyTorch/TensorFlow)。 - Sagemaker 不仅可以用于开发和训练生成式 AI 大模型,更可以广泛用于传统的预测性 AI(如 XGBoost、时间序列预测等)。 - 因此如果业界开源的 AI 模型无法满足具体的应用的需求,开发者可以自行开发模型的网络架构,并收集大量的训练数据从零训练或对现有模型进行深度微调,在这种情况下就应该使用 SageMaker。 下图总结了三者在定位上的差异: !AWS_AI_Architecture_%281%29.png Bedrock模型的基本使用测试 在使用代码对 Bedrock 上模型进行实际的调用测试之前,首先需要安装本地环境中的依赖包并做适当的配置。在当前系统(Windows)的终端窗口输入以下命令安装AWS CLI v2:(首次安装后需要重新进入终端才能使用aws cli命令) 首次使用AWS Cli工具时,需要先在 AWS 的web console(IAM-用户/[用户名]/安全凭证/访问密钥)中创建一个新的访问密钥,并在终端中使用aws configure命令把这个密钥配置进去: 如果要查询 AWS Bedrock 指定region所支持的model列表及其对应的 modelArn,使用以下命令: 以下使用Python基于AWS的boto3 package完成一个访问Bedrock指定模型的例子: 可以看到访问Bedrock Model的流程跟使用HTTP访问各个大模型服务的API接口没有太大区别,只不过这里是使用AWS Boto3的bedrock_runtime.converse接口并指定modelId而已。

双频WiFi+BT的通信复用与并发技术总结

在WiFi产品的演进过程中,2.4GHz和5GHz的双频(Dual Band)是一个非常常见和常用的术语。在通信的过程中,两个频段的通信究竟是通过分时等方式复用/切换还是能够直接实现双频并发通信,就形成了两套完全不同的工作逻辑。本文尝试从RF链路的复用和并发处理的逻辑上来解释这个问题。 典型的RF通信链路 要理解双频WiFi以及蓝牙模块在通信过程中的RF链路的复用与并发的问题,首先需要对基本的RF通信连接的组成结构有一个总体的理解。 从一个尽量简化的角度上,一个典型的RF通信链路的连接图如下所示: !RF_Transceiver_Link_Diagram_%281%29.png 如上图所示,这个简化的RF通信链路包含RFIC(也可以称为Transceiver)、射频前端FEM以及匹配电路与天线这几个部分。 在具体的通信过程中(以发送为例): - 数字电路部分(也就是所谓的数字基带部分)要通过射频发送的字节流0/1信号首先通过QAM、OFDM等调制处理变成模拟低频基带信号,这个过程也就是一个数字到模拟信号(DAC)的转换过程。 - 然后通过上图中的 TX LPF(低通滤波器)滤除 DAC 转换时所产生的高频数字噪声,以确保模拟基带信号平滑、纯净。 - 上图中的PLL/VCO(本振)负责产生RF通信中的高频载波频率,例如2.4GHz和5GHz。Up-Mixer(上变频器)则负责把模拟低频基带信号搬运到高频载波信号的频段,所以,经过这个环节处理以后,低频基带信号的频谱被平移到了高频载波频段,这个环节输出的 RF 射频信号就是包含了低频基带信号信息的高频载波射频信号。 - 以上RFIC环节输出的高频RF信号的功率一般比较小,所以还在在FEM的PA中进行信号功率放大,然后再通过射频开发+带通滤波电路+匹配网络的处理后,耦合到天线上发射出去。 那么,以上的整个RF链路中的各个环节,对于2.4GHz WiFi、5GHz WiFi、2.4GHz BT而言,哪些是可以在通信中是可以复用的? 主要的原则如下: 1. 首先一点,对于 2.4GHz 和 5GHz 频段的无线通信而言,上图中的 FEM 及其无源匹配电路等,由于物理学上的频率谐振和阻抗匹配特性,是不肯能跨频段复用的。这是因为,PA 和 LNA 内部的晶体管匹配电路极其依赖频率,2.4GHz(波长约12.5cm)和5GHz(波长约6cm)的阻抗特性相差极大;而匹配网络是由电感(L)和电容(C)组成的,其电抗 直接受角频率 影响,不同频段的匹配网络参数需要独立调试。因此针对 2.4GHz和5GHz 的射频通信,在芯片外(或内部封装)必须要有独立的 2.4G 和 5G FEM及其外围匹配电路。 2. 至于上图中的 RFIC 部分是否可以复用,则需要视情况来确定: 1. 2.4GHz WiFI和BT之间,尽管都是2.4GHz频段,但因为两者的基带信号调制方式和协议特性的巨大差别,所以在目前主流的 WiFi/BT Combo 芯片中,RFIC 内部的各个模块(DAC、ADC、基带滤波器、混频器等)在硅片上几乎都是两套独立的物理电路,否则很难在各项物理指标上达到比较好的权衡。 2. 而2.4GHz和5GHz WiFi两者是否可以共享RFIC,则要看设备通信的要求是双频分时通信还是双频并发通信。对于分时通信的情况,两者可以复用RFIC,此时芯片内部只有一个宽带 PLL/VCO,在切换频段时,通过改变分频比,让 PLL 从锁定 2.4GHz 跳变到锁定 5GHz,当然代价是需要几毫秒的切换锁相时间;而对于并发通信的情况,RFIC不可复用,此时必须在硅片上蚀刻两套完全独立的 PLL,一套死死锁住 2.4G,另一套死死锁住 5G,两者互不干扰。 3. 天线部分是完全可以复用的。虽然2.4GHz和5GHz两个频段的电磁波频率不同,但在天线设计上可以使用双频谐振天线(例如包含两个不同长度振子的 PCB 倒 F 天线 PIFA),可以在 2.4G 和 5G 都呈现出良好的 50 欧姆阻抗。具体的工程实践中,可以在上图中的匹配网络之后、天线之前,插入一个 Diplexer。这个器件内部包含一个低通滤波器 (LPF, 允许 2.4G 通过)和一个高通滤波器 (HPF, 允许 5G 通过)。 2.4GHz WiFi与BT Combo的RF复用设计 基于以上总结的RF链路的复用设计原则,下图是一个2.4GHz WiFi与BT Combo的典型RF设计链路: !Combo_RF_Architecture.png - 如前所述,WiFI与蓝牙之间对于基带信号的调制规范差异极大、蓝牙的跳频通信与WIFI的OFDM对于PLL的要求差异也极大,所以在RFIC层面上,Combo需要针对蓝牙和WiFi有一套各自完整独立的实现。 - 而在 FEM 方面,2.4GHz WiFi和BT都在2.4GHz频段工作,但因为两者通信中调制方式、参数和功率的巨大差不,并不能直接复用完整的FEM模块: - LNA (低噪声放大器部分):由于进入天线的都是 2.4GHz 的微弱信号,系统通常会共用同一个 LNA来放大接收到的空口信号,经过LNA放大后通过模拟开关一分为二,分别送给 WiFi 的 Down-Mixer 和 BT 的 Down-Mixer。 - PA (功率放大器):通常不复用,物理分离。如果让蓝牙直接使用 WiFi 的 PA 发射,会导致蓝牙功耗成倍飙升。因此,通常会在 FEM中包含一个极其小巧、省电的蓝牙专属小 PA。 - FEM内部在WiFi和BT通信过程中共享通信链路(LNA)和天线时,就不能同时进行 WiFi 发射和蓝牙接收,此时就需要使用PTA机制来对蓝牙和WiFi的使用时隙进行调度管理。 - RF末端的天线、匹配网络、BPF 这几个部分,则可以100% 完全复用。这是因为频段相同,阻抗特性一模一样,蓝牙完全可以直接使用 2.4GHz WiFi 调校好的 50 欧姆完美匹配网络。 2.4GHz/5GHz WiFi的RF复用设计(分时复用) 对于大部分低成本 IoT 设备、低端手机或单天线网卡而言,这类产品中的 WiFi 芯片普遍采用的是双频可选或者分时双频的机制来实现对两个频段的支持,其核心特征是:一芯双频,但同一时刻只能二选一。一旦 WIFi 芯片及其RF链路工作在 2.4 GHz,其5 GHz 链路就处于物理断电或休眠状态,反之亦然。 在硬件层面的设计师,这类双频复用的芯片为了极致削减成本,在硅片上只保留了一套 MAC、一套基带(PHY)和一套频率综合器(Synthesizer/PLL),但因为频段不同,所以FEM部分肯定是要独立分开的。 !TDM_DualBand_WiFi_Architecture_Fixed.png 在上图所展示的双频 WiFi 分时复用的架构图中: - 因为无论是2.4GHz还是5GHz的WiFi通信,其基带信号的调整规范都是相同的,所以可以彻底共享基带,也就是说ADC/DAC 和基带滤波器只有一套。 - 可以复用高频信号的 PLL,只保留一颗 PLL,然后需要在两个频段之间切换通信的时候,通过切换锁相频率分别为 2.4G 和 5G 混频器来提供本振信号。当然如上所述,这个切换的代价是每次切换都需要几个毫秒来达到本振信号的稳定,所以切换的频率不能太高,但是对于同一时刻在2.4GHz和5GHz二选一的WiFi通信应用而言足够了。 - 需要两个频段独立的射频前端 FEM,在模拟高频段,2.4G 和 5G 必须拥有完全独立的 PA、LNA 和 T/R 开关。 - 最终通过Diplexer 把两个频段的RF信号合路处理,RF信号耦合到到同一根双频天线上发射出去。 2.4GHz/5GHz WiFi的RF复用设计(DBDC) 接下来的双频并发(Dual Frequency Dual Concurrent)模式的2.4GHz和5GHz的wifi RF链路相对就比较简单了,整个RF链路能够复用的环节最少,成本最高: !DBDC_DualBand_WiFi_Architecture.png - 因为要双频并发,那么两个频段就无法复用PLL,因此RFIC部分就是完整的两套独立实现。 - 而因为2.4GHz和5GHz频段的巨大差异性,因为整个FEM及其相关的BPF以及匹配电路也需要是完全独立的实现。 - 最终两路并发的RF信号通过合路器Diplexer合并到一起,通过一条天线发射出去。当然也可以各自使用自己的独立天线发射出去。

基于Insightface的人脸识别功能的完整实现流程总结

本文详细解释了计算机视觉领域中的人脸识别功能的工作流程,并通过InsightFace这个完整的人脸检测+识别的解决方案对以上流程的具体实现进行代码级别的演示和解释。 人脸检测+识别功能的完整工作流程 在人脸识别功能在实际产品的工业落地实践中,并非是通过一个独立的模型来解决完整的问题,而是一套级联的流水线:人脸检测(Detection)+对检测到的人脸图像进行几何修正(Alignment)+人脸识别(Recognition)。 而在目前的工业界,尤其是在移动端(手机)和边缘计算设备(如瑞芯微 RK3588)上,人脸识别的标准化解决方案是 InsightFace 开源生态体系。该方案能够支持在算力受限的 NPU 上实现实时且高精度的人脸识别,其通常采用的 Pipeline 是:SCRFD(轻量级人脸检测与关键点定位) + 仿射变换对齐 + MobileFaceNet-ArcFace(轻量级特征提取)。因此整个人脸识别的流程可以分为三个阶段: - Phase 1 (检测器 - SCRFD):输入整张大图,通过 SCRFD 模型检测到人脸区域以及定位其中的 5 个关键点(双眼、鼻尖、两嘴角),该阶段输出人脸区域所在的边界框(Bounding Box)和以上的 5 个面部关键点(Landmarks)。 - Phase 2 (对齐器 - 几何变换):这部分就是进行单纯的图像几何变换,不是深度学习模块(可以直接使用Opencv的warpAffine接口执行这个变换),计算检测关键点与标准模板关键点之间的变换矩阵,主要的目标是将前一阶段检测出来的人脸图像摆正。 - Phase 3 (识别器 - MobileFaceNet):输入摆正后的人脸区域小图,输出该小图对应的 512 维特征,并且与数据库中保存的人脸特征数据进行余弦距离计算,进行人脸特征比对,找到对应的人脸ID。 !Face_Recognition_Pipeline.png 从数据流图的角度上讲: - 检测阶段:输入一张高清分辨率的大图(如1920 x 1080),经过 SCRFD 模型检测,找到 个人脸,输出与之对应的个人脸的坐标框位置()以及每个脸 5 个关键点的 (x, y) 坐标()。 - 对齐阶段:该阶段会将任意大小、角度的人脸,通过矩阵乘法裁剪缩放并旋转至绝对正脸,输出的每张人脸图像的尺寸强制锁定为 112x112,因此输入数据的维度为 。 - 识别阶段;基于对齐阶段输出的人脸图像,使用 MobileFaceNet 对其提取特征向量,每张人脸输出 512 维特征向量,因此输出数据维度为 。接下来就是基于这 512 维特征向量从向量库中通过余弦距离进行比对在,找到对应的人脸ID。 人脸识别和匹配的流程总结 以上从输入图像中检测到人脸区域,对人脸区域图像对齐以及从对齐后的人脸图像提取特征向量的过程,是比较好理解的。那么提取到人脸图像的特征向量后,进一步是如何进行人脸识别的呢? 其实这个人脸识别的过程就是一个从向量数据库中进行图像搜索的过程,这一步与OpenAI的CLIP方案(实例讲解OpenAI的轻量级多模态模型CLIP)基于自然语言进行图像搜索的实现流程如出一辙。 - 首先需要有一个人脸特征向量的注册库,其中预先预先存储好了已知人员的特征向量。这些已知人员的特征向量同样采用以上的检测-对齐-特征向量生成的步骤进行,得到注册人员的512维特征向量后保存在这个注册库中。需要注意,特征向量生成需要使用相同的 MobileFaceNet 模型来生成,也就是说,注册人员的人脸登记流程和后续进行人脸识别流程在人脸图像的特征向量生成应使用同一个 MobileFaceNet 模型,否则特征向量会不匹配。 - 后续的人脸识别的流程实际上就是一个查询向量注册库的流程。从当前摄像头抓拍并经过 MobileFaceNet 提取出人脸的实时 512 维特征,从注册库中使用余弦距离比较和判断来找到人脸在注册库中的ID。 !Vector_DB_Matching_LR.png 如果注册库中的已知人员数量不多的话,可以直接以线性的方式保存所有已知人员的特征信息。这种传统的线性遍历算法复杂度为 ,如果已知人员的数量很多的话,可以采用现代的向量数据库(如 Faiss, Milvus),这些数据库通过引入近似最近邻(ANN)算法,将搜索复杂度降至 。 在进行人脸特征向量入库以及人脸特征向量通过余弦距离来进行匹配时,需要先将向量的模长统一缩放为 1(这一步也就所谓的L2归一化),使其投射到超球面上,这是余弦相似度等价于向量内积的先决条件。这样余弦相似度的计算就简化成了: 基于InsightFace方案实现人脸识别的完整流程记录 以上详细描述了人脸检测、几何校正和缩放、人脸信息入口、人脸识别等环节的执行流程,以下通过 InsightFace 给出的完整方案来实现人脸识别的功能。 首先需要安装 InsightFace 包及其项目演示所需要的各种依赖包: 人脸检测 基于 InsightFace 的人脸检测的流程代码如下所示: FaceAnalysis API首次执行会自动下载buffalo_l模型包,其中包含了多个人脸检测+识别流程的onnx模型文件,其中: - det_10g.onnx:SCRFD 人脸检测模型。 - w600k_r50.onnx:ResNet50人脸识别模型。 - w600k_mbf.onnx:MobileFaceNet识别模型。 - genderage.onnx: 基于人脸图预测性别和年龄。 - 2d106det.onnx: 稠密关键点模块,用于预测面部的 106 个精细轮廓点(常用于美颜、贴纸)。 - 1k3d68.onnx: 3D 姿态模块,用于 3D 人脸重建和深度俯仰角计算。 进行人脸检测时,只需要调用app.get就可以得到这一个图像帧中所包含的所有人脸的坐标及其关键点信息。 几何变换 上一步已经通过SCRFD模型得到了图像中所包含的人脸区域的坐标位置以及关键点信息,下一步就是把其中包含的任意角度(俯仰、偏航、旋转)的人脸强制拉平到统一的正视面部拓扑坐标系中,以确保后续的人脸特性信息提取环节面对的人脸图像都是规范化的前向正视人脸。 这一步大致的数学原理是:在一个112x112分辨率的画布上,基于 InsightFace 提供的标准正脸的 5 个基准点常量,与前一步检测到的人脸的5个关键点的坐标位置,进行两组坐标之间的矩阵变换,只允许旋转、平移和等比例缩放(不允许拉伸变形),变换的目标是使得两组关键点之间的误差最小。 具体的工程实现中,可以直接调用OpenCV的cv2.estimateAffinePartial2D来实现这个几何变换。 !Face_Affine_Transform.png 这一步对应的代码如下: 人脸识别 经过上一步的几何变换处理以后,从原图中检测出来的每一个人脸,就变换成了112x112分辨率的正向面部人脸图像。接下来就是从这个标准化处理过后的人脸图像中提取其特征向量,并查询向量数据库得到其对应的ID。 特征向量提取的任务,在InsightFace中提供了两套模型,分别针对算力需求不同的硬件: - w600k_r50 (ResNet-50 backbone):使用经典的ResNet-50深度残差网络,特征提取能力强,但计算量大,适合服务器/高性能设备。 - w600k_mbf (MobileFaceNet backbone):专为移动端设计的轻量级网络,使用深度可分离卷积(Depthwise Separable Conv),参数量极少,推理速度快,适合嵌入式/移动端设备。 无论始终哪种模型,最终都是从前一阶段的112x112的人脸图像提取出来 512 维的特征向量。 现在有了人脸对应的 512 维特征向量,下一步就是通过计算检测人脸的特征向量与人脸向量库中特征向量的余弦距离来判断两者是否一致。两张人脸的特征向量 和 (且已经过 L2 归一化,即 ),判断它们是否为同一个人的数学依据是: 如果 (通常设为 0.45 或 0.5),则可以判定为同一人。因此计算两个特征向量之间的相似度就非常简单了,直接可以使用 numpy 的点积计算公式并与阈值进行比较就可以了:

百度RT-DETRv2模型的训练以及在RK3588平台上的适配

百度RT-DETRv2模型的官方Github地址为:https://github.com/lyuwenyu/RT-DETR,在该项目中包含了该模型基于Pytorch和百度paddlepaddle的两套独立实现。本文记录了该模型pytorch实现的自定义数据训练、推理测试、ONNX导出以及在RK3588开发板上适配的完整记录。 源码下载和训练数据集的准备 百度RT-DETRv2模型的官方Github地址为:https://github.com/lyuwenyu/RT-DETR,首先从以上地址下载源代码。可以看到该项目中实际上分别包含了RTDETR和RTDETRv2两个模型各自的paddlepaddle和pytorch的两种实现。此处只关注其中rtdetrv2_pytorch这个目录,如红框所标记。 !331b29d9-52fd-470e-ba95-31042d04f330.png 因为RTDETR模型的训练数据默认采用COCO标注格式,所以在训练之前要提前要把自己的数据集标注格式转换为COCO格式。 在Roboflow上找到一个baby五官检测的数据集,以COCO标注格式下载该数据集,后续使用这个数据集对RT-DETRv2模型进行训练:https://universe.roboflow.com/pavel-txwf8/baby_monitoring-x30nd-1hjmq/dataset/1。 下一步开始修改训练和数据集的配置文件。在 RT-DETRv2 的源码中,rtdetrv2_pytorch\configs\rtdetrv2 在目录下包含有多个训练模型和数据集的配置文件: !image.png 这些配置文件的命名遵循了典型的 “模型缩放 + 骨干网络 + 训练策略” 的工程化逻辑。 - 模型 Backbone 核心版本: - rtdetrv2_r18vd / rtdetrv2_r34vd / rtdetrv2_r50vd:其中的r18、r34、r50 指的是Backbone部分使用的 ResNet 骨干网络的层数。其中的vd 表示使用了 ResNet-vd 结构。相比原始 ResNet,它在下采样部分做了优化,这对保持特征信息更有利,且对 NPU 非常友好。 - hgnetv2:百度自研的轻量化骨干网络。 - 模型缩放: - rtdetrv2_s / rtdetrv2_m / rtdetrv2_l:其中的s、m、l类似于 YOLO 的 Small/Medium/Large 缩放。通常涉及到隐藏层维度和编码器层数的设置。 - 训练周期数量: - 120e / 72e / 30e:其中的 e 代表 Epochs(训练轮数)。120e 表示训练120个epoch,一般用于从零开始训练或者长周期微调,精度最高;30e 训练30个epoch,通常用于快速验证或基于预训练模型的微调。 - 数据集标注格式: - coco:表示该配置默认适配 MS-COCO 数据集的标注格式。 此处以其中的rtdetrv2_r18vd_120e_coco.yml为基础进行自定义数据集的训练配置文件的修改。主要的修改内容,就是在以上配置文件的末尾增加自己数据集的相关配置信息如下: RTDETRv2模型的训练以及本地推理测试 训练之前要提前下载好模型的预训练文件,后续基于这个预训练文件进行继续训练。因为我们训练配置文件选择的是rtdetrv2_r18vd_120e_coco ,因此此处下载的预训练文件应该是rtdetrv2_r18vd_120e_coco.pth。下载地址为:https://github.com/lyuwenyu/storage/releases/download/v0.1/rtdetrv2_r18vd_120e_coco.pth。 至此,训练的数据配置文件以及预训练文件都已经准备好,下一步就是调用rtdetrv2_pytorch\tools\train.py脚本来执行训练过程: 训练命令: 训练中断后继续训练: 训练过程中和完成后生成的文件保存在rtdetrv2_r18vd_120e_coco.yml 中设置的output_dir目录中,其中包含的文件: - checkpoint0xxx.pth:训练过程中每一轮的权重文件。 - last.pth: 最后一轮的权重文件。 - best.pth: 训练过程中 mAP 最高的那一轮权重,即训练结果文件。推理测试和后续的模型转换应使用这个模型权重文件。 - log.txt: 该文件中记录了训练全过程的 Loss 和 AP 变化。 训练完成后可以使用以下命令进行推理测试,以确认训练结果的有效性。该项目中包含了一个本地推理脚本文件:rtdetrv2_pytorch\references\deploy\rtdetrv2_torch.py 。 训练过程中存在的问题: 因为训练数据集中有一些图片的分辨率太大了(6000x4000),需要修改train.py的代码,解除对输入图像分辨率大小的限制,否则在训练过程中会报错。在train.py的开头部分包含: 模型的ONNX导出 在百度的RTDETRv2_pytorch的实现中提供了一个onnx模型文件导出的脚本:rtdetrv2_pytorch\tools\export_onnx.py。但是因为我们后续要移植到RK3588平台上,需要对这个文件做一下改动,去掉其中后处理部分的逻辑,这一点对于后续进行RK3588的模型转换和板端推理而言至关重要。 基于rtdetrv2_pytorch\tools\export_onnx.py 对onnx转换脚本进行修改,主要涉及到Model的声明以及onnx导出配置方面的修改 当然因为该模型中包含有Grid Sample算子,与 RT-DETR模型的自定义数据训练以及在RK3588上的适配 一文遇到的问题相同,最新版本的RKNN运行时库(v2.3.2)不支持这个算子,所以按照相同的处理路径,在导出模型之前仍然使用Monkey Patch的办法替换掉Grid Sample算子: 然后执行以上脚本进行ONNX格式的导出(每次导出ONNX以后最好再使用onnxsim进一步简化模型文件中的算子): RK3588板端模型转换与板端运行 后续针对RK3588平台所进行的RKNN模型的转换流程和板端运行的流程与步骤与RT-DETR模型的自定义数据训练以及在RK3588上的适配 没有差异,只不过需要注意,模型转换应设置为FP16量化格式。如果设置为W8A8这样的量化模式,在板端运行的结果很差,应该是量化影响了算法的精度。