val.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. # Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
  2. from pathlib import Path
  3. import numpy as np
  4. import torch
  5. from ultralytics.models.yolo.detect import DetectionValidator
  6. from ultralytics.utils import LOGGER, ops
  7. from ultralytics.utils.checks import check_requirements
  8. from ultralytics.utils.metrics import OKS_SIGMA, PoseMetrics, box_iou, kpt_iou
  9. from ultralytics.utils.plotting import output_to_target, plot_images
  10. class PoseValidator(DetectionValidator):
  11. """
  12. A class extending the DetectionValidator class for validation based on a pose model.
  13. Example:
  14. ```python
  15. from ultralytics.models.yolo.pose import PoseValidator
  16. args = dict(model="yolov8n-pose.pt", data="coco8-pose.yaml")
  17. validator = PoseValidator(args=args)
  18. validator()
  19. ```
  20. """
  21. def __init__(self, dataloader=None, save_dir=None, pbar=None, args=None, _callbacks=None):
  22. """Initialize a 'PoseValidator' object with custom parameters and assigned attributes."""
  23. super().__init__(dataloader, save_dir, pbar, args, _callbacks)
  24. self.sigma = None
  25. self.kpt_shape = None
  26. self.args.task = "pose"
  27. self.metrics = PoseMetrics(save_dir=self.save_dir, on_plot=self.on_plot)
  28. if isinstance(self.args.device, str) and self.args.device.lower() == "mps":
  29. LOGGER.warning(
  30. "WARNING ⚠️ Apple MPS known Pose bug. Recommend 'device=cpu' for Pose models. "
  31. "See https://github.com/ultralytics/ultralytics/issues/4031."
  32. )
  33. def preprocess(self, batch):
  34. """Preprocesses the batch by converting the 'keypoints' data into a float and moving it to the device."""
  35. batch = super().preprocess(batch)
  36. batch["keypoints"] = batch["keypoints"].to(self.device).float()
  37. return batch
  38. def get_desc(self):
  39. """Returns description of evaluation metrics in string format."""
  40. return ("%22s" + "%11s" * 10) % (
  41. "Class",
  42. "Images",
  43. "Instances",
  44. "Box(P",
  45. "R",
  46. "mAP50",
  47. "mAP50-95)",
  48. "Pose(P",
  49. "R",
  50. "mAP50",
  51. "mAP50-95)",
  52. )
  53. def postprocess(self, preds):
  54. """Apply non-maximum suppression and return detections with high confidence scores."""
  55. return ops.non_max_suppression(
  56. preds,
  57. self.args.conf,
  58. self.args.iou,
  59. labels=self.lb,
  60. multi_label=True,
  61. agnostic=self.args.single_cls or self.args.agnostic_nms,
  62. max_det=self.args.max_det,
  63. nc=self.nc,
  64. )
  65. def init_metrics(self, model):
  66. """Initiate pose estimation metrics for YOLO model."""
  67. super().init_metrics(model)
  68. self.kpt_shape = self.data["kpt_shape"]
  69. is_pose = self.kpt_shape == [17, 3]
  70. nkpt = self.kpt_shape[0]
  71. self.sigma = OKS_SIGMA if is_pose else np.ones(nkpt) / nkpt
  72. self.stats = dict(tp_p=[], tp=[], conf=[], pred_cls=[], target_cls=[], target_img=[])
  73. def _prepare_batch(self, si, batch):
  74. """Prepares a batch for processing by converting keypoints to float and moving to device."""
  75. pbatch = super()._prepare_batch(si, batch)
  76. kpts = batch["keypoints"][batch["batch_idx"] == si]
  77. h, w = pbatch["imgsz"]
  78. kpts = kpts.clone()
  79. kpts[..., 0] *= w
  80. kpts[..., 1] *= h
  81. kpts = ops.scale_coords(pbatch["imgsz"], kpts, pbatch["ori_shape"], ratio_pad=pbatch["ratio_pad"])
  82. pbatch["kpts"] = kpts
  83. return pbatch
  84. def _prepare_pred(self, pred, pbatch):
  85. """Prepares and scales keypoints in a batch for pose processing."""
  86. predn = super()._prepare_pred(pred, pbatch)
  87. nk = pbatch["kpts"].shape[1]
  88. pred_kpts = predn[:, 6:].view(len(predn), nk, -1)
  89. ops.scale_coords(pbatch["imgsz"], pred_kpts, pbatch["ori_shape"], ratio_pad=pbatch["ratio_pad"])
  90. return predn, pred_kpts
  91. def update_metrics(self, preds, batch):
  92. """Metrics."""
  93. for si, pred in enumerate(preds):
  94. self.seen += 1
  95. npr = len(pred)
  96. stat = dict(
  97. conf=torch.zeros(0, device=self.device),
  98. pred_cls=torch.zeros(0, device=self.device),
  99. tp=torch.zeros(npr, self.niou, dtype=torch.bool, device=self.device),
  100. tp_p=torch.zeros(npr, self.niou, dtype=torch.bool, device=self.device),
  101. )
  102. pbatch = self._prepare_batch(si, batch)
  103. cls, bbox = pbatch.pop("cls"), pbatch.pop("bbox")
  104. nl = len(cls)
  105. stat["target_cls"] = cls
  106. stat["target_img"] = cls.unique()
  107. if npr == 0:
  108. if nl:
  109. for k in self.stats.keys():
  110. self.stats[k].append(stat[k])
  111. if self.args.plots:
  112. self.confusion_matrix.process_batch(detections=None, gt_bboxes=bbox, gt_cls=cls)
  113. continue
  114. # Predictions
  115. if self.args.single_cls:
  116. pred[:, 5] = 0
  117. predn, pred_kpts = self._prepare_pred(pred, pbatch)
  118. stat["conf"] = predn[:, 4]
  119. stat["pred_cls"] = predn[:, 5]
  120. # Evaluate
  121. if nl:
  122. stat["tp"] = self._process_batch(predn, bbox, cls)
  123. stat["tp_p"] = self._process_batch(predn, bbox, cls, pred_kpts, pbatch["kpts"])
  124. if self.args.plots:
  125. self.confusion_matrix.process_batch(predn, bbox, cls)
  126. for k in self.stats.keys():
  127. self.stats[k].append(stat[k])
  128. # Save
  129. if self.args.save_json:
  130. self.pred_to_json(predn, batch["im_file"][si])
  131. if self.args.save_txt:
  132. self.save_one_txt(
  133. predn,
  134. pred_kpts,
  135. self.args.save_conf,
  136. pbatch["ori_shape"],
  137. self.save_dir / "labels" / f"{Path(batch['im_file'][si]).stem}.txt",
  138. )
  139. def _process_batch(self, detections, gt_bboxes, gt_cls, pred_kpts=None, gt_kpts=None):
  140. """
  141. Return correct prediction matrix by computing Intersection over Union (IoU) between detections and ground truth.
  142. Args:
  143. detections (torch.Tensor): Tensor with shape (N, 6) representing detection boxes and scores, where each
  144. detection is of the format (x1, y1, x2, y2, conf, class).
  145. gt_bboxes (torch.Tensor): Tensor with shape (M, 4) representing ground truth bounding boxes, where each
  146. box is of the format (x1, y1, x2, y2).
  147. gt_cls (torch.Tensor): Tensor with shape (M,) representing ground truth class indices.
  148. pred_kpts (torch.Tensor | None): Optional tensor with shape (N, 51) representing predicted keypoints, where
  149. 51 corresponds to 17 keypoints each having 3 values.
  150. gt_kpts (torch.Tensor | None): Optional tensor with shape (N, 51) representing ground truth keypoints.
  151. Returns:
  152. torch.Tensor: A tensor with shape (N, 10) representing the correct prediction matrix for 10 IoU levels,
  153. where N is the number of detections.
  154. Example:
  155. ```python
  156. detections = torch.rand(100, 6) # 100 predictions: (x1, y1, x2, y2, conf, class)
  157. gt_bboxes = torch.rand(50, 4) # 50 ground truth boxes: (x1, y1, x2, y2)
  158. gt_cls = torch.randint(0, 2, (50,)) # 50 ground truth class indices
  159. pred_kpts = torch.rand(100, 51) # 100 predicted keypoints
  160. gt_kpts = torch.rand(50, 51) # 50 ground truth keypoints
  161. correct_preds = _process_batch(detections, gt_bboxes, gt_cls, pred_kpts, gt_kpts)
  162. ```
  163. Note:
  164. `0.53` scale factor used in area computation is referenced from https://github.com/jin-s13/xtcocoapi/blob/master/xtcocotools/cocoeval.py#L384.
  165. """
  166. if pred_kpts is not None and gt_kpts is not None:
  167. # `0.53` is from https://github.com/jin-s13/xtcocoapi/blob/master/xtcocotools/cocoeval.py#L384
  168. area = ops.xyxy2xywh(gt_bboxes)[:, 2:].prod(1) * 0.53
  169. iou = kpt_iou(gt_kpts, pred_kpts, sigma=self.sigma, area=area)
  170. else: # boxes
  171. iou = box_iou(gt_bboxes, detections[:, :4])
  172. return self.match_predictions(detections[:, 5], gt_cls, iou)
  173. def plot_val_samples(self, batch, ni):
  174. """Plots and saves validation set samples with predicted bounding boxes and keypoints."""
  175. plot_images(
  176. batch["img"],
  177. batch["batch_idx"],
  178. batch["cls"].squeeze(-1),
  179. batch["bboxes"],
  180. kpts=batch["keypoints"],
  181. paths=batch["im_file"],
  182. fname=self.save_dir / f"val_batch{ni}_labels.jpg",
  183. names=self.names,
  184. on_plot=self.on_plot,
  185. )
  186. def plot_predictions(self, batch, preds, ni):
  187. """Plots predictions for YOLO model."""
  188. pred_kpts = torch.cat([p[:, 6:].view(-1, *self.kpt_shape) for p in preds], 0)
  189. plot_images(
  190. batch["img"],
  191. *output_to_target(preds, max_det=self.args.max_det),
  192. kpts=pred_kpts,
  193. paths=batch["im_file"],
  194. fname=self.save_dir / f"val_batch{ni}_pred.jpg",
  195. names=self.names,
  196. on_plot=self.on_plot,
  197. ) # pred
  198. def save_one_txt(self, predn, pred_kpts, save_conf, shape, file):
  199. """Save YOLO detections to a txt file in normalized coordinates in a specific format."""
  200. from ultralytics.engine.results import Results
  201. Results(
  202. np.zeros((shape[0], shape[1]), dtype=np.uint8),
  203. path=None,
  204. names=self.names,
  205. boxes=predn[:, :6],
  206. keypoints=pred_kpts,
  207. ).save_txt(file, save_conf=save_conf)
  208. def pred_to_json(self, predn, filename):
  209. """Converts YOLO predictions to COCO JSON format."""
  210. stem = Path(filename).stem
  211. image_id = int(stem) if stem.isnumeric() else stem
  212. box = ops.xyxy2xywh(predn[:, :4]) # xywh
  213. box[:, :2] -= box[:, 2:] / 2 # xy center to top-left corner
  214. for p, b in zip(predn.tolist(), box.tolist()):
  215. self.jdict.append(
  216. {
  217. "image_id": image_id,
  218. "category_id": self.class_map[int(p[5])],
  219. "bbox": [round(x, 3) for x in b],
  220. "keypoints": p[6:],
  221. "score": round(p[4], 5),
  222. }
  223. )
  224. def eval_json(self, stats):
  225. """Evaluates object detection model using COCO JSON format."""
  226. if self.args.save_json and self.is_coco and len(self.jdict):
  227. anno_json = self.data["path"] / "annotations/person_keypoints_val2017.json" # annotations
  228. pred_json = self.save_dir / "predictions.json" # predictions
  229. LOGGER.info(f"\nEvaluating pycocotools mAP using {pred_json} and {anno_json}...")
  230. try: # https://github.com/cocodataset/cocoapi/blob/master/PythonAPI/pycocoEvalDemo.ipynb
  231. check_requirements("pycocotools>=2.0.6")
  232. from pycocotools.coco import COCO # noqa
  233. from pycocotools.cocoeval import COCOeval # noqa
  234. for x in anno_json, pred_json:
  235. assert x.is_file(), f"{x} file not found"
  236. anno = COCO(str(anno_json)) # init annotations api
  237. pred = anno.loadRes(str(pred_json)) # init predictions api (must pass string, not Path)
  238. for i, eval in enumerate([COCOeval(anno, pred, "bbox"), COCOeval(anno, pred, "keypoints")]):
  239. if self.is_coco:
  240. eval.params.imgIds = [int(Path(x).stem) for x in self.dataloader.dataset.im_files] # im to eval
  241. eval.evaluate()
  242. eval.accumulate()
  243. eval.summarize()
  244. idx = i * 4 + 2
  245. stats[self.metrics.keys[idx + 1]], stats[self.metrics.keys[idx]] = eval.stats[
  246. :2
  247. ] # update mAP50-95 and mAP50
  248. except Exception as e:
  249. LOGGER.warning(f"pycocotools unable to run: {e}")
  250. return stats