PKaushik commited on
Commit
e6a2ff6
1 Parent(s): dedceac
Files changed (1) hide show
  1. yolov6/data/datasets.py +550 -0
yolov6/data/datasets.py ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding:utf-8 -*-
3
+
4
+ import glob
5
+ import os
6
+ import os.path as osp
7
+ import random
8
+ import json
9
+ import time
10
+ import hashlib
11
+
12
+ from multiprocessing.pool import Pool
13
+
14
+ import cv2
15
+ import numpy as np
16
+ import torch
17
+ from PIL import ExifTags, Image, ImageOps
18
+ from torch.utils.data import Dataset
19
+ from tqdm import tqdm
20
+
21
+ from .data_augment import (
22
+ augment_hsv,
23
+ letterbox,
24
+ mixup,
25
+ random_affine,
26
+ mosaic_augmentation,
27
+ )
28
+ from yolov6.utils.events import LOGGER
29
+
30
+ # Parameters
31
+ IMG_FORMATS = ["bmp", "jpg", "jpeg", "png", "tif", "tiff", "dng", "webp", "mpo"]
32
+ # Get orientation exif tag
33
+ for k, v in ExifTags.TAGS.items():
34
+ if v == "Orientation":
35
+ ORIENTATION = k
36
+ break
37
+
38
+
39
+ class TrainValDataset(Dataset):
40
+ # YOLOv6 train_loader/val_loader, loads images and labels for training and validation
41
+ def __init__(
42
+ self,
43
+ img_dir,
44
+ img_size=640,
45
+ batch_size=16,
46
+ augment=False,
47
+ hyp=None,
48
+ rect=False,
49
+ check_images=False,
50
+ check_labels=False,
51
+ stride=32,
52
+ pad=0.0,
53
+ rank=-1,
54
+ data_dict=None,
55
+ task="train",
56
+ ):
57
+ assert task.lower() in ("train", "val", "speed"), f"Not supported task: {task}"
58
+ t1 = time.time()
59
+ self.__dict__.update(locals())
60
+ self.main_process = self.rank in (-1, 0)
61
+ self.task = self.task.capitalize()
62
+ self.class_names = data_dict["names"]
63
+ self.img_paths, self.labels = self.get_imgs_labels(self.img_dir)
64
+ if self.rect:
65
+ shapes = [self.img_info[p]["shape"] for p in self.img_paths]
66
+ self.shapes = np.array(shapes, dtype=np.float64)
67
+ self.batch_indices = np.floor(
68
+ np.arange(len(shapes)) / self.batch_size
69
+ ).astype(
70
+ np.int
71
+ ) # batch indices of each image
72
+ self.sort_files_shapes()
73
+ t2 = time.time()
74
+ if self.main_process:
75
+ LOGGER.info(f"%.1fs for dataset initialization." % (t2 - t1))
76
+
77
+ def __len__(self):
78
+ """Get the length of dataset"""
79
+ return len(self.img_paths)
80
+
81
+ def __getitem__(self, index):
82
+ """Fetching a data sample for a given key.
83
+ This function applies mosaic and mixup augments during training.
84
+ During validation, letterbox augment is applied.
85
+ """
86
+ # Mosaic Augmentation
87
+ if self.augment and random.random() < self.hyp["mosaic"]:
88
+ img, labels = self.get_mosaic(index)
89
+ shapes = None
90
+
91
+ # MixUp augmentation
92
+ if random.random() < self.hyp["mixup"]:
93
+ img_other, labels_other = self.get_mosaic(
94
+ random.randint(0, len(self.img_paths) - 1)
95
+ )
96
+ img, labels = mixup(img, labels, img_other, labels_other)
97
+
98
+ else:
99
+ # Load image
100
+ img, (h0, w0), (h, w) = self.load_image(index)
101
+
102
+ # Letterbox
103
+ shape = (
104
+ self.batch_shapes[self.batch_indices[index]]
105
+ if self.rect
106
+ else self.img_size
107
+ ) # final letterboxed shape
108
+ img, ratio, pad = letterbox(img, shape, auto=False, scaleup=self.augment)
109
+ shapes = (h0, w0), ((h / h0, w / w0), pad) # for COCO mAP rescaling
110
+
111
+ labels = self.labels[index].copy()
112
+ if labels.size:
113
+ w *= ratio
114
+ h *= ratio
115
+ # new boxes
116
+ boxes = np.copy(labels[:, 1:])
117
+ boxes[:, 0] = (
118
+ w * (labels[:, 1] - labels[:, 3] / 2) + pad[0]
119
+ ) # top left x
120
+ boxes[:, 1] = (
121
+ h * (labels[:, 2] - labels[:, 4] / 2) + pad[1]
122
+ ) # top left y
123
+ boxes[:, 2] = (
124
+ w * (labels[:, 1] + labels[:, 3] / 2) + pad[0]
125
+ ) # bottom right x
126
+ boxes[:, 3] = (
127
+ h * (labels[:, 2] + labels[:, 4] / 2) + pad[1]
128
+ ) # bottom right y
129
+ labels[:, 1:] = boxes
130
+
131
+ if self.augment:
132
+ img, labels = random_affine(
133
+ img,
134
+ labels,
135
+ degrees=self.hyp["degrees"],
136
+ translate=self.hyp["translate"],
137
+ scale=self.hyp["scale"],
138
+ shear=self.hyp["shear"],
139
+ new_shape=(self.img_size, self.img_size),
140
+ )
141
+
142
+ if len(labels):
143
+ h, w = img.shape[:2]
144
+
145
+ labels[:, [1, 3]] = labels[:, [1, 3]].clip(0, w - 1e-3) # x1, x2
146
+ labels[:, [2, 4]] = labels[:, [2, 4]].clip(0, h - 1e-3) # y1, y2
147
+
148
+ boxes = np.copy(labels[:, 1:])
149
+ boxes[:, 0] = ((labels[:, 1] + labels[:, 3]) / 2) / w # x center
150
+ boxes[:, 1] = ((labels[:, 2] + labels[:, 4]) / 2) / h # y center
151
+ boxes[:, 2] = (labels[:, 3] - labels[:, 1]) / w # width
152
+ boxes[:, 3] = (labels[:, 4] - labels[:, 2]) / h # height
153
+ labels[:, 1:] = boxes
154
+
155
+ if self.augment:
156
+ img, labels = self.general_augment(img, labels)
157
+
158
+ labels_out = torch.zeros((len(labels), 6))
159
+ if len(labels):
160
+ labels_out[:, 1:] = torch.from_numpy(labels)
161
+
162
+ # Convert
163
+ img = img.transpose((2, 0, 1))[::-1] # HWC to CHW, BGR to RGB
164
+ img = np.ascontiguousarray(img)
165
+
166
+ return torch.from_numpy(img), labels_out, self.img_paths[index], shapes
167
+
168
+ def load_image(self, index):
169
+ """Load image.
170
+ This function loads image by cv2, resize original image to target shape(img_size) with keeping ratio.
171
+
172
+ Returns:
173
+ Image, original shape of image, resized image shape
174
+ """
175
+ path = self.img_paths[index]
176
+ im = cv2.imread(path)
177
+ assert im is not None, f"Image Not Found {path}, workdir: {os.getcwd()}"
178
+
179
+ h0, w0 = im.shape[:2] # origin shape
180
+ r = self.img_size / max(h0, w0)
181
+ if r != 1:
182
+ im = cv2.resize(
183
+ im,
184
+ (int(w0 * r), int(h0 * r)),
185
+ interpolation=cv2.INTER_AREA
186
+ if r < 1 and not self.augment
187
+ else cv2.INTER_LINEAR,
188
+ )
189
+ return im, (h0, w0), im.shape[:2]
190
+
191
+ @staticmethod
192
+ def collate_fn(batch):
193
+ """Merges a list of samples to form a mini-batch of Tensor(s)"""
194
+ img, label, path, shapes = zip(*batch)
195
+ for i, l in enumerate(label):
196
+ l[:, 0] = i # add target image index for build_targets()
197
+ return torch.stack(img, 0), torch.cat(label, 0), path, shapes
198
+
199
+ def get_imgs_labels(self, img_dir):
200
+
201
+ assert osp.exists(img_dir), f"{img_dir} is an invalid directory path!"
202
+ valid_img_record = osp.join(
203
+ osp.dirname(img_dir), "." + osp.basename(img_dir) + ".json"
204
+ )
205
+ NUM_THREADS = min(8, os.cpu_count())
206
+
207
+ img_paths = glob.glob(osp.join(img_dir, "*"), recursive=True)
208
+ img_paths = sorted(
209
+ p for p in img_paths if p.split(".")[-1].lower() in IMG_FORMATS
210
+ )
211
+ assert img_paths, f"No images found in {img_dir}."
212
+
213
+ img_hash = self.get_hash(img_paths)
214
+ if osp.exists(valid_img_record):
215
+ with open(valid_img_record, "r") as f:
216
+ cache_info = json.load(f)
217
+ if "image_hash" in cache_info and cache_info["image_hash"] == img_hash:
218
+ img_info = cache_info["information"]
219
+ else:
220
+ self.check_images = True
221
+ else:
222
+ self.check_images = True
223
+
224
+ # check images
225
+ if self.check_images and self.main_process:
226
+ img_info = {}
227
+ nc, msgs = 0, [] # number corrupt, messages
228
+ LOGGER.info(
229
+ f"{self.task}: Checking formats of images with {NUM_THREADS} process(es): "
230
+ )
231
+ with Pool(NUM_THREADS) as pool:
232
+ pbar = tqdm(
233
+ pool.imap(TrainValDataset.check_image, img_paths),
234
+ total=len(img_paths),
235
+ )
236
+ for img_path, shape_per_img, nc_per_img, msg in pbar:
237
+ if nc_per_img == 0: # not corrupted
238
+ img_info[img_path] = {"shape": shape_per_img}
239
+ nc += nc_per_img
240
+ if msg:
241
+ msgs.append(msg)
242
+ pbar.desc = f"{nc} image(s) corrupted"
243
+ pbar.close()
244
+ if msgs:
245
+ LOGGER.info("\n".join(msgs))
246
+
247
+ cache_info = {"information": img_info, "image_hash": img_hash}
248
+ # save valid image paths.
249
+ with open(valid_img_record, "w") as f:
250
+ json.dump(cache_info, f)
251
+
252
+ # check and load anns
253
+ label_dir = osp.join(
254
+ osp.dirname(osp.dirname(img_dir)), "labels", osp.basename(img_dir)
255
+ )
256
+ assert osp.exists(label_dir), f"{label_dir} is an invalid directory path!"
257
+
258
+ img_paths = list(img_info.keys())
259
+ label_paths = sorted(
260
+ osp.join(label_dir, osp.splitext(osp.basename(p))[0] + ".txt")
261
+ for p in img_paths
262
+ )
263
+ label_hash = self.get_hash(label_paths)
264
+ if "label_hash" not in cache_info or cache_info["label_hash"] != label_hash:
265
+ self.check_labels = True
266
+
267
+ if self.check_labels:
268
+ cache_info["label_hash"] = label_hash
269
+ nm, nf, ne, nc, msgs = 0, 0, 0, 0, [] # number corrupt, messages
270
+ LOGGER.info(
271
+ f"{self.task}: Checking formats of labels with {NUM_THREADS} process(es): "
272
+ )
273
+ with Pool(NUM_THREADS) as pool:
274
+ pbar = pool.imap(
275
+ TrainValDataset.check_label_files, zip(img_paths, label_paths)
276
+ )
277
+ pbar = tqdm(pbar, total=len(label_paths)) if self.main_process else pbar
278
+ for (
279
+ img_path,
280
+ labels_per_file,
281
+ nc_per_file,
282
+ nm_per_file,
283
+ nf_per_file,
284
+ ne_per_file,
285
+ msg,
286
+ ) in pbar:
287
+ if nc_per_file == 0:
288
+ img_info[img_path]["labels"] = labels_per_file
289
+ else:
290
+ img_info.pop(img_path)
291
+ nc += nc_per_file
292
+ nm += nm_per_file
293
+ nf += nf_per_file
294
+ ne += ne_per_file
295
+ if msg:
296
+ msgs.append(msg)
297
+ if self.main_process:
298
+ pbar.desc = f"{nf} label(s) found, {nm} label(s) missing, {ne} label(s) empty, {nc} invalid label files"
299
+ if self.main_process:
300
+ pbar.close()
301
+ with open(valid_img_record, "w") as f:
302
+ json.dump(cache_info, f)
303
+ if msgs:
304
+ LOGGER.info("\n".join(msgs))
305
+ if nf == 0:
306
+ LOGGER.warning(
307
+ f"WARNING: No labels found in {osp.dirname(self.img_paths[0])}. "
308
+ )
309
+
310
+ if self.task.lower() == "val":
311
+ if self.data_dict.get("is_coco", False): # use original json file when evaluating on coco dataset.
312
+ assert osp.exists(self.data_dict["anno_path"]), "Eval on coco dataset must provide valid path of the annotation file in config file: data/coco.yaml"
313
+ else:
314
+ assert (
315
+ self.class_names
316
+ ), "Class names is required when converting labels to coco format for evaluating."
317
+ save_dir = osp.join(osp.dirname(osp.dirname(img_dir)), "annotations")
318
+ if not osp.exists(save_dir):
319
+ os.mkdir(save_dir)
320
+ save_path = osp.join(
321
+ save_dir, "instances_" + osp.basename(img_dir) + ".json"
322
+ )
323
+ TrainValDataset.generate_coco_format_labels(
324
+ img_info, self.class_names, save_path
325
+ )
326
+
327
+ img_paths, labels = list(
328
+ zip(
329
+ *[
330
+ (
331
+ img_path,
332
+ np.array(info["labels"], dtype=np.float32)
333
+ if info["labels"]
334
+ else np.zeros((0, 5), dtype=np.float32),
335
+ )
336
+ for img_path, info in img_info.items()
337
+ ]
338
+ )
339
+ )
340
+ self.img_info = img_info
341
+ LOGGER.info(
342
+ f"{self.task}: Final numbers of valid images: {len(img_paths)}/ labels: {len(labels)}. "
343
+ )
344
+ return img_paths, labels
345
+
346
+ def get_mosaic(self, index):
347
+ """Gets images and labels after mosaic augments"""
348
+ indices = [index] + random.choices(
349
+ range(0, len(self.img_paths)), k=3
350
+ ) # 3 additional image indices
351
+ random.shuffle(indices)
352
+ imgs, hs, ws, labels = [], [], [], []
353
+ for index in indices:
354
+ img, _, (h, w) = self.load_image(index)
355
+ labels_per_img = self.labels[index]
356
+ imgs.append(img)
357
+ hs.append(h)
358
+ ws.append(w)
359
+ labels.append(labels_per_img)
360
+ img, labels = mosaic_augmentation(self.img_size, imgs, hs, ws, labels, self.hyp)
361
+ return img, labels
362
+
363
+ def general_augment(self, img, labels):
364
+ """Gets images and labels after general augment
365
+ This function applies hsv, random ud-flip and random lr-flips augments.
366
+ """
367
+ nl = len(labels)
368
+
369
+ # HSV color-space
370
+ augment_hsv(
371
+ img,
372
+ hgain=self.hyp["hsv_h"],
373
+ sgain=self.hyp["hsv_s"],
374
+ vgain=self.hyp["hsv_v"],
375
+ )
376
+
377
+ # Flip up-down
378
+ if random.random() < self.hyp["flipud"]:
379
+ img = np.flipud(img)
380
+ if nl:
381
+ labels[:, 2] = 1 - labels[:, 2]
382
+
383
+ # Flip left-right
384
+ if random.random() < self.hyp["fliplr"]:
385
+ img = np.fliplr(img)
386
+ if nl:
387
+ labels[:, 1] = 1 - labels[:, 1]
388
+
389
+ return img, labels
390
+
391
+ def sort_files_shapes(self):
392
+ # Sort by aspect ratio
393
+ batch_num = self.batch_indices[-1] + 1
394
+ s = self.shapes # wh
395
+ ar = s[:, 1] / s[:, 0] # aspect ratio
396
+ irect = ar.argsort()
397
+ self.img_paths = [self.img_paths[i] for i in irect]
398
+ self.labels = [self.labels[i] for i in irect]
399
+ self.shapes = s[irect] # wh
400
+ ar = ar[irect]
401
+
402
+ # Set training image shapes
403
+ shapes = [[1, 1]] * batch_num
404
+ for i in range(batch_num):
405
+ ari = ar[self.batch_indices == i]
406
+ mini, maxi = ari.min(), ari.max()
407
+ if maxi < 1:
408
+ shapes[i] = [maxi, 1]
409
+ elif mini > 1:
410
+ shapes[i] = [1, 1 / mini]
411
+ self.batch_shapes = (
412
+ np.ceil(np.array(shapes) * self.img_size / self.stride + self.pad).astype(
413
+ np.int
414
+ )
415
+ * self.stride
416
+ )
417
+
418
+ @staticmethod
419
+ def check_image(im_file):
420
+ # verify an image.
421
+ nc, msg = 0, ""
422
+ try:
423
+ im = Image.open(im_file)
424
+ im.verify() # PIL verify
425
+ shape = im.size # (width, height)
426
+ im_exif = im._getexif()
427
+ if im_exif and ORIENTATION in im_exif:
428
+ rotation = im_exif[ORIENTATION]
429
+ if rotation in (6, 8):
430
+ shape = (shape[1], shape[0])
431
+
432
+ assert (shape[0] > 9) & (shape[1] > 9), f"image size {shape} <10 pixels"
433
+ assert im.format.lower() in IMG_FORMATS, f"invalid image format {im.format}"
434
+ if im.format.lower() in ("jpg", "jpeg"):
435
+ with open(im_file, "rb") as f:
436
+ f.seek(-2, 2)
437
+ if f.read() != b"\xff\xd9": # corrupt JPEG
438
+ ImageOps.exif_transpose(Image.open(im_file)).save(
439
+ im_file, "JPEG", subsampling=0, quality=100
440
+ )
441
+ msg += f"WARNING: {im_file}: corrupt JPEG restored and saved"
442
+ return im_file, shape, nc, msg
443
+ except Exception as e:
444
+ nc = 1
445
+ msg = f"WARNING: {im_file}: ignoring corrupt image: {e}"
446
+ return im_file, None, nc, msg
447
+
448
+ @staticmethod
449
+ def check_label_files(args):
450
+ img_path, lb_path = args
451
+ nm, nf, ne, nc, msg = 0, 0, 0, 0, "" # number (missing, found, empty, message
452
+ try:
453
+ if osp.exists(lb_path):
454
+ nf = 1 # label found
455
+ with open(lb_path, "r") as f:
456
+ labels = [
457
+ x.split() for x in f.read().strip().splitlines() if len(x)
458
+ ]
459
+ labels = np.array(labels, dtype=np.float32)
460
+ if len(labels):
461
+ assert all(
462
+ len(l) == 5 for l in labels
463
+ ), f"{lb_path}: wrong label format."
464
+ assert (
465
+ labels >= 0
466
+ ).all(), f"{lb_path}: Label values error: all values in label file must > 0"
467
+ assert (
468
+ labels[:, 1:] <= 1
469
+ ).all(), f"{lb_path}: Label values error: all coordinates must be normalized"
470
+
471
+ _, indices = np.unique(labels, axis=0, return_index=True)
472
+ if len(indices) < len(labels): # duplicate row check
473
+ labels = labels[indices] # remove duplicates
474
+ msg += f"WARNING: {lb_path}: {len(labels) - len(indices)} duplicate labels removed"
475
+ labels = labels.tolist()
476
+ else:
477
+ ne = 1 # label empty
478
+ labels = []
479
+ else:
480
+ nm = 1 # label missing
481
+ labels = []
482
+
483
+ return img_path, labels, nc, nm, nf, ne, msg
484
+ except Exception as e:
485
+ nc = 1
486
+ msg = f"WARNING: {lb_path}: ignoring invalid labels: {e}"
487
+ return img_path, None, nc, nm, nf, ne, msg
488
+
489
+ @staticmethod
490
+ def generate_coco_format_labels(img_info, class_names, save_path):
491
+ # for evaluation with pycocotools
492
+ dataset = {"categories": [], "annotations": [], "images": []}
493
+ for i, class_name in enumerate(class_names):
494
+ dataset["categories"].append(
495
+ {"id": i, "name": class_name, "supercategory": ""}
496
+ )
497
+
498
+ ann_id = 0
499
+ LOGGER.info(f"Convert to COCO format")
500
+ for i, (img_path, info) in enumerate(tqdm(img_info.items())):
501
+ labels = info["labels"] if info["labels"] else []
502
+ img_id = osp.splitext(osp.basename(img_path))[0]
503
+ img_id = int(img_id) if img_id.isnumeric() else img_id
504
+ img_w, img_h = info["shape"]
505
+ dataset["images"].append(
506
+ {
507
+ "file_name": os.path.basename(img_path),
508
+ "id": img_id,
509
+ "width": img_w,
510
+ "height": img_h,
511
+ }
512
+ )
513
+ if labels:
514
+ for label in labels:
515
+ c, x, y, w, h = label[:5]
516
+ # convert x,y,w,h to x1,y1,x2,y2
517
+ x1 = (x - w / 2) * img_w
518
+ y1 = (y - h / 2) * img_h
519
+ x2 = (x + w / 2) * img_w
520
+ y2 = (y + h / 2) * img_h
521
+ # cls_id starts from 0
522
+ cls_id = int(c)
523
+ w = max(0, x2 - x1)
524
+ h = max(0, y2 - y1)
525
+ dataset["annotations"].append(
526
+ {
527
+ "area": h * w,
528
+ "bbox": [x1, y1, w, h],
529
+ "category_id": cls_id,
530
+ "id": ann_id,
531
+ "image_id": img_id,
532
+ "iscrowd": 0,
533
+ # mask
534
+ "segmentation": [],
535
+ }
536
+ )
537
+ ann_id += 1
538
+
539
+ with open(save_path, "w") as f:
540
+ json.dump(dataset, f)
541
+ LOGGER.info(
542
+ f"Convert to COCO format finished. Resutls saved in {save_path}"
543
+ )
544
+
545
+ @staticmethod
546
+ def get_hash(paths):
547
+ """Get the hash value of paths"""
548
+ assert isinstance(paths, list), "Only support list currently."
549
+ h = hashlib.md5("".join(paths).encode())
550
+ return h.hexdigest()