1. 锚点图到底是什么从“找中心”到“画框框”的通用语言大家好我是老张在AI和算法可视化这块摸爬滚打了十来年。今天想和大家聊聊一个听起来有点学术但实际上非常“接地气”的工具——锚点图。你可能在聚类分析、深度学习论文或者目标检测的代码里见过它感觉它无处不在但又有点说不清道不明。别担心今天我就用最直白的话带你彻底搞懂它并且手把手教你把它用起来。简单来说锚点图就是一种“看图说话”的高级方式专门用来展示那些在数据或模型中起“定海神针”作用的关键点。这些关键点我们称之为“锚点”。你可以把它想象成一场大型线下聚会锚点就是组织者事先定好的几个核心集合点比如A点在星巴克门口B点在电影院大厅而来参加聚会的每个人数据点最终都会根据距离远近选择去其中一个集合点报到。锚点图就是把这场聚会的全景包括集合点在哪、谁去了哪个点一目了然地画给你看。它的核心作用就三个解释、指导和优化。在聚类里它解释数据是怎么分成一坨一坨的在深度学习的度量学习里它指导模型“谁和谁应该更亲近”在目标检测里它优化算法告诉模型“大概在哪个位置、用多大尺寸的框去找物体”。我刚开始接触时也觉得抽象但后来发现只要抓住“锚点”这个核心所有应用场景都是相通的。下面我就从最经典的聚类分析开始带你一步步解锁锚点图的实战技能。2. 聚类分析中的锚点图一眼看穿数据怎么“抱团”聚类是我们探索数据最常用的手段之一但光有聚类结果标签还不够。我们常常会问这些类是怎么形成的聚类中心的位置合理吗有没有异常点这时候锚点图就该登场了。在聚类场景下锚点就是每个簇的中心点锚点图就是描绘所有数据点如何围绕各自中心点分布的地图。2.1 超越K-Means动态锚点图揭示迭代过程我们最熟悉的K-Means聚类其本质就是寻找最优锚点中心点的过程。一个静态的聚类结果图只能给你最终答案而一个动态的锚点图却能带你亲历整个迭代优化之旅。这就像看一部快进的电影你能看到中心点是如何从随机初始位置“连滚带爬”地移动到最终最佳位置的。下面这个增强版的代码不仅画出了最终结果更把每一次迭代的“锚点迁徙史”都记录了下来import numpy as np import matplotlib.pyplot as plt from sklearn.cluster import KMeans from matplotlib.animation import FuncAnimation # 生成更复杂的数据三个不同形状、密度的簇 np.random.seed(42) cluster1 np.random.randn(150, 2) * 0.8 np.array([2, 2]) # 紧凑 cluster2 np.random.randn(100, 2) * 1.5 np.array([-2, -2]) # 分散 cluster3 np.random.randn(80, 2) * 0.6 np.array([2, -2]) # 非常紧凑 X np.vstack([cluster1, cluster2, cluster3]) # 为了展示迭代我们自定义一个记录历史的K-Means class KMeansWithHistory: def __init__(self, n_clusters3, max_iter10): self.n_clusters n_clusters self.max_iter max_iter self.centroid_history [] # 记录每轮中心点 self.label_history [] # 记录每轮标签 def fit(self, X): # 随机初始化中心点 initial_indices np.random.choice(len(X), self.n_clusters, replaceFalse) centroids X[initial_indices] self.centroid_history.append(centroids.copy()) for i in range(self.max_iter): # 步骤1: 分配标签每个点到最近中心的距离 distances np.sqrt(((X[:, np.newaxis, :] - centroids[np.newaxis, :, :]) ** 2).sum(axis2)) labels np.argmin(distances, axis1) self.label_history.append(labels.copy()) # 步骤2: 更新中心点计算每个簇的均值 new_centroids np.array([X[labels j].mean(axis0) for j in range(self.n_clusters)]) self.centroid_history.append(new_centroids.copy()) # 如果中心点不再变化提前结束 if np.allclose(centroids, new_centroids): print(f在第{i1}轮迭代后收敛。) break centroids new_centroids return self # 训练并记录历史 kmeans_history KMeansWithHistory(n_clusters3, max_iter15) kmeans_history.fit(X) history kmeans_history.centroid_history labels_history kmeans_history.label_history # 绘制最终锚点图 fig, (ax1, ax2) plt.subplots(1, 2, figsize(14, 5)) final_labels labels_history[-1] final_centroids history[-1] # 左图最终聚类结果 scatter ax1.scatter(X[:, 0], X[:, 1], cfinal_labels, cmaptab20c, alpha0.6, s30) ax1.scatter(final_centroids[:, 0], final_centroids[:, 1], cred, markerX, s400, linewidths3, edgecolorblack, label最终锚点) ax1.set_title(最终聚类锚点图) ax1.legend() ax1.grid(True, linestyle--, alpha0.5) # 右图锚点移动轨迹 colors [darkorange, purple, brown] for i in range(len(X)): ax2.scatter(X[i, 0], X[i, 1], clightgray, alpha0.2, s10) # 背景数据点 # 绘制每个锚点的移动路径 for cluster_idx in range(3): traj_x [step[cluster_idx, 0] for step in history] traj_y [step[cluster_idx, 1] for step in history] ax2.plot(traj_x, traj_y, o-, linewidth3, markersize8, colorcolors[cluster_idx], labelf锚点{cluster_idx1}轨迹) ax2.scatter(traj_x[-1], traj_y[-1], colorcolors[cluster_idx], s300, markerX, edgecolorblack, zorder5) ax2.set_title(锚点中心点迭代移动轨迹) ax2.legend() ax2.grid(True, linestyle--, alpha0.5) plt.tight_layout() plt.show()运行这段代码你会得到两张图。左图是大家熟悉的最终聚类状态不同颜色的点属于不同的簇红色的“X”就是最终的锚点。真正有价值的是右图它清晰展示了三个锚点中心点从初始随机位置是如何一步步“走”到最终位置的。通过这个动态视角你可以直观判断迭代是否收敛轨迹不再大幅移动、初始点选择是否糟糕轨迹是否过长、曲折甚至能发现某个簇的锚点移动微小说明它很早就定位准确了。这种洞察是任何静态图表或数字指标都无法替代的。2.2 实战避坑如何为你的数据选择合适的锚点数量K-Means需要你事先指定簇的数量K也就是锚点的个数。猜错了怎么办锚点图同样能帮你。我们可以用一种叫做**“手肘法”结合锚点图可视化**的方法来辅助决策。思路是尝试不同的K值计算每个K值下所有数据点到其对应锚点的平均距离称为惯性然后画出曲线。通常曲线拐点像手肘对应的K值就是较优选择。# 接续上面的数据 X inertias [] k_range range(1, 11) fig, axes plt.subplots(2, 5, figsize(20, 8)) axes axes.flatten() for idx, k in enumerate(k_range): kmeans KMeans(n_clustersk, random_state42, n_init10) kmeans.fit(X) inertias.append(kmeans.inertia_) # 惯性即样本到最近聚类中心的平方距离之和 # 绘制每个K值对应的锚点图 ax axes[idx] if k 1: ax.scatter(X[:, 0], X[:, 1], cgray, alpha0.5, s20) ax.scatter(kmeans.cluster_centers_[:, 0], kmeans.cluster_centers_[:, 1], cred, markerX, s200, edgecolorblack) else: ax.scatter(X[:, 0], X[:, 1], ckmeans.labels_, cmaptab20, alpha0.6, s20) ax.scatter(kmeans.cluster_centers_[:, 0], kmeans.cluster_centers_[:, 1], crange(k), cmaptab20, markerX, s200, edgecolorblack, linewidth2) ax.set_title(fK{k}) ax.set_xticks([]) ax.set_yticks([]) plt.suptitle(不同K值下的聚类锚点图对比, fontsize16) plt.tight_layout() plt.show() # 绘制手肘图 plt.figure(figsize(8, 5)) plt.plot(k_range, inertias, bo-) plt.xlabel(聚类数量 K) plt.ylabel(惯性 (Inertia)) plt.title(手肘法寻找最优K值) plt.grid(True, linestyle--, alpha0.7) plt.show()当你运行这段代码会看到两排子图展示了K从1到10的聚类结果。同时手肘图会显示惯性随K值下降的曲线。你会发现当K3时惯性下降的斜率发生明显变化出现“手肘”并且从子图也能直观看到K3时三个簇的划分最为清晰自然锚点的位置也恰到好处地处于每个簇的“心脏”地带。当K继续增大比如K4或5你会看到有些簇被强行拆分出现一些锚点距离非常近或者某个簇的锚点“悬浮”在数据稀疏区域的情况这些都是过拟合的信号。通过这种可视化你就能更有信心地为你的数据选择最合适的锚点数量。3. 深度学习中的锚点图让模型学会“对比”与“度量”如果说聚类中的锚点是“地理中心”那么深度学习特别是度量学习和对比学习中的锚点就是“关系枢纽”。这里的锚点不再是一个统计意义上的中心而是一个具体的样本比如一张图片模型的任务是学习让相似的样本正样本在特征空间里靠近这个锚点让不相似的样本负样本远离它。锚点图在这里的作用就是让这种抽象的“拉近推远”关系变得肉眼可见。3.1 三元组损失一个锚点引发的“拉锯战”三元组损失是理解这个概念的绝佳例子。它需要三个元素一个锚点样本Anchor一个正样本Positive与锚点同类一个负样本Negative与锚点不同类。损失函数的目标是让锚点与正样本之间的距离小于锚点与负样本之间的距离并且要至少小出一个“间隔”。光看公式可能头晕我们直接画出来import numpy as np import matplotlib.pyplot as plt from matplotlib.patches import FancyArrowPatch # 模拟一个学习过程初始状态 vs 理想状态 np.random.seed(10) # 初始状态特征未很好分离 anchor_init np.array([0, 0]) positive_init np.array([1.8, 0.5]) # 初始离得不算近 negative_init np.array([0.5, 2.5]) # 初始离得不算远 # 理想状态经过模型优化后 anchor_ideal np.array([0, 0]) positive_ideal np.array([0.3, 0.2]) # 被拉近 negative_ideal np.array([2.5, 2.5]) # 被推远 fig, (ax1, ax2) plt.subplots(1, 2, figsize(14, 6)) # 左图初始状态 ax1.scatter(anchor_init[0], anchor_init[1], cred, s400, markero, edgecolorblack, linewidth3, label锚点 (Anchor), zorder5) ax1.scatter(positive_init[0], positive_init[1], cblue, s300, marker^, edgecolorblack, linewidth2, label正样本 (Positive), zorder5) ax1.scatter(negative_init[0], negative_init[1], cgreen, s300, markers, edgecolorblack, linewidth2, label负样本 (Negative), zorder5) # 画距离线 ax1.plot([anchor_init[0], positive_init[0]], [anchor_init[1], positive_init[1]], b--, linewidth2, alpha0.7) ax1.plot([anchor_init[0], negative_init[0]], [anchor_init[1], negative_init[1]], g--, linewidth2, alpha0.7) # 标注距离 ax1.text((anchor_init[0]positive_init[0])/2, (anchor_init[1]positive_init[1])/2 - 0.15, fd(A,P){np.linalg.norm(positive_init - anchor_init):.2f}, colorblue, fontsize11, hacenter, bboxdict(boxstyleround,pad0.3, facecolorwhite, alpha0.8)) ax1.text((anchor_init[0]negative_init[0])/2, (anchor_init[1]negative_init[1])/2 0.15, fd(A,N){np.linalg.norm(negative_init - anchor_init):.2f}, colorgreen, fontsize11, hacenter, bboxdict(boxstyleround,pad0.3, facecolorwhite, alpha0.8)) ax1.set_xlim(-1, 3.5) ax1.set_ylim(-1, 3.5) ax1.set_title(初始状态特征未充分学习, fontsize14, pad15) ax1.legend(locupper left) ax1.grid(True, linestyle:, alpha0.5) ax1.set_aspect(equal) # 右图理想状态 ax2.scatter(anchor_ideal[0], anchor_ideal[1], cred, s400, markero, edgecolorblack, linewidth3, label锚点 (Anchor), zorder5) ax2.scatter(positive_ideal[0], positive_ideal[1], cblue, s300, marker^, edgecolorblack, linewidth2, label正样本 (Positive), zorder5) ax2.scatter(negative_ideal[0], negative_ideal[1], cgreen, s300, markers, edgecolorblack, linewidth2, label负样本 (Negative), zorder5) ax2.plot([anchor_ideal[0], positive_ideal[0]], [anchor_ideal[1], positive_ideal[1]], b-, linewidth3, alpha0.9) ax2.plot([anchor_ideal[0], negative_ideal[0]], [anchor_ideal[1], negative_ideal[1]], g-, linewidth3, alpha0.9) # 用箭头表示“拉近”和“推远”的力 arrow_p FancyArrowPatch(posApositive_init, posBpositive_ideal, arrowstyle-,head_width0.4,head_length0.6, colorblue, linewidth2, alpha0.6, linestyle:) ax2.add_patch(arrow_p) arrow_n FancyArrowPatch(posAnegative_init, posBnegative_ideal, arrowstyle-,head_width0.4,head_length0.6, colorgreen, linewidth2, alpha0.6, linestyle:) ax2.add_patch(arrow_n) ax2.text(positive_ideal[0]/2, positive_ideal[1]/2 - 0.1, fd(A,P){np.linalg.norm(positive_ideal - anchor_ideal):.2f}, colorblue, fontsize11, hacenter, bboxdict(boxstyleround,pad0.3, facecolorwhite, alpha0.9)) ax2.text((anchor_ideal[0]negative_ideal[0])/2, (anchor_ideal[1]negative_ideal[1])/2 0.1, fd(A,N){np.linalg.norm(negative_ideal - anchor_ideal):.2f}, colorgreen, fontsize11, hacenter, bboxdict(boxstyleround,pad0.3, facecolorwhite, alpha0.9)) ax2.text(0.8, 1.5, 模型学习的目标\n拉近正样本推远负样本, fontsize12, bboxdict(boxstyleround,pad0.5, facecoloryellow, alpha0.3)) ax2.set_xlim(-1, 3.5) ax2.set_ylim(-1, 3.5) ax2.set_title(理想状态三元组损失优化后, fontsize14, pad15) ax2.legend(locupper left) ax2.grid(True, linestyle:, alpha0.5) ax2.set_aspect(equal) plt.suptitle(三元组损失中的锚点图可视化“拉近”与“推远”, fontsize16, y1.02) plt.tight_layout() plt.show()这张对比图非常直观地展示了模型学习的动态过程。左图是训练初期正负样本与锚点的距离差别不大模型“敌我不分”。右图是训练目标通过三元组损失的优化正样本被强力拉向锚点负样本被狠狠推开两者之间形成了一个清晰的“安全距离”间隔。图中的虚线箭头示意了这种“力”的方向。在实际项目中我经常用这种图向团队解释为什么模型能区分开相似物体比如不同品种的猫其核心就是通过海量的三元组让模型在特征空间里为每个类别都学会了这种以锚点为核心的“排兵布阵”。3.2 从点到面批次数据的锚点关系矩阵图在实际训练中我们不会一次只处理一个三元组而是处理一个批次Batch的数据。这时我们可以将整个批次的特征提取出来计算它们之间的相似度比如余弦相似度然后以热力图的形式呈现。这张热力图中每一行或每一列都可以看作是以该样本为锚点观察它与批次内所有其他样本的关系。import seaborn as sns from sklearn.metrics.pairwise import cosine_similarity # 模拟一个批次的特征向量假设批次大小为8特征维度为16 # 前4个样本属于类别A索引0-3后4个属于类别B索引4-7 np.random.seed(42) batch_features np.random.randn(8, 16) * 0.5 # 人为制造类内相似性让同类样本的特征更接近 batch_features[0:4] np.random.randn(4, 16) * 0.2 1.0 # 类别A有一个共同偏置 batch_features[4:8] np.random.randn(4, 16) * 0.2 - 1.0 # 类别B有一个共同偏置 # 计算余弦相似度矩阵 similarity_matrix cosine_similarity(batch_features) # 绘制相似度热力图锚点关系矩阵 plt.figure(figsize(10, 8)) ax sns.heatmap(similarity_matrix, annotTrue, # 显示数值 fmt.2f, # 数值格式 cmapcoolwarm, center0, squareTrue, cbar_kws{label: 余弦相似度}, xticklabels[fSample_{i} for i in range(8)], yticklabels[fSample_{i} for i in range(8)]) ax.set_title(批次样本特征相似度矩阵锚点关系视图, fontsize15, pad20) ax.hlines([4], *ax.get_xlim(), colorsblack, linestylesdashed, linewidth2) ax.vlines([4], *ax.get_ylim(), colorsblack, linestylesdashed, linewidth2) ax.text(2, 8.5, 类别 A, hacenter, fontsize12, fontweightbold) ax.text(6, 8.5, 类别 B, hacenter, fontsize12, fontweightbold) ax.text(-0.8, 2, 类别 A, hacenter, fontsize12, fontweightbold, rotation90) ax.text(-0.8, 6, 类别 B, hacenter, fontsize12, fontweightbold, rotation90) plt.tight_layout() plt.show()这张热力图信息量巨大。颜色越红值越接近1表示两个样本特征越相似越蓝值越接近-1表示越不相似。你可以清晰地看到两个明显的红色方块对角线分布这表示类别A内部的样本彼此相似左上红块类别B内部的样本也彼此相似右下红块。而两个类别交叉的区域右上和左下则呈现蓝色表示类间差异明显。如果我们把每一行当作一个锚点那么这一行就展示了该锚点与批次内所有其他样本的“亲疏关系”。一个好的模型其产出的相似度矩阵就应该呈现出这种清晰的“分块对角线”结构。在模型调试时如果发现热力图一片模糊或红色分散那就说明模型没有学好特征需要检查数据、损失函数或超参数了。4. 目标检测中的锚点图从“乱枪打鸟”到“精准狙击”终于来到大家最关心的目标检测环节了。如果说前两个场景的锚点是“点”那么目标检测里的锚点就是“框”专业名称叫锚框。这是锚点图应用最成功、也最直观的领域之一。早期目标检测算法比如滑动窗口就像“乱枪打鸟”在图像上密密麻麻地扫描计算量巨大。锚框的引入相当于让模型学会了“经验性狙击”只在最可能出目标的位置和尺度上生成候选框效率和质量都大幅提升。4.1 锚框的本质一组预先定义好的“猜测模板”你可以把锚框理解为放在图像不同位置、不同大小、不同长宽比的“空白相框”。模型的任务不是凭空生成框而是对这些预设的锚框进行两项微调第一判断这个锚框里有没有目标分类第二对这个锚框进行精细的位置和大小调整回归使其紧紧套住目标。我们以Faster R-CNN中经典的9个锚框3种尺度×3种长宽比为例来看看它们在一张特征图上的分布import matplotlib.pyplot as plt import matplotlib.patches as patches import numpy as np def generate_anchors(center_x, center_y, base_size256, ratios[0.5, 1, 2], scales[8, 16, 32]): 在特征图的一个点上生成9个锚框对应原图。 center_x, center_y: 特征图上的点坐标例如特征图上的(3,3)点。 base_size: 基准大小用于缩放。 ratios: 宽高比 [w/h]。 scales: 尺度缩放因子。 返回在原图坐标下的锚框列表 [x1, y1, x2, y2]。 anchors [] # 将特征图坐标映射回原图坐标假设下采样步长为16即stride16 stride 16 center_x_original center_x * stride center_y_original center_y * stride for scale in scales: for ratio in ratios: # 计算锚框的宽和高在原图尺度下 # 面积 (base_size * scale)^2 area (base_size * scale) ** 2 # 根据宽高比计算宽和高 h np.sqrt(area / ratio) w ratio * h # 计算左上角坐标 x1 center_x_original - w / 2.0 y1 center_y_original - h / 2.0 x2 center_x_original w / 2.0 y2 center_y_original h / 2.0 anchors.append([x1, y1, x2, y2]) return np.array(anchors) # 模拟一张特征图例如下采样16倍后的大小为10x10 feat_map_h, feat_map_w 10, 10 # 我们选取特征图中心区域的一个点(5,5)来可视化其上的9个锚框 center_x, center_y 5, 5 anchors generate_anchors(center_x, center_y) # 绘制原图背景假设原图为 640x480 fig, ax plt.subplots(1, figsize(10, 8)) ax.set_xlim(0, 640) ax.set_ylim(480, 0) # 图像坐标系y轴向下 ax.set_aspect(equal) ax.set_title(f特征图点({center_x},{center_y})处生成的9个锚框原图尺度, fontsize14, pad15) ax.set_xlabel(原图X坐标 (像素)) ax.set_ylabel(原图Y坐标 (像素)) ax.grid(True, linestyle--, alpha0.3) # 画出特征图网格在原图上的位置 stride 16 for i in range(feat_map_h 1): ax.axhline(yi * stride, colorgray, linestyle-, linewidth0.5, alpha0.5) for j in range(feat_map_w 1): ax.axvline(xj * stride, colorgray, linestyle-, linewidth0.5, alpha0.5) # 用红点标出特征图上的这个点对应的原图位置 center_x_orig center_x * stride center_y_orig center_y * stride ax.scatter(center_x_orig, center_y_orig, colorred, s100, zorder5, label特征点对应原图位置) # 定义9种颜色和线型方便区分 colors plt.cm.tab10(np.arange(9)/10) line_styles [-, --, -.] labels_added set() # 画出9个锚框 for idx, (x1, y1, x2, y2) in enumerate(anchors): w x2 - x1 h y2 - y1 ratio w / h scale_idx idx // 3 ratio_idx idx % 3 ratio_text f{ratios[ratio_idx]:.1f} scale_text f{scales[scale_idx]} # 为每种宽高比创建一个图例标签避免重复 label_key fratio{ratio_text} if label_key not in labels_added: label label_key labels_added.add(label_key) else: label None rect patches.Rectangle((x1, y1), w, h, linewidth2.5, edgecolorcolors[idx], facecolornone, linestyleline_styles[scale_idx], labellabel) ax.add_patch(rect) # 在框中心标注尺度 ax.text((x1x2)/2, (y1y2)/2, fS{scale_text}, fontsize9, hacenter, vacenter, colorcolors[idx], fontweightbold, bboxdict(boxstyleround,pad0.2, facecolorwhite, alpha0.7)) # 添加一个自定义图例解释线型代表尺度 from matplotlib.lines import Line2D custom_lines [Line2D([0], [0], colorgray, lw2, linestylels) for ls in line_styles] ax.legend(custom_lines, [fScale{s} for s in scales], locupper left, title尺度 (Scale)) # 添加另一个图例显示颜色代表的宽高比 handles, labels ax.get_legend_handles_labels() # 避免重复我们手动添加一个宽高比说明 from matplotlib.patches import Patch legend_elements [Patch(facecolornone, edgecolorcolors[i], labelfRatio{ratios[i%3]:.1f}) for i in [0,3,6]] ax.legend(handleshandles legend_elements, locupper right, title图例) plt.tight_layout() plt.show()运行这段代码你会看到一张非常直观的锚框生成图。图像上的灰色网格代表了特征图的位置红点表示我们选中的那个特征点。以这个红点为中心生成了9个不同大小、不同形状的矩形框这就是锚框。三种不同的虚线样式代表了三种尺度小、中、大三种不同的颜色代表了三种宽高比瘦高、正方形、扁宽。模型在训练时会学习对于图像中某个位置这9种“猜测模板”中哪一个最可能包含目标以及需要如何调整这个模板的四个边才能完美框住目标。这种设计极大地减少了搜索空间让模型的学习目标更加明确。4.2 可视化锚框匹配过程理解“正负样本”的由来目标检测训练的一个关键步骤是锚框匹配即为每个锚框分配一个标签是正样本包含目标、负样本背景还是忽略不计。这个过程直接决定了模型学什么。我们可以通过锚点图来可视化这个匹配过程理解模型是如何选择“学习重点”的。# 模拟一张图像和一个真实目标框Ground Truth Box img_width, img_height 640, 480 gt_box np.array([280, 150, 420, 320]) # [x1, y1, x2, y2] 格式 # 在图像上生成密集的锚框简化版实际在特征图的每个点上生成 stride 16 feat_map_w, feat_map_h img_width // stride, img_height // stride all_anchors [] anchor_centers [] for i in range(feat_map_h): for j in range(feat_map_w): anchors_at_point generate_anchors(j, i) # 使用前面的函数 all_anchors.extend(anchors_at_point) anchor_centers.extend([(j*stride stride//2, i*stride stride//2)] * 9) # 锚框中心 all_anchors np.array(all_anchors) anchor_centers np.array(anchor_centers) # 简单的匹配逻辑计算每个锚框与真实框的交并比(IoU) def compute_iou(box1, box2): # box: [x1, y1, x2, y2] inter_x1 max(box1[0], box2[0]) inter_y1 max(box1[1], box2[1]) inter_x2 min(box1[2], box2[2]) inter_y2 min(box1[3], box2[3]) inter_area max(0, inter_x2 - inter_x1) * max(0, inter_y2 - inter_y1) area1 (box1[2]-box1[0]) * (box1[3]-box1[1]) area2 (box2[2]-box2[0]) * (box2[3]-box2[1]) union_area area1 area2 - inter_area return inter_area / union_area if union_area 0 else 0 ious np.array([compute_iou(anchor, gt_box) for anchor in all_anchors]) # 根据IoU分配标签IoU0.7为正样本IoU0.3为负样本其余忽略 pos_thresh, neg_thresh 0.7, 0.3 labels np.zeros(len(ious), dtypeint) # 0: 忽略, 1: 正样本, -1: 负样本 labels[ious pos_thresh] 1 labels[ious neg_thresh] -1 # 可视化 fig, ax plt.subplots(1, figsize(12, 9)) ax.set_xlim(0, img_width) ax.set_ylim(img_height, 0) ax.set_aspect(equal) ax.set_title(锚框匹配可视化正样本、负样本与忽略区域, fontsize15, pad20) ax.set_xlabel(原图X坐标) ax.set_ylabel(原图Y坐标) ax.grid(True, linestyle:, alpha0.3) # 1. 画出所有锚框的中心点用极浅的灰色表示锚点密度 ax.scatter(anchor_centers[:, 0], anchor_centers[:, 1], colorlightgray, s1, alpha0.3, label锚框中心点密集) # 2. 画出真实目标框Ground Truth gt_rect patches.Rectangle((gt_box[0], gt_box[1]), gt_box[2]-gt_box[0], gt_box[3]-gt_box[1], linewidth4, edgecolorlime, facecolornone, label真实目标框 (GT)) ax.add_patch(gt_rect) # 3. 画出正样本锚框IoU 0.7 pos_indices np.where(labels 1)[0] for idx in pos_indices[:15]: # 只画一部分避免太乱 x1, y1, x2, y2 all_anchors[idx] rect patches.Rectangle((x1, y1), x2-x1, y2-y1, linewidth2, edgecolorred, facecolorred, alpha0.3, label正样本锚框 (IoU0.7) if idx pos_indices[0] else ) ax.add_patch(rect) # 4. 画出负样本锚框IoU 0.3—— 选取离GT较远的一些展示 neg_indices np.where(labels -1)[0] # 为了可视化清晰我们只选取距离GT中心较远的负样本 gt_center np.array([(gt_box[0]gt_box[2])/2, (gt_box[1]gt_box[3])/2]) neg_centers anchor_centers[neg_indices] distances np.linalg.norm(neg_centers - gt_center, axis1) # 选取距离最远的前20个负样本展示 far_neg_indices neg_indices[np.argsort(distances)[-20:]] for idx in far_neg_indices: x1, y1, x2, y2 all_anchors[idx] rect patches.Rectangle((x1, y1), x2-x1, y2-y1, linewidth1, edgecolorblue, facecolorblue, alpha0.15, label负样本锚框 (IoU0.3) if idx far_neg_indices[0] else ) ax.add_patch(rect) # 5. 添加说明文字 ax.text(gt_box[0], gt_box[1]-10, 真实目标, colorlime, fontsize12, fontweightbold, bboxdict(facecolorblack, alpha0.7)) ax.text(100, 400, 红色半透明框正样本锚框\n与GT重叠度高模型需学习调整它, colorred, fontsize11, bboxdict(boxstyleround,pad0.5, facecolorwhite, alpha0.8)) ax.text(400, 50, 蓝色半透明框负样本锚框\n与GT重叠度低模型需将其判为背景, colorblue, fontsize11, bboxdict(boxstyleround,pad0.5, facecolorwhite, alpha0.8)) ax.text(300, 450, 灰色点所有锚框的中心位置\n展示了锚框的密集分布, colorgray, fontsize10, bboxdict(boxstyleround,pad0.5, facecolorwhite, alpha0.8)) # 整理图例去重 handles, labels_legend ax.get_legend_handles_labels() by_label dict(zip(labels_legend, handles)) ax.legend(by_label.values(), by_label.keys(), loclower right, fontsize10) plt.tight_layout() plt.show()这张图是理解目标检测训练的核心。图中绿色的实线框是我们的真实目标。那些与绿色框重叠度很高IoU0.7的红色半透明框被标记为正样本模型需要学习如何将这些红色的锚框微调使其无限接近绿色框。而那些远离目标、几乎不重叠的蓝色半透明框被标记为负样本模型需要学会把这些框判断为“背景”。图像上密密麻麻的灰色点代表了所有锚框的中心你可以感受到锚框的密度。通过这张锚点图你可以直观地看到模型并不是学习所有的框而是有选择地学习那些与真实目标关系密切的“高质量候选框”以及明确拒绝那些明显是背景的框。这种可视化对于调试检测模型非常有用例如如果你发现某个目标没有被检测到可以检查其位置附近是否有足够多、尺寸合适的锚框被标记为正样本。4.3 YOLO与SSD的锚框设计差异一张图看懂流派之争不同的目标检测算法其锚框的设计哲学也不同。主流的YOLO系列和SSD算法在锚框的使用上就有明显区别。我们可以用锚点图来直观对比这两种风格。# 模拟YOLO和SSD在不同特征图层上的锚框设计思路 fig, axes plt.subplots(1, 2, figsize(16, 7)) # 左图YOLO风格以YOLOv3为例—— 不同尺度的特征图使用不同尺寸的锚框 ax1 axes[0] ax1.set_xlim(0, 640) ax1.set_ylim(640, 0) ax1.set_title(YOLO风格锚框设计多尺度特征图, fontsize14, pad15) ax1.set_xlabel(图像宽度) ax1.set_ylabel(图像高度) ax1.grid(True, linestyle--, alpha0.3) # 假设有三层特征图下采样步长分别为32, 16, 8 strides [32, 16, 8] colors [red, blue, green] layer_names [深层特征图 (stride32), 中层特征图 (stride16), 浅层特征图 (stride8)] # 为每层特征图定义一组锚框尺寸宽高这是YOLO预先在数据集上聚类得到的 anchors_per_layer { 32: [(116, 90), (156, 198), (373, 326)], # 大目标 16: [(30, 61), (62, 45), (59, 119)], # 中目标 8: [(10, 13), (16, 30), (33, 23)] # 小目标 } for stride, color, name in zip(strides, colors, layer_names): # 在该层特征图上选取几个点来展示锚框 feat_map_size 640 // stride sample_points [(feat_map_size//4, feat_map_size//4), (feat_map_size//2, feat_map_size//2), (3*feat_map_size//4, 3*feat_map_size//4)] for px, py in sample_points: center_x (px 0.5) * stride # 锚框中心点原图坐标 center_y (py 0.5) * stride for aw, ah in anchors_per_layer[stride]: x1 center_x - aw / 2.0 y1 center_y - ah / 2.0 rect patches.Rectangle((x1, y1), aw, ah, linewidth2, edgecolorcolor, facecolornone, alpha0.7, linestyle-, labelname if pxsample_points[0][0] and pysample_points[0][1] else ) ax1.add_patch(rect) # 画特征点 ax1.scatter(center_x, center_y, colorcolor, s50, markero, alpha0.8, zorder5) ax1.legend(locupper right) # 右图SSD风格 —— 同一特征图上使用多种尺度和长宽比的锚框 ax2 axes[1] ax2.set_xlim(0, 640) ax2.set_ylim(640, 0) ax2.set_title(SSD风格锚框设计单层特征图多尺度/比例, fontsize14, pad15) ax2.set_xlabel(图像宽度) ax2.set_ylabel(图像高度) ax2.grid(True, linestyle--, alpha0.3) # 模拟SSD在某一层特征图上的锚框生成基于公式 stride_ssd 16 feat_map_size_ssd 640 // stride_ssd center_x_ssd, center_y_ssd (feat_map_size_ssd//2 0.5) * stride_ssd, (feat_map_size_ssd//2 0.5) * stride_ssd # SSD的尺度和比例简化版 min_scale, max_scale 0.2, 0.9 aspect_ratios [1., 2., 0.5, 3., 1./3.] # 1:1, 2:1, 1:2, 3:1, 1:3 num_priors len(aspect_ratios) 1 # 额外加一个尺度为 sqrt(s_k * s_{k1}) 的框 scales np.linspace(min_scale, max_scale, 6) # 6个尺度层 # 我们选取中间两个尺度来演示 selected_scales [scales[2], scales[3]] colors_ssd [darkorange, purple] for scale_idx, scale in enumerate(selected_scales): color colors_ssd[scale_idx] # 基础框大小相对于图像尺寸 base_size scale * 640 for ar_idx, ar in enumerate(aspect_ratios): if ar 1: # 对于长宽比1额外使用 sqrt(scale * next_scale) 的尺度 if scale_idx len(scales)-1: extra_scale np.sqrt(scale * scales[scale_idx1]) w h extra_scale * 640 x1 center_x_ssd - w/2 y1 center_y_ssd - h/2 rect patches.Rectangle((x1, y1), w, h, linewidth3, edgecolorred, facecolornone, linestyle:, label额外尺度框 (ar1) if scale_idx0 and ar_idx0 else ) ax2.add_patch(rect) # 计算当前尺度和长宽比下的宽高 w base_size * np.sqrt(ar) h base_size / np.sqrt(ar) x1 center_x_ssd - w/2 y1 center_y_ssd - h/2 rect patches.Rectangle((x1, y1), w, h, linewidth2, edgecolorcolor, facecolornone, alpha0.8, linestyle-, labelf尺度{scale:.2f}, 比例{ar} if ar_idx0 else ) ax2.add_patch(rect) # 画中心点 ax2.scatter(center_x_ssd, center_y_ssd, colorcolor, s100, markers, alpha0.8, zorder5, labelf中心点 (尺度{scale:.2f}) if scale_idx0 else ) ax2.legend(locupper right, fontsize9) plt.suptitle(目标检测两大流派锚框设计对比, fontsize16, y1.02) plt.tight_layout() plt.show()通过这张对比图你可以清晰地看到两种设计思路的差异。左图YOLO风格展示了“分而治之”的思想深层特征图感受野大负责检测大目标所以锚框也大中层特征图负责中目标浅层特征图负责小目标。每一层只使用少数几个通常是3个针对该尺度优化过的锚框。右图SSD风格则展示了“密集预测”的思想在同一层特征图的同一个位置上就生成了多个不同尺度和长宽比的锚框图中展示了两个尺度每个尺度下又有多种长宽比其中红色虚线框是ar1时的额外尺度框。SSD通过这种设计在单层上就能覆盖较大范围的目标尺寸。理解这种差异对于你选择或设计模型至关重要。如果你的场景中小目标很多比如航拍图像那么关注浅层特征图和更小的锚框设计就很重要如果你的目标长宽比变化很大比如交通场景中的车辆和人那么SSD这种提供多种长宽比的设计可能更有优势。锚点图让你一眼就能看穿这些设计背后的逻辑。