yolov5检测部分代码解析
yolov5源代码:https://github.com/ultralytics/yolov5
设置目标检测的配置参数:
parser = argparse.ArgumentParser()
parser.add_argument('--weights', nargs='+', type=str, default='yolov5s.pt', help='model.pt path(s)')
parser.add_argument('--source', type=str, default='data/images', help='source') # file/folder, 0 for webcam
parser.add_argument('--img-size', type=int, default=640, help='inference size (pixels)')
parser.add_argument('--conf-thres', type=float, default=0.25, help='object confidence threshold')
parser.add_argument('--iou-thres', type=float, default=0.45, help='IOU threshold for NMS')
parser.add_argument('--device', default='', help='cuda device, i.e. 0 or 0,1,2,3 or cpu')
parser.add_argument('--view-img', action='store_true', help='display results')
parser.add_argument('--save-txt', action='store_true', help='save results to *.txt')
parser.add_argument('--save-conf', action='store_true', help='save confidences in --save-txt labels')
parser.add_argument('--nosave', action='store_true', help='do not save images/videos')
parser.add_argument('--classes', nargs='+', type=int, help='filter by class: --class 0, or --class 0 2 3')
parser.add_argument('--agnostic-nms', action='store_true', help='class-agnostic NMS')
parser.add_argument('--augment', action='store_true', help='augmented inference')
parser.add_argument('--update', action='store_true', help='update all models')
parser.add_argument('--project', default='runs/detect', help='save results to project/name')
parser.add_argument('--name', default='exp', help='save results to project/name')
parser.add_argument('--exist-ok', action='store_true', help='existing project/name ok, do not increment')
opt = parser.parse_args()
print(opt)
执行检测程序前,查看requirements.txt里的相关安装包是否安装在执行检测的python环境下:
check_requirements(exclude=('pycocotools', 'thop'))
def check_requirements(file='requirements.txt', exclude=()):
# Check installed dependencies meet requirements
import pkg_resources as pkg
prefix = colorstr('red', 'bold', 'requirements:')
file = Path(file)
if not file.exists():
print(f"{prefix} {file.resolve()} not found, check failed.")
return
n = 0 # number of packages updates
requirements = [f'{x.name}{x.specifier}' for x in pkg.parse_requirements(file.open()) if x.name not in exclude]
for r in requirements:
try:
pkg.require(r)
except Exception as e: # DistributionNotFound or VersionConflict if requirements not met
n += 1
print(f"{prefix} {e.req} not found and is required by YOLOv5, attempting auto-update...")
print(subprocess.check_output(f"pip install '{e.req}'", shell=True).decode())
if n: # if packages updated
s = f"{prefix} {n} package{'s' * (n > 1)} updated per {file.resolve()}\n" \
f"{prefix} ⚠️ {colorstr('bold', 'Restart runtime or rerun command for updates to take effect')}\n"
print(emojis(s)) # emoji-safe
执行检测部分:
with torch.no_grad():
if opt.update: # update all models (to fix SourceChangeWarning)
for opt.weights in ['yolov5s.pt', 'yolov5m.pt', 'yolov5l.pt', 'yolov5x.pt']:
detect()
strip_optimizer(opt.weights)
else:
detect()
接下来主要看detect()内部的执行。
第一行是相关参数的加载,参数加载的内容为输入的配置参数。
第二行为是否保存图片的判断(bool类型),默认为保存图片,不保存图片可以在执行程序中输入 --nosave,或者你检测的对象是一个网络视频流的地址,其中视频流地址保存在一个文件类型为txt的文档里。
第三个是摄像头检测的一个判断(bool类型), 是你在–source后的输入,当检测对象是本地的一个摄像头或者是一个txt文档内有网络视频源地址,或者是你直接输入网络视频源的地址以(‘rtsp://’, ‘rtmp://’, ‘http://’)这三个开头的网络地址,以上三种情况(不是指网络地址的三种情况)判定你的检测对象是摄像头检测。
def detect(save_img=False):
source, weights, view_img, save_txt, imgsz = opt.source, opt.weights, opt.view_img, opt.save_txt, opt.img_size
save_img = not opt.nosave and not source.endswith('.txt') # save inference images
webcam = source.isnumeric() or source.endswith('.txt') or source.lower().startswith(
('rtsp://', 'rtmp://', 'http://'))
保存检测结果地址的一个写入程序:
第一行为保存地址返回(str),其中opt.project,默认为’runs/detect’, opt.name默认为’exp’,opt.exist_ok默认为False,opt.exist_ok判断是否将检测结果保存在你默认或者输入的opt.project/opt.name文件内,执行检测程序时如果不改变opt.project和opt.name,只输入–exist_ok,则保存结果会在’runs/detect/exp文件夹内。如果不输入默认的opt.project,opt.name和opt.exist_ok ,increment_path()这个程序是通过通过正则匹配的方式在你的runs/detect文件内查找含有exp的文件,并匹配出最大的数字,进行+1后,返回’runs/detect/exp’ + '(maxnum+1)'的一个字符串
第二行为第一行返回保存位置的一个文件夹创建
# Directories
save_dir = Path(increment_path(Path(opt.project) / opt.name, exist_ok=opt.exist_ok)) # increment run
(save_dir / 'labels' if save_txt else save_dir).mkdir(parents=True, exist_ok=True) # make dir
def increment_path(path, exist_ok=True, sep=''):
# Increment path, i.e. runs/exp --> runs/exp{sep}0, runs/exp{sep}1 etc.
path = Path(path) # os-agnostic
if (path.exists() and exist_ok) or (not path.exists()):
return str(path)
else:
dirs = glob.glob(f"{path}{sep}*") # similar paths
matches = [re.search(rf"%s{sep}(\d+)" % path.stem, d) for d in dirs]
i = [int(m.groups()[0]) for m in matches if m] # indices
n = max(i) + 1 if i else 2 # increment number
return f"{path}{sep}{n}" # update path
第一行打印日志信息
第二行选择程序在哪个硬件执行(CPU或者GPU),select_device(),如果在opt.device选择输入cpu,则在cpu上进行检测程序的执行,如果不输入,则通过torch.cuda.is_available()来判断是否使用GPU运行程序
第三行通过降低少许检测精度提高检测速度的判断(bool)
# Initialize
set_logging()
device = select_device(opt.device)
half = device.type != 'cpu' # half precision only supported on CUDA
def select_device(device='', batch_size=None):
# device = 'cpu' or '0' or '0,1,2,3'
s = f'YOLOv5 🚀 {git_describe() or date_modified()} torch {torch.__version__} ' # string
cpu = device.lower() == 'cpu'
if cpu:
os.environ['CUDA_VISIBLE_DEVICES'] = '-1' # force torch.cuda.is_available() = False
elif device: # non-cpu device requested
os.environ['CUDA_VISIBLE_DEVICES'] = device # set environment variable
assert torch.cuda.is_available(), f'CUDA unavailable, invalid device {device} requested' # check availability
cuda = not cpu and torch.cuda.is_available()
if cuda:
n = torch.cuda.device_count()
if n > 1 and batch_size: # check that batch_size is compatible with device_count
assert batch_size % n == 0, f'batch-size {batch_size} not multiple of GPU count {n}'
space = ' ' * len(s)
for i, d in enumerate(device.split(',') if device else range(n)):
p = torch.cuda.get_device_properties(i)
s += f"{'' if i == 0 else space}CUDA:{d} ({p.name}, {p.total_memory / 1024 ** 2}MB)\n" # bytes to MB
else:
s += 'CPU\n'
logger.info(s.encode().decode('ascii', 'ignore') if platform.system() == 'Windows' else s) # emoji-safe
return torch.device('cuda:0' if cuda else 'cpu')
第一行为模型的载入,其中attempt_load()是对权重文件的一个载入,主要是通过torch.load()这个函数,值得注意的是,这个可以是多个模型的混和载入(需要注意每个模型的输入和输入大小),也可以是对一个完整检测模型的载入。
第二行是检测模型时yolov5也用了FPN里面的一个思路,将卷积神经网络中最后三层通过上采样的方式进行了一个堆叠的,要求图片的分辨率为32的一个整数倍。
第三行为输入判断你输入的opt.img_size是否是32的倍数,如果不是,则改变你输入的img_size为向上取32的倍数的分辨率。
# Load model
model = attempt_load(weights, map_location=device) # load FP32 model
stride = int(model.stride.max()) # model stride
imgsz = check_img_size(imgsz, s=stride) # check img_size
if half:
model.half() # to FP16
def attempt_load(weights, map_location=None):
# Loads an ensemble of models weights=[a,b,c] or a single model weights=[a] or weights=a
model = Ensemble()
for w in weights if isinstance(weights, list) else [weights]:
attempt_download(w)
ckpt = torch.load(w, map_location=map_location) # load
model.append(ckpt['ema' if ckpt.get('ema') else 'model'].float().fuse().eval()) # FP32 model
# Compatibility updates
for m in model.modules():
if type(m) in [nn.Hardswish, nn.LeakyReLU, nn.ReLU, nn.ReLU6, nn.SiLU]:
m.inplace = True # pytorch 1.7.0 compatibility
elif type(m) is Conv:
m._non_persistent_buffers_set = set() # pytorch 1.6.0 compatibility
if len(model) == 1:
return model[-1] # return model
else:
print('Ensemble created with %s\n' % weights)
for k in ['names', 'stride']:
setattr(model, k, getattr(model[-1], k))
return model # return ensemble
def check_img_size(img_size, s=32):
# Verify img_size is a multiple of stride s
new_size = make_divisible(img_size, int(s)) # ceil gs-multiple
if new_size != img_size:
print('WARNING: --img-size %g must be multiple of max stride %g, updating to %g' % (img_size, s, new_size))
return new_size
是否进行第二步检测分类,通过resnet101对通过yolov5识别检测后的结果在进行一次预测。
# Second-stage classifier
classify = False
if classify:
modelc = load_classifier(name='resnet101', n=2) # initialize
modelc.load_state_dict(torch.load('weights/resnet101.pt', map_location=device)['model']).to(device).eval()
数据集的载入:
第一行是声明视频保存和vid_writer两个变量。
第二行,if里是如果检测目标是摄像头或者网络视频源,check_imshow()检测运行环境是否是在docker下(docker环境下不能打开界面显示,会报错),然后是视频流或者摄像头的一个载入。
如果不是视频流或者摄像头,载入检测对象为图片、视频或者文件夹内的文件,通过迭代的方式一个一个的进行检测。
# Set Dataloader
vid_path, vid_writer = None, None
if webcam:
view_img = check_imshow()
cudnn.benchmark = True # set True to speed up constant image size inference
dataset = LoadStreams(source, img_size=imgsz, stride=stride)
else:
dataset = LoadImages(source, img_size=imgsz, stride=stride)
载入模型内部的class names,以及当前检测每一个name对应的矩形框的颜色
# Get names and colors
names = model.module.names if hasattr(model, 'module') else model.names
colors = [[random.randint(0, 255) for _ in range(3)] for _ in names]
开始执行检测推理部分的代码:
第一个if内的代码是运行一次模型推理,推理为一张全为数值0的图片,图片分辨率为输入的imgsz* imgsz(opencv里的话是全黑的图片)。主要可能是为了检测模型检测是否会报错和让GPU开始预热,哈哈哈。
t0是为了计算整个推理过程的时间的开始计时,即完成文件夹内图像的推理时间。
dataset里的内容可以参考下LoadImages类函数里的__next__(),并通过__iter__(),进行文件夹内部文件的迭代更新。
for里的内容为先将图片内的数值载入到的device(GPU or CPU),如果是在GPU上进行推理(前面的half)将像数值的uint8类型转为fp16或者fp32,然后将像数值的变化范围固定到0.0-1.0之间(对深度学习推理计算更友好吧),最后判断图片的shape是不是3,因为在tensor环境下使用的是ndimension(),如果不是则增加一层。
path为检测对象的地址,img为将检测的原始图片resize到目标尺寸(不失真放缩,通过填充RBG为114, 114, 114的颜色条)
# Run inference
if device.type != 'cpu':
model(torch.zeros(1, 3, imgsz, imgsz).to(device).type_as(next(model.parameters()))) # run once
t0 = time.time()
for path, img, im0s, vid_cap in dataset:
img = torch.from_numpy(img).to(device)
img = img.half() if half else img.float() # uint8 to fp16/32
img /= 255.0 # 0 - 255 to 0.0 - 1.0
if img.ndimension() == 3:
img = img.unsqueeze(0)
t1是计算单个文件的推理时间的开始计时,在GPU上通过torch.cuda.synchronize()使推理时间计算的更加精确。
pred是模型推理输出的全部结果,以输入尺寸图片为640640为例,coco80类检测,输出的pred的shape为2520085,(20203)大分辨率下的2020个滑块同时乘以大分辨率对应的3个anchor;(40403)中分辨率下的4040个滑块同时乘以中分辨率对应的3个anchor;(80803)小分辨率下的80*80个滑块同时乘以小分辨率对应的3个anchor;三组数相加为25200,85其中[0:4]为anchor画出来的矩形框,[4]为ojbconf即anchor中含有80类中目标检测的置信度,后面的[5:]为80类别对应的每一类的置信度。
# Inference
t1 = time_synchronized()
pred = model(img, augment=opt.augment)[0]
def time_synchronized():
# pytorch-accurate time
if torch.cuda.is_available():
torch.cuda.synchronize()
return time.time()
极大值抑制,non_max_suppression()函数太长了,就不在这里贴出来了,有兴趣的可以在源码里看看,在这里我大致说下极大值抑制的流程吧。
第一步是判断pred列表里ojbconf也就是pred中其中一个anchor中[4]小于opt.conf_thres的值,返回一个True和Flase的列表,长度为25200。
当遇到判断列表中的第一个True,先计算类别置信度,即obj_conf*80类别算法得出对应的置信度,再将前面4个数即anchor对应的矩形框转换为xyxy的矩形框(box)的像数点坐标值。然后取出得到置信度(conf)中最大的值和所在的位置(classID),再将box的xyxy、conf以及classID拼凑起来为一个列表。最后通过NMS极大值抑制,通过对计算每个为True的box的交并比,输入满足要求的输入阈值的一个list,list的shape[1]为6,[x,y,x,y,conf,classID]。
# Apply NMS
pred = non_max_suppression(pred, opt.conf_thres, opt.iou_thres, classes=opt.classes, agnostic=opt.agnostic_nms)
t2 = time_synchronized()
是否对预测结果进行二次预测分类
# Apply Classifier
if classify:
pred = apply_classifier(pred, modelc, img, im0s)
预测结果的处理:
首先是判断预测的对象是是为视频源还是其他的对象,设声明结果保存的地址(图片结果以及标签结果),s为每张resize到目标尺寸图像的shape,即喂入网络的图片尺寸(str),在tensor上获得原始检测画面的size。
# Process detections
for i, det in enumerate(pred): # detections per image
if webcam: # batch_size >= 1
p, s, im0, frame = path[i], '%g: ' % i, im0s[i].copy(), dataset.count
else:
p, s, im0, frame = path, '', im0s, getattr(dataset, 'frame', 0)
p = Path(p) # to Path
save_path = str(save_dir / p.name) # img.jpg
txt_path = str(save_dir / 'labels' / p.stem) + ('' if dataset.mode == 'image' else f'_{frame}') # img.txt
s += '%gx%g ' % img.shape[2:] # print string
gn = torch.tensor(im0.shape)[[1, 0, 1, 0]] # normalization gain whwh
如果det中有相应的检测目标,将det[:, 0:4],即在img(原图(im0s)resize到targetSize后的图像)上xyxy对于的像数值坐标还原到原图(im0s)的像数值坐标,为了后续方便在原图上画出目标物的矩形框。
然后是确定det[:, -1]中的所存在同类的classID一共有多少个,并有几个类别,是在后续输出检测完成后打印检测结果的相关信息。
检测结果写入,如果在程序执行时输入了 --save-txt,则将类别 xywh 置信度 写入保存在save_dir / ‘labels’/图片名中,如果去掉置信度和前后的空格就是通过labelimg标注后的yolo标签。
执行程序的时候,没有设置 --nosave或设置了–view-img,则在im0s原始图像上画出预测结果的矩形框,为了后续的保存图像、视频结果或者网络视频源。
在命令框内打印检测总体结果
if len(det):
# Rescale boxes from img_size to im0 size
det[:, :4] = scale_coords(img.shape[2:], det[:, :4], im0.shape).round()
# Print results
for c in det[:, -1].unique():
n = (det[:, -1] == c).sum() # detections per class
s += f"{n} {names[int(c)]}{'s' * (n > 1)}, " # add to string
# Write results
for *xyxy, conf, cls in reversed(det):
if save_txt: # Write to file
xywh = (xyxy2xywh(torch.tensor(xyxy).view(1, 4)) / gn).view(-1).tolist() # normalized xywh
line = (cls, *xywh, conf) if opt.save_conf else (cls, *xywh) # label format
with open(txt_path + '.txt', 'a') as f:
f.write(('%g ' * len(line)).rstrip() % line + '\n')
if save_img or view_img: # Add bbox to image
label = f'{names[int(cls)]} {conf:.2f}'
plot_one_box(xyxy, im0, label=label, color=colors[int(cls)], line_thickness=3)
# Print time (inference + NMS)
print(f'{s}Done. ({t2 - t1:.3f}s)')
检测时需要查看图片或者视频结果,通过imshow显示,显示检测后的图像或视频结果。
# Stream results
if view_img:
cv2.imshow(str(p), im0)
cv2.waitKey(1) # 1 millisecond
当检测对象是图片是,保存图片到save_path,为save_dir / 完整的图片名(有尾缀)。
当检测对象是视频或者视频源时,因为对视频检测是对视频中的每一帧的图像进行检测,检测视频显示是通过将视频的每一帧拼接成视频显示出来的,当视频检测结果保存的save_path在同一个视频的时候不会发生变化,当发生变化时,cv2.VideoWriter里的save_path也需要相应的改变。
如果检测对象是本地视频则通过cv2获取fps,w,h,检测对象是网络视频默认fps=30。
然后通过cv2.VideoWriter(args).write(im0)写入每一帧检测结果,保存视频。
if save_img:
if dataset.mode == 'image':
cv2.imwrite(save_path, im0)
else: # 'video' or 'stream'
if vid_path != save_path: # new video
vid_path = save_path
if isinstance(vid_writer, cv2.VideoWriter):
vid_writer.release() # release previous video writer
if vid_cap: # video
fps = vid_cap.get(cv2.CAP_PROP_FPS)
w = int(vid_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(vid_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
else: # stream
fps, w, h = 30, im0.shape[1], im0.shape[0]
save_path += '.mp4'
vid_writer = cv2.VideoWriter(save_path, cv2.VideoWriter_fourcc(*'mp4v'), fps, (w, h))
vid_writer.write(im0)
打印最总的检测结果。
if save_txt or save_img:
s = f"\n{len(list(save_dir.glob('labels/*.txt')))} labels saved to {save_dir / 'labels'}" if save_txt else ''
print(f"Results saved to {save_dir}{s}")
print(f'Done. ({time.time() - t0:.3f}s)')
欢迎讨论,并指出纠正上面的错误。