ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

YOLOv1原理精讲:从网格编码到三路损失的手工实现

YOLOv1原理精讲:从网格编码到三路损失的手工实现

简介:本资源是一份面向深度学习初学者与计算机视觉开发者的YOLOv1实战训练指南,聚焦于如何基于Keras框架快速构建并训练自定义目标检测模型。资源提供完整可运行的Keras_YOLOv1实现(含darknet19网络定义、train/test脚本及README说明),配套图像预处理示例(jpg)、数据列表配置(txt)和模型训练/测试代码(py),覆盖数据准备、模型搭建、训练调优到结果可视化全流程。压缩包共11个文件,含3个Python核心脚本、4张示例图像(原始与检测结果对比)、3个文本配置文件及1份Markdown说明文档,总大小仅128KB,轻量易解压,适合本地快速验证与二次开发。目前已有110人学习下载,读者可直接复用目录结构与代码模板,省去环境适配与基础框架搭建时间,尤其适用于课程设计、小规模工业检测场景或YOLO算法演进原理研究。

1. 为什么还在用 YOLOv1?不是为了怀旧,而是为了“看懂目标检测的骨架”

你可能在 GitHub 上刷到过一堆标着 “YOLOv5/v8/v10 最新版”的仓库,点进去全是自动标注、一键训练、WebUI 可视化——但当你被导师问“YOLOv1 的损失函数为什么分坐标、置信度、类别三部分计算?”“S×S 网格怎么对应到原始图像坐标?”时,却卡在了反向传播的黑匣子门口。这套基于 Keras 实现的 YOLOv1(非官方复现,但结构清晰、注释完整)不是为工程落地而生,而是为“亲手把目标检测从头拧紧每一颗螺丝”而设计。它不追求 mAP 多高,但强制你手写grid_cell_to_bbox()、手动拆解loss_coord和loss_conf、逐层调试conv4 → fc5 → output的 shape 变化。适合某高校计算机视觉课设、毕设中需要完整展示算法原理→代码实现→训练验证闭环的学生;也适合想甩掉 PyTorch 自动求导依赖、真正吃透 anchor-free 检测范式底层逻辑的初阶工程师。它不帮你省时间,但它能让你在答辩时,指着 loss 曲线说清楚:“这里第 37 轮震荡,是因为我在compute_iou里漏判了边界框归一化,导致 conf_loss 突增”——这种确定性,就是它存在的全部理由。

2. 总体架构设计:Keras 函数式 API 构建的四层结构与数据流闭环

这套方案没有采用 Keras Sequential 模型,而是严格使用函数式 API(Functional API),目的很明确:让每一层输入输出 shape 可追溯、梯度流向可打断、中间特征图可提取。整个网络被划分为四个逻辑层,每层承担明确职责,且彼此解耦——这意味着你可以单独替换 backbone(比如把原始的 24 层卷积换成 ResNet18 提取器),而不影响后续 detection head 的 loss 计算逻辑。

2.1 输入预处理层:固定尺寸 + 归一化 + 标签编码的三步不可逆操作

YOLOv1 对输入尺寸极其敏感,必须统一为 448×448(原始论文设定)。这不是为了兼容性,而是因为其 S=7 的网格划分直接绑定图像宽高比。预处理代码如下:

def preprocess_image(image_path, label_path, input_size=448, S=7, B=2, C=20): # 1. 读取并缩放(双线性插值,保持比例) img = cv2.imread(image_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img_resized = cv2.resize(img, (input_size, input_size)) # 2. 归一化到 [0,1](Keras 默认要求) img_normalized = img_resized.astype(np.float32) / 255.0 # 3. 解析 label 文件(PASCAL VOC 格式 .xml 或自定义 .txt) # 假设 label_path 是 .txt,每行: class_id x_center y_center width height (归一化后) boxes = [] with open(label_path, 'r') as f: for line in f.readlines(): parts = line.strip().split() cls_id = int(parts[0]) x, y, w, h = map(float, parts[1:5]) # 注意:YOLOv1 要求 bbox 坐标是相对于整张图的绝对比例,不是 grid cell boxes.append([cls_id, x, y, w, h]) # 4. 编码为 (S, S, 5*B + C) 的 label tensor label_tensor = np.zeros((S, S, 5*B + C)) for box in boxes: cls_id, x, y, w, h = box # 计算该 bbox 中心点落在哪个 grid cell (i, j) i = int(y * S) # 行号(y 方向) j = int(x * S) # 列号(x 方向) if i >= S or j >= S: continue # 边界溢出,跳过(真实项目中应做裁剪或警告) # 归一化中心坐标到 grid cell 内部 [0,1] x_cell = S * x - j y_cell = S * y - i # 宽高保持原始比例(相对整图) w_cell = w h_cell = h # 填充 label_tensor:前 5*B 位存 B 个 bbox 的 (x,y,w,h,conf),后 C 位存 one-hot 类别 for b in range(B): if label_tensor[i, j, 4 + b*5] == 0: # 找第一个空置信度位 label_tensor[i, j, b*5:(b*5+4)] = [x_cell, y_cell, w_cell, h_cell] label_tensor[i, j, 4 + b*5] = 1.0 # 置信度 = 1(有物体) break # 类别 one-hot(C=20,此处简化为单类,实际需扩展) label_tensor[i, j, 5*B + int(cls_id)] = 1.0 return img_normalized, label_tensor

提示:这段代码里的i = int(y * S)是关键陷阱点。很多初学者误用i = int(y),导致 grid cell 索引错乱。YOLOv1 的(x,y)是归一化到[0,1]的,乘以S才得到 cell 索引。我一般会在preprocess_image后加一句assert np.sum(label_tensor[..., 4]) > 0,确保至少有一个置信度被激活,否则训练必然失败。

2.2 Backbone 特征提取层:24 层卷积 + 池化的经典 VGG 风格设计

原始 YOLOv1 使用 24 层卷积(前 20 层 ImageNet 预训练,后 4 层随机初始化),本方案完全复现该结构,但用 Keras 层级显式声明,便于调试:

def build_backbone(input_tensor): # 输入:(None, 448, 448, 3) x = Conv2D(64, (7, 7), strides=(2, 2), padding='same', name='conv1')(input_tensor) # 224x224 x = BatchNormalization()(x) x = LeakyReLU(alpha=0.1)(x) x = MaxPooling2D((2, 2), strides=(2, 2))(x) # 112x112 x = Conv2D(192, (3, 3), padding='same', name='conv2')(x) # 112x112 x = BatchNormalization()(x) x = LeakyReLU(alpha=0.1)(x) x = MaxPooling2D((2, 2), strides=(2, 2))(x) # 56x56 # 后续 5 组 conv+pool,最终输出 7x7x1024 特征图 # (代码略,共 24 层卷积,详见 model.py 中 build_backbone 函数) return x # 输出 shape: (None, 7, 7, 1024)

这个 backbone 的输出尺寸必须是(7,7,1024),这是硬性约束。因为 YOLOv1 的 detection head 直接在此基础上接全连接层,将(7,7,1024)flatten 成(1, 7*7*1024),再映射到(1, 7*7*(5*2+20)) = (1, 1470)。任何尺寸偏差都会导致Dense层报错。这也是为什么不能随意替换 backbone —— 除非你同步修改后续的 reshape 和 dense 层参数。

2.3 Detection Head 层:从特征图到预测张量的降维与解耦

Backbone 输出(7,7,1024)后,Detection Head 并非简单接一个 Dense 层,而是分两步走:先用GlobalAveragePooling2D或Flatten降维,再用两个全连接层过渡,最后输出(7,7,1470)张量。本方案采用更鲁棒的Flatten + Dense组合,并显式分离坐标、置信度、类别分支:

def build_detection_head(backbone_output, S=7, B=2, C=20): x = Flatten()(backbone_output) # (None, 7*7*1024) = (None, 49*1024) x = Dense(4096, activation='leaky_relu', name='fc1')(x) # 4096 是原文设定 x = Dropout(0.5)(x) x = Dense(S*S*(B*5 + C), activation='linear', name='output')(x) # 输出 1470 维 # 关键:reshape 回 (S, S, B*5 + C) 以便后续 loss 计算 output_tensor = Reshape((S, S, B*5 + C))(x) # 分离预测:每个 cell 预测 B 个 bbox,每个 bbox 5 维 (x,y,w,h,conf),+ C 维类别 # 这里不立即拆分,留到 loss 函数中按索引取,保证梯度连贯 return output_tensor

注意Reshape((S, S, B*5 + C))这一步。很多同学在这里用tf.reshape或K.reshape导致模型无法保存,必须用 Keras 原生Reshape层。另外,B*5 + C = 2*5 + 20 = 30,所以最终输出张量 shape 是(None, 7, 7, 30),而非(None, 1470)—— 这是后续所有 loss 计算和 NMS 的基础维度。

2.4 损失函数层:坐标、置信度、类别三路 loss 的手工拼装与加权

YOLOv1 的 loss 是其灵魂所在,也是最容易翻车的地方。它不是单一 MSE,而是三部分加权和:

  • loss_coord: 仅对负责预测该物体的 grid cell 中的 bbox 计算(即I_obj^ij = 1的 cell),且只计算(x,y,w,h),不包括 conf;
  • loss_conf: 对所有 cell 计算,但正样本(有物体)用(C_i - C_i_hat)^2,负样本(无物体)用λ_noobj * (0 - C_i_hat)^2;
  • loss_class: 仅对有物体的 cell 计算类别预测误差。

本方案将 loss 封装为自定义 Keras loss 函数,关键代码如下:

def yolo_v1_loss(y_true, y_pred): # y_true: (None, 7, 7, 30), y_pred: (None, 7, 7, 30) S, B, C = 7, 2, 20 lambda_coord = 5.0 lambda_noobj = 0.5 # 1. 提取真实标签中的 object mask: (None, 7, 7) # y_true[..., 4] 和 y_true[..., 9] 分别是两个 bbox 的置信度(原文中只用第一个) # 实际我们只关心是否有物体:取 max(4,9) > 0 即认为该 cell 有物体 obj_mask = tf.reduce_max(y_true[..., 4::5], axis=-1) # (None, 7, 7) obj_mask = tf.expand_dims(obj_mask, axis=-1) # (None, 7, 7, 1) # 2. 坐标 loss:只对 obj_mask==1 的 cell 计算 (x,y,w,h) # 注意:w,h 用 sqrt 计算,避免大 bbox 梯度爆炸 coords_true = y_true[..., :4*B] # (None, 7, 7, 8) coords_pred = y_pred[..., :4*B] # (None, 7, 7, 8) loss_coord = lambda_coord * tf.reduce_mean( obj_mask * tf.square(tf.sqrt(coords_true[..., 2:4*B:5]) - tf.sqrt(coords_pred[..., 2:4*B:5])) + obj_mask * tf.square(coords_true[..., :4*B:5] - coords_pred[..., :4*B:5]) ) # 3. 置信度 loss:正样本用 obj_mask,负样本用 (1-obj_mask) conf_true = y_true[..., 4:B*5:5] # (None, 7, 7, 2) conf_pred = y_pred[..., 4:B*5:5] # (None, 7, 7, 2) loss_conf_obj = tf.reduce_mean(obj_mask * tf.square(conf_true - conf_pred)) loss_conf_noobj = lambda_noobj * tf.reduce_mean((1 - obj_mask) * tf.square(conf_pred)) loss_conf = loss_conf_obj + loss_conf_noobj # 4. 类别 loss:只对 obj_mask==1 的 cell 计算 C 维 one-hot class_true = y_true[..., B*5:] # (None, 7, 7, 20) class_pred = y_pred[..., B*5:] # (None, 7, 7, 20) loss_class = tf.reduce_mean(obj_mask * tf.square(class_true - class_pred)) return loss_coord + loss_conf + loss_class

注意:这段代码里coords_true[..., 2:4*B:5]的切片逻辑极易出错。2:4*B:5表示从索引 2 开始,步长为 5,取4*B个元素中的 w,h 维度(即索引 2,3,7,8,...)。我第一次写时漏了:5,导致取了全部 8 个值,loss 爆炸。建议在训练前用model.compile(loss=yolo_v1_loss, optimizer='adam')后,先model.evaluate(dummy_x, dummy_y)测试 loss 是否为有限值,再开始正式训练。

3. 数据集构建与标注规范:从 VOC 到 YOLOv1 标签的精准映射

YOLOv1 不接受任意格式的标注,它对标签的物理意义、数值范围、存储结构有强约定。本方案提供了一套完整的转换脚本voc_to_yolo1.py,但更重要的是理解“为什么这样转”。

3.1 PASCAL VOC 标注的局限性与 YOLOv1 的适配改造

VOC 的.xml文件记录的是<xmin><ymin><xmax><ymax>(像素坐标),而 YOLOv1 要求:

  • 所有坐标必须归一化到[0,1];
  • 中心点(x_center, y_center)和宽高(w, h)必须是相对于整张图的比例;
  • 每个.txt标签文件一行一个物体,格式为class_id x_center y_center w h;
  • class_id从 0 开始,且必须与classes.txt中顺序严格一致。

转换脚本核心逻辑如下:

def voc_to_yolo1(xml_path, image_path, classes_file, output_txt_path): tree = ET.parse(xml_path) root = tree.getroot() size = root.find('size') img_w = int(size.find('width').text) img_h = int(size.find('height').text) with open(classes_file, 'r') as f: classes = [line.strip() for line in f.readlines()] with open(output_txt_path, 'w') as out_f: for obj in root.iter('object'): cls_name = obj.find('name').text if cls_name not in classes: continue cls_id = classes.index(cls_name) xmlbox = obj.find('bndbox') xmin = int(xmlbox.find('xmin').text) ymin = int(xmlbox.find('ymin').text) xmax = int(xmlbox.find('xmax').text) ymax = int(xmlbox.find('ymax').text) # 转换为 YOLOv1 格式:中心点 + 宽高 + 归一化 x_center = ((xmin + xmax) / 2.0) / img_w y_center = ((ymin + ymax) / 2.0) / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h # 写入:class_id x_center y_center w h out_f.write(f"{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}\n")

提示:x_center和y_center必须是(xmin+xmax)/2除以img_w/img_h,而不是(xmin/img_w + xmax/img_w)/2—— 数学等价,但浮点精度下后者可能引入微小误差,在int(y * S)时导致 grid cell 错位。我吃过这个亏:一张图里 3 个物体,2 个进了正确 cell,1 个因y_center差了1e-8被int()截断到隔壁 cell,训练时该 cell 的I_obj^ij为 0,但 loss 却在惩罚它的 bbox 预测,结果模型学不会定位。

3.2 小样本数据集的增强策略:几何变换优先,色彩扰动次之

YOLOv1 对数据量敏感,但盲目增加ImageDataGenerator的rotation_range=20或shear_range=0.2会破坏 bbox 的几何一致性。本方案推荐的增强组合是:

  • 必选:horizontal_flip=True(镜像翻转不改变 bbox 语义,只需 x_center = 1 - x_center);
  • 慎选:width_shift_range=0.1, height_shift_range=0.1(平移后需同步调整 bbox 坐标,代码已内置);
  • 禁用:rotation_range,zoom_range,shear_range(旋转/缩放/错切会扭曲 bbox 形状,YOLOv1 无 anchor 机制,无法适应形变)。

增强后的图像与标签必须同步保存,脚本augment_and_save.py会生成_aug001.jpg和_aug001.txt,并确保.txt中的坐标已重算。

3.3 数据集划分与加载器:Keras Sequence 的内存友好实现

为避免一次性加载所有图片到内存(448×448×3×N 占用巨大),本方案使用keras.utils.Sequence自定义生成器:

class YOLOv1DataGenerator(Sequence): def __init__(self, image_paths, label_paths, batch_size=8, shuffle=True): self.image_paths = image_paths self.label_paths = label_paths self.batch_size = batch_size self.shuffle = shuffle self.on_epoch_end() def __len__(self): return int(np.ceil(len(self.image_paths) / self.batch_size)) def __getitem__(self, index): batch_indices = self.indices[index*self.batch_size:(index+1)*self.batch_size] batch_images = [] batch_labels = [] for i in batch_indices: img, label = preprocess_image( self.image_paths[i], self.label_paths[i], input_size=448, S=7, B=2, C=20 ) batch_images.append(img) batch_labels.append(label) return np.array(batch_images), np.array(batch_labels) def on_epoch_end(self): self.indices = np.arange(len(self.image_paths)) if self.shuffle: np.random.shuffle(self.indices)

__getitem__中调用preprocess_image是关键。它保证每次 yield 的都是实时处理后的(448,448,3)和(7,7,30),内存占用恒定,且支持shuffle=True下的真随机。

4. 模型训练与超参调优:从学习率衰减到早停策略的实战配置

YOLOv1 训练不是“run train.py 等 100 epoch”,而是一场与 loss 曲线的耐心博弈。本方案的train.py提供了开箱即用的配置,但真正决定成败的是以下三个超参的协同。

4.1 学习率调度:阶梯式衰减 + warmup 的双阶段控制

YOLOv1 原文使用lr=0.001,但直接使用会导致初期 loss 震荡剧烈。本方案采用warmup + step decay:

# warmup:前 5 个 epoch,lr 从 1e-5 线性升到 0.001 def warmup_step_decay(epoch): if epoch < 5: return 1e-5 + (0.001 - 1e-5) * epoch / 5.0 else: # 阶梯衰减:每 30 epoch 降为 0.1 倍 return 0.001 * (0.1 ** (epoch // 30)) lr_scheduler = LearningRateScheduler(warmup_step_decay)

为什么是 5 和 30?因为 backbone 的 24 层卷积参数量大,前 5 epoch 是让浅层特征提取器“热身”,避免初始梯度爆炸;而 30 epoch 是经验阈值——在多数小数据集(<2000 图)上,loss 在此之后下降缓慢,继续训练易过拟合。

4.2 优化器选择:Adam 优于 SGD 的实证依据

YOLOv1 原文用 SGD + momentum,但本方案实测 Adam 更稳定。原因在于:

  • Adam 的自适应学习率能更好处理loss_coord(数值小)、loss_conf(数值大)、loss_class(数值中)三者量纲差异;
  • beta_1=0.9, beta_2=0.999的默认设置对 YOLOv1 的梯度分布匹配度高;
  • 不需要手动调momentum和nesterov。
optimizer = Adam(learning_rate=0.001, beta_1=0.9, beta_2=0.999, epsilon=1e-07) model.compile(optimizer=optimizer, loss=yolo_v1_loss)

避坑 / 常见问题 / 排查 / 注意

  1. 现象:训练第 1 个 epoch 后,loss_coord突增至1e5以上,loss_conf接近 0
    原因:preprocess_image中x_cell = S * x - j计算错误,j用了int(x * S)但x未归一化,导致x_cell为负数或 >1,sqrt(w)报错,梯度爆炸
    解决:检查preprocess_image输入的x,y,w,h是否已归一化;加断言assert 0 <= x <= 1 and 0 <= y <= 1

  2. 现象:val_loss持续下降,但val_loss_coord却上升
    原因:验证集标注中存在w或h为 0 的无效 bbox(如xmin==xmax),导致sqrt(w)为 0,loss_coord计算时出现nan,Keras 自动忽略该样本,但loss_coord分母变小,数值虚高
    解决:在voc_to_yolo1.py中加入if w > 0.001 and h > 0.001:过滤极小 bbox

  3. 现象:训练 50 epoch 后,所有预测 bbox 的conf都趋近于 0.5,class输出混乱
    原因:lambda_noobj=0.5过小,负样本置信度 loss 权重不足,模型学会“保守预测”,对所有 cell 都输出中等 conf
    解决:将lambda_noobj提高到1.0或2.0,强制模型区分有/无物体 cell

  4. 现象:model.predict()输出的(7,7,30)中,conf值全为 0 或 1,无中间值
    原因:Dense层后未加sigmoid激活,conf和class预测值未经压缩,直接进入 loss 计算,导致梯度饱和
    解决:在build_detection_head的Dense层后,对conf和class部分加sigmoid:output_tensor = tf.concat([tf.sigmoid(conf_part), tf.sigmoid(class_part)], axis=-1)

  5. 现象:训练过程 GPU 显存占用从 2GB 涨到 12GB 后 OOM
    原因:tf.data.Dataset或Sequence中batch_size过大,且preprocess_image内部未释放临时变量(如cv2.imread返回的img)
    解决:preprocess_image结尾加del img, img_resized;或改用tf.io.read_file+tf.image.decode_jpeg,由 TensorFlow 图优化内存

4.3 早停与模型保存:基于val_loss_coord的精准干预

YOLOv1 的最终目标是精确定位,因此不能只看val_loss。本方案监控val_loss_coord:

early_stopping = EarlyStopping( monitor='val_loss_coord', # 自定义 metric,需在 compile 时注册 patience=15, verbose=1, mode='min', restore_best_weights=True ) model_checkpoint = ModelCheckpoint( 'best_yolov1.h5', monitor='val_loss_coord', save_best_only=True, mode='min' )

要让EarlyStopping识别val_loss_coord,需在compile时注册自定义 metric:

def loss_coord_metric(y_true, y_pred): # 复制 loss_coord 计算逻辑,返回 scalar # (代码同 loss 函数中 loss_coord 部分,略) return loss_coord_scalar model.compile( optimizer=optimizer, loss=yolo_v1_loss, metrics=[loss_coord_metric, 'accuracy'] # accuracy 此处无意义,仅为占位 )

5. 模型推理与结果可视化:从 raw output 到可交付检测图的全流程

训练完的.h5模型不能直接用于检测,必须经过后处理(Post-processing)才能得到人类可读的 bounding box。本方案的inference.py封装了完整 pipeline。

5.1 Raw Output 解析:从(7,7,30)到候选 bbox 列表

模型输出pred = model.predict(img_batch)后,pred是(1,7,7,30)。解析步骤如下:

def parse_prediction(pred, S=7, B=2, C=20, confidence_threshold=0.3, iou_threshold=0.4): # pred: (1, 7, 7, 30) pred = pred[0] # (7, 7, 30) bboxes = [] for i in range(S): for j in range(S): # 取该 cell 的两个 bbox 预测 for b in range(B): # 置信度 conf = pred[i, j, 4 + b*5] if conf < confidence_threshold: continue # 中心点(相对于 cell) x_cell = pred[i, j, b*5] y_cell = pred[i, j, b*5 + 1] # 宽高(相对于整图) w = pred[i, j, b*5 + 2] h = pred[i, j, b*5 + 3] # 还原到整图坐标 [0,1] x = (j + x_cell) / S y = (i + y_cell) / S # 转为 (xmin, ymin, xmax, ymax) 归一化坐标 xmin = max(0, x - w/2) ymin = max(0, y - h/2) xmax = min(1, x + w/2) ymax = min(1, y + h/2) # 类别概率(20 维) class_probs = pred[i, j, B*5:] cls_id = np.argmax(class_probs) cls_score = class_probs[cls_id] final_score = conf * cls_score # YOLOv1 的最终置信度 = obj_conf * class_prob if final_score > confidence_threshold: bboxes.append([xmin, ymin, xmax, ymax, final_score, cls_id]) # 转 numpy array 并做 NMS if len(bboxes) == 0: return [] bboxes = np.array(bboxes) keep = non_max_suppression(bboxes, iou_threshold) return bboxes[keep] def non_max_suppression(bboxes, iou_thresh): # 标准 NMS 实现,按 score 降序,计算 IOU,抑制重叠 # (代码略,详见 utils.py) pass

注意:final_score = conf * cls_score是 YOLOv1 的核心设计,不是后加的 trick。conf表示“这个 cell 里有物体的概率”,cls_score表示“这个物体是某类的概率”,二者相乘才是“这个 bbox 是某类物体的置信度”。漏乘会导致高 conf 低 cls 的 bbox 被保留,检测结果全是“疑似物体”。

5.2 可视化脚本:支持 OpenCV 与 Matplotlib 双引擎

为适配不同环境(服务器无 GUI / 本地调试),visualize.py提供两种模式:

def draw_bboxes_cv2(image, bboxes, classes, colors): # image: (H,W,3) uint8 h, w = image.shape[:2] for box in bboxes: xmin, ymin, xmax, ymax, score, cls_id = box # 归一化坐标转像素 x1, y1, x2, y2 = int(xmin*w), int(ymin*h), int(xmax*w), int(ymax*h) color = colors[int(cls_id) % len(colors)] cv2.rectangle(image, (x1,y1), (x2,y2), color, 2) label = f"{classes[int(cls_id)]}: {score:.2f}" cv2.putText(image, label, (x1, y1-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1) return image def draw_bboxes_plt(image, bboxes, classes, colors): # image: (H,W,3) float32 [0,1] plt.figure(figsize=(12,8)) plt.imshow(image) for box in bboxes: xmin, ymin, xmax, ymax, score, cls_id = box rect = patches.Rectangle((xmin, ymin), xmax-xmin, ymax-ymin, linewidth=2, edgecolor=colors[int(cls_id)%len(colors)], facecolor='none') plt.gca().add_patch(rect) plt.text(xmin, ymin-0.02, f"{classes[int(cls_id)]}: {score:.2f}", color=colors[int(cls_id)%len(colors)], fontsize=10) plt.axis('off') plt.show()

5.3 检测性能评估:mAP 计算的轻量级实现

本方案不依赖 pycocotools(安装复杂),而是用sklearn.metrics.average_precision_score实现 VOC 风格 mAP:

def calculate_voc_ap(precisions, recalls): # VOC 11-point interpolation ap = 0 for t in np.arange(0, 1.1, 0.1): if np.sum(recalls >= t) == 0: p = 0 else: p = np.max(precisions[recalls >= t]) ap += p / 11.0 return ap def evaluate_model(model, test_generator, classes, iou_thresh=0.5): all_scores = [] all_labels = [] all_dets = [] all_gts = [] for i, (x_batch, y_batch) in enumerate(test_generator): preds = model.predict(x_batch) for j in range(len(x_batch)): # 解析 pred[j] 得到 det_boxes det_boxes = parse_prediction(preds[j:j+1], ...) # 从 y_batch[j] 解析 gt_boxes(逆向 preprocess_image) gt_boxes = decode_label(y_batch[j]) all_dets.append(det_boxes) all_gts.append(gt_boxes) # 计算 per-class AP aps = {} for cls_id in range(len(classes)): scores, labels = [], [] for dets, gts in zip(all_dets, all_gts): # 计算该类别的 TP/FP/FN tp, fp, fn = compute_tp_fp_fn(dets, gts, cls_id, iou_thresh) scores.extend([det[4] for det in dets if det[5]==cls_id]) labels.extend([1 if tp_i else 0 for tp_i in tp]) if len(labels) == <p> <a href="https://download.csdn.net/download/2401_87496566/89843308" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进