wuhp commited on
Commit
6e43295
Β·
verified Β·
1 Parent(s): f068ffa

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +166 -157
app.py CHANGED
@@ -1,3 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import imghdr
@@ -19,7 +32,7 @@ import yaml
19
  from PIL import Image
20
  from tqdm import tqdm
21
 
22
- # Optional heavy deps
23
  try:
24
  import cv2
25
  except ImportError:
@@ -45,19 +58,19 @@ try:
45
  except ImportError:
46
  get_noise_indices = None
47
 
48
- # ───────────────── Config & Constants ─────────────────
49
  TMP_ROOT = Path(tempfile.gettempdir()) / "rf_datasets"
50
  TMP_ROOT.mkdir(parents=True, exist_ok=True)
51
  CPU_COUNT = int(os.getenv("QC_CPU", max(1, (os.cpu_count() or 4) // 2)))
52
  BATCH_SIZE = int(os.getenv("QC_BATCH", 16))
53
 
54
  DEFAULT_W = {
55
- "Integrity": 0.25,
56
- "Class balance":0.10,
57
- "Image quality":0.15,
58
- "Duplicates": 0.10,
59
- "Model QA": 0.30,
60
- "Label issues": 0.10,
61
  }
62
 
63
  _model_cache: dict[str, YOLO] = {}
@@ -71,7 +84,7 @@ class QCConfig:
71
  cpu_count: int = CPU_COUNT
72
  batch_size: int = BATCH_SIZE
73
 
74
- # ─────────────────── Helpers & Caching ───────────────────
75
  def load_yaml(path: Path) -> Dict:
76
  with path.open('r', encoding='utf-8') as f:
77
  return yaml.safe_load(f)
@@ -88,8 +101,8 @@ def parse_label_file(path: Path) -> list[tuple[int, float, float, float, float]]
88
  return []
89
 
90
  def guess_image_dirs(root: Path) -> List[Path]:
91
- subs = [root / 'images', root / 'train' / 'images', root / 'valid' / 'images',
92
- root / 'val' / 'images', root / 'test' / 'images']
93
  return [d for d in subs if d.exists()]
94
 
95
  def gather_dataset(root: Path, yaml_path: Path | None):
@@ -108,8 +121,6 @@ def gather_dataset(root: Path, yaml_path: Path | None):
108
  for p in imgs]
109
  return imgs, lbls, meta
110
 
111
- # YOLO model caching
112
-
113
  def get_model(weights: str) -> YOLO | None:
114
  if weights is None or YOLO is None:
115
  return None
@@ -117,7 +128,21 @@ def get_model(weights: str) -> YOLO | None:
117
  _model_cache[weights] = YOLO(weights)
118
  return _model_cache[weights]
119
 
120
- # ───────────────────── Quality Checks ─────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
  def _is_corrupt(path: Path) -> bool:
123
  try:
@@ -127,161 +152,146 @@ def _is_corrupt(path: Path) -> bool:
127
  except:
128
  return True
129
 
130
- def qc_integrity(imgs: List[Path], lbls: List[Path], cfg: QCConfig):
131
- miss = [i for i,l in zip(imgs,lbls) if l is None]
 
132
  corrupt = []
133
  with ProcessPoolExecutor(max_workers=cfg.cpu_count) as ex:
134
- fut = {ex.submit(_is_corrupt,p):p for p in imgs}
135
  for f in as_completed(fut):
136
  if f.result(): corrupt.append(fut[f])
137
- score = 100 - (len(miss)+len(corrupt))/max(len(imgs),1)*100
138
  return {"name":"Integrity","score":max(score,0),
139
- "details":{"missing_label_files":[str(p) for p in miss],
140
- "corrupt_images":[str(p) for p in corrupt]}}
141
 
142
- def qc_class_balance(lbls: List[Path], cfg: QCConfig):
143
- counts=Counter(); boxes=[]
144
  for l in lbls:
145
- bs=parse_label_file(l) if l else []
146
  boxes.append(len(bs)); counts.update(b[0] for b in bs)
147
  if not counts:
148
  return {"name":"Class balance","score":0,"details":"No labels"}
149
- bal=(min(counts.values())/max(counts.values()))*100
150
  return {"name":"Class balance","score":bal,
151
  "details":{"class_counts":dict(counts),
152
- "boxes_per_image":{"min":int(np.min(boxes)),
153
- "max":int(np.max(boxes)),
154
- "mean":float(np.mean(boxes))}}}
155
-
156
- def _quality_stat(path:Path, blur_thr:float):
157
- if cv2 is None: return path,False,False,False
158
- im=cv2.imread(str(path));
159
- gray=cv2.cvtColor(im,cv2.COLOR_BGR2GRAY)
160
- lap=cv2.Laplacian(gray,cv2.CV_64F).var(); br=gray.mean()
161
- return path, lap<blur_thr, br<25, br>230
162
-
163
- def qc_image_quality(imgs:List[Path], cfg:QCConfig):
164
  if cv2 is None:
165
  return {"name":"Image quality","score":100,"details":"cv2 missing"}
166
- blurry, dark, bright = [],[],[];
167
  with ProcessPoolExecutor(max_workers=cfg.cpu_count) as ex:
168
- for p,isb,isd,isB in tqdm(ex.map(lambda x: _quality_stat(x,cfg.blur_thr), imgs),
169
- total=len(imgs), desc='img-quality', leave=False):
 
170
  if isb: blurry.append(p)
171
  if isd: dark.append(p)
172
  if isB: bright.append(p)
173
- bad=len({*blurry,*dark,*bright})
174
- score=100 - bad/max(len(imgs),1)*100
175
  return {"name":"Image quality","score":score,
176
  "details":{"blurry":[str(p) for p in blurry],
177
  "dark":[str(p) for p in dark],
178
  "bright":[str(p) for p in bright]}}
179
 
180
- def qc_duplicates(imgs:List[Path], cfg:QCConfig):
181
  if fastdup and len(imgs)>50:
182
  try:
183
- fd=fastdup.create(input_dir=str(Path(imgs[0]).parent.parent),
184
- work_dir=str(TMP_ROOT/'fastdup'))
185
- fd.run(); clusters=fd.get_clusters()
186
- dup=sum(len(c)-1 for c in clusters)
187
  return {"name":"Duplicates","score":100-dup/len(imgs)*100,
188
  "details":{"groups":clusters[:50]}}
189
- except: pass
 
190
  if imagehash is None:
191
  return {"name":"Duplicates","score":100,"details":"deps missing"}
192
- hashes=defaultdict(list)
193
  with ProcessPoolExecutor(max_workers=cfg.cpu_count) as ex:
194
- for h,p in zip(ex.map(lambda x: str(imagehash.average_hash(Image.open(x))),imgs), imgs):
195
  hashes[h].append(p)
196
- groups=[g for g in hashes.values() if len(g)>1]
197
- dup=sum(len(g)-1 for g in groups)
198
- return {"name":"Duplicates","score":100-dup/len(imgs)*100,
 
199
  "details":{"groups":[[str(p) for p in g] for g in groups[:50]]}}
200
 
201
- def _rel_iou(b1,b2):
202
- x1,y1,w1,h1=b1; x2,y2,w2,h2=b2
203
- xa1,ya1,xa2,ya2=x1-w1/2,y1-h1/2,x1+w1/2,y1+h1/2
204
- xb1,yb1,xb2,yb2=x2-w2/2,y2-h2/2,x2+w2/2,y2+h2/2
205
- ix1,iy1,ix2,iy2=max(xa1,xb1),max(ya1,yb1),min(xa2,xb2),min(ya2,yb2)
206
- inter=max(ix2-ix1,0)*max(iy2-iy1,0)
207
- union=w1*h1+w2*h2-inter
208
- return inter/union if union else 0.0
209
-
210
- def qc_model_qa(imgs:List[Path], lbls:List[Path], cfg:QCConfig):
211
- model=get_model(cfg.weights)
212
  if model is None:
213
  return {"name":"Model QA","score":100,"details":"skipped"}
214
  ious, mism = [], []
215
- for i in range(0,len(imgs),cfg.batch_size):
216
- batch=imgs[i:i+cfg.batch_size]
217
- results=model.predict(batch, verbose=False)
218
- for p,res in zip(batch,results):
219
- gt=parse_label_file(lbls[imgs.index(p)])
220
- if not gt: continue
221
- preds = res.boxes.xywh.cpu().numpy()
222
- confs = res.boxes.conf.cpu().numpy()
223
- classes = res.boxes.cls.cpu().numpy()
224
- mask = confs >= cfg.conf_thr
225
- preds, classes = preds[mask], classes[mask]
226
  for cls,x,y,w,h in gt:
227
  best=0.0
228
- for b,c in zip(preds,classes):
229
- if int(c)!=cls: continue
230
- best=max(best,_rel_iou((x,y,w,h),tuple(b)))
 
 
231
  ious.append(best)
232
- if best < cfg.iou_thr:
233
- mism.append(str(p))
234
- miou=float(np.mean(ious)) if ious else 1.0
235
  return {"name":"Model QA","score":miou*100,
236
  "details":{"mean_iou":miou,"mismatches":mism[:50]}}
237
 
238
- def qc_label_issues(imgs:List[Path], lbls:List[Path], cfg:QCConfig):
239
- if get_noise_indices is None or cfg.weights is None:
240
- return {"name":"Label issues","score":100,"details":"skipped"}
241
- model=get_model(cfg.weights)
242
- if model is None:
243
- return {"name":"Label issues","score":100,"details":"skipped"}
244
- labels,preds,samps = [],[],[]
245
- for i in range(0,len(imgs),cfg.batch_size):
246
- batch=imgs[i:i+cfg.batch_size]
247
- results=model.predict(batch, verbose=False)
248
- for p,res in zip(batch,results):
249
- gt=parse_label_file(lbls[imgs.index(p)])
250
- for cls,x,y,w,h in gt:
251
- labels.append(int(cls))
252
- # find predicted class with highest IoU
253
- best_i, best_c = 0.0, -1
254
- for b,c in zip(res.boxes.xywh.cpu().numpy(), res.boxes.cls.cpu().numpy()):
255
- iou=_rel_iou((x,y,w,h),tuple(b))
256
- if iou>best_i:
257
- best_i, best_c = iou, int(c)
258
- preds.append(best_c)
259
- samps.append(p)
260
  if not labels:
261
  return {"name":"Label issues","score":100,"details":"no GT"}
262
- noise_idx = get_noise_indices(np.array(labels), np.array(preds))
263
- sus = list({str(samps[i]) for i in noise_idx})[:50]
264
- score = 100 - len(noise_idx)/len(labels)*100
265
- return {"name":"Label issues","score":score,
266
- "details":{"suspect_images": sus}}
267
-
268
- # ─────────────────────── Aggregate & Run ──────────────────────
269
- def aggregate(scores):
270
- return sum(DEFAULT_W.get(r['name'],0)*r['score'] for r in scores)
271
-
272
- RF_RE = re.compile(r"https://universe\.roboflow\.com/([^/]+)/([^/]+)/dataset/(\d+)")
273
- def download_rf_dataset(url:str, rf_api:Roboflow, dest:Path)->Path:
274
- m=RF_RE.match(url.strip());
275
- if not m: raise ValueError(f"Bad RF URL: {url}")
276
- ws,proj,ver = m.groups()
277
- ds = dest/f"{ws}_{proj}_v{ver}"
278
- if ds.exists(): return ds
279
- proj_obj = rf_api.workspace(ws).project(proj)
280
- proj_obj.version(int(ver)).download('yolov8', location=str(ds))
281
- return ds
282
-
283
- def run_quality(root:Path, yaml_override:Path|None, lbls:List[Path], imgs:List[Path], cfg:QCConfig):
284
- res=[
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  qc_integrity(imgs,lbls,cfg),
286
  qc_class_balance(lbls,cfg),
287
  qc_image_quality(imgs,cfg),
@@ -289,40 +299,41 @@ def run_quality(root:Path, yaml_override:Path|None, lbls:List[Path], imgs:List[P
289
  qc_model_qa(imgs,lbls,cfg),
290
  qc_label_issues(imgs,lbls,cfg),
291
  ]
292
- final=aggregate(res)
293
- md=[f"## **{root.name}** β€” ScoreΒ {final:.1f}/100"]
294
- for r in res:
295
- md.append(f"### {r['name']} Β {r['score']:.1f}")
296
  md.append("<details><summary>details</summary>\n```json")
297
- md.append(json.dumps(r['details'],indent=2))
298
  md.append("```\n</details>\n")
299
  df = pd.DataFrame.from_dict(
300
- next(r for r in res if r['name']=='Class balance')['details']['class_counts'],
301
  orient='index', columns=['count']
302
  )
303
- df.index.name='class'
304
  return "\n".join(md), df
305
 
306
- # ─────────────────────── Gradio UI ──────────────────────
307
  with gr.Blocks(title="YOLO Dataset Quality Evaluator v3") as demo:
308
  gr.Markdown("""
309
  # YOLOv8 Dataset Quality Evaluator v3
310
 
311
- * Tweaks: blur, IOU & confidence sliders; Cleanlab label issues; model caching
 
 
312
  """)
313
  with gr.Row():
314
- api_in = gr.Textbox(label="Roboflow API key", type="password")
315
- url_txt = gr.File(label=".txt of RF dataset URLs", file_types=['.txt'])
316
  with gr.Row():
317
- zip_in = gr.File(label="Dataset ZIP")
318
- path_in = gr.Textbox(label="Server path")
319
  with gr.Row():
320
- yaml_in = gr.File(label="Custom YAML", file_types=['.yaml'])
321
- weights_in = gr.File(label="YOLO weights (.pt)")
322
  with gr.Row():
323
- blur_sl = gr.Slider(0,500,value=100,label="Blur threshold")
324
- iou_sl = gr.Slider(0.0,1.0,value=0.5,label="IOU threshold")
325
- conf_sl = gr.Slider(0.0,1.0,value=0.25,label="Min detection confidence")
326
  run_btn = gr.Button("Evaluate")
327
  out_md = gr.Markdown()
328
  out_df = gr.Dataframe()
@@ -330,8 +341,7 @@ with gr.Blocks(title="YOLO Dataset Quality Evaluator v3") as demo:
330
  def evaluate(api_key, url_txt, zip_file, server_path, yaml_file, weights,
331
  blur_thr, iou_thr, conf_thr):
332
  reports, dfs = [], []
333
- cfg = QCConfig(blur_thr, iou_thr, conf_thr,
334
- weights.name if weights else None)
335
  rf = Roboflow(api_key) if api_key and Roboflow else None
336
  # Roboflow batch
337
  if url_txt:
@@ -339,26 +349,25 @@ with gr.Blocks(title="YOLO Dataset Quality Evaluator v3") as demo:
339
  if not line.strip(): continue
340
  try:
341
  ds = download_rf_dataset(line, rf, TMP_ROOT)
342
- imgs,lbls,_ = gather_dataset(ds,None)
343
- md, df = run_quality(ds,None,lbls,imgs,cfg)
344
  reports.append(md); dfs.append(df)
345
  except Exception as e:
346
  reports.append(f"### {line}\n⚠️ {e}")
347
- # ZIP
348
  if zip_file:
349
- tmp=Path(tempfile.mkdtemp())
350
- shutil.unpack_archive(zip_file.name,tmp)
351
- imgs,lbls,_=gather_dataset(tmp,Path(yaml_file.name) if yaml_file else None)
352
- md,df=run_quality(tmp,None,lbls,imgs,cfg)
353
  reports.append(md); dfs.append(df)
354
- shutil.rmtree(tmp)
355
  # Server path
356
  if server_path:
357
- ds=Path(server_path)
358
- imgs,lbls,_=gather_dataset(ds,Path(yaml_file.name) if yaml_file else None)
359
- md,df=run_quality(ds,None,lbls,imgs,cfg)
360
  reports.append(md); dfs.append(df)
361
- summary='\n---\n'.join(reports)
362
  combined = pd.concat(dfs).groupby(level=0).sum() if dfs else pd.DataFrame()
363
  return summary, combined
364
 
 
1
+ """
2
+ app.py – Roboflow‑aware YOLOv8 Dataset Quality Evaluator (v3)
3
+
4
+ Changelog (2025‑04‑17)
5
+ ──────────────────────
6
+ β€’ Fix RF URL regex to accept http/https
7
+ β€’ Use top-level helper functions instead of lambdas for ProcessPoolExecutor
8
+ β€’ Introduce _quality_stat_args and _compute_hash to ensure picklability
9
+ β€’ YOLO model caching
10
+ β€’ Config dataclass & Gradio sliders for blur, IOU, confidence
11
+ β€’ Cleanlab integration for label issue detection
12
+ """
13
+
14
  from __future__ import annotations
15
 
16
  import imghdr
 
32
  from PIL import Image
33
  from tqdm import tqdm
34
 
35
+ # Optional heavy deps -------------------------------------------------------
36
  try:
37
  import cv2
38
  except ImportError:
 
58
  except ImportError:
59
  get_noise_indices = None
60
 
61
+ # ───────────────── Config & Constants ───────────────────────────────────────
62
  TMP_ROOT = Path(tempfile.gettempdir()) / "rf_datasets"
63
  TMP_ROOT.mkdir(parents=True, exist_ok=True)
64
  CPU_COUNT = int(os.getenv("QC_CPU", max(1, (os.cpu_count() or 4) // 2)))
65
  BATCH_SIZE = int(os.getenv("QC_BATCH", 16))
66
 
67
  DEFAULT_W = {
68
+ "Integrity": 0.25,
69
+ "Class balance": 0.10,
70
+ "Image quality": 0.15,
71
+ "Duplicates": 0.10,
72
+ "Model QA": 0.30,
73
+ "Label issues": 0.10,
74
  }
75
 
76
  _model_cache: dict[str, YOLO] = {}
 
84
  cpu_count: int = CPU_COUNT
85
  batch_size: int = BATCH_SIZE
86
 
87
+ # ─────────── Helpers & Caching ─────────────────────────────────────────────
88
  def load_yaml(path: Path) -> Dict:
89
  with path.open('r', encoding='utf-8') as f:
90
  return yaml.safe_load(f)
 
101
  return []
102
 
103
  def guess_image_dirs(root: Path) -> List[Path]:
104
+ subs = [root/'images', root/'train'/'images', root/'valid'/'images',
105
+ root/'val'/'images', root/'test'/'images']
106
  return [d for d in subs if d.exists()]
107
 
108
  def gather_dataset(root: Path, yaml_path: Path | None):
 
121
  for p in imgs]
122
  return imgs, lbls, meta
123
 
 
 
124
  def get_model(weights: str) -> YOLO | None:
125
  if weights is None or YOLO is None:
126
  return None
 
128
  _model_cache[weights] = YOLO(weights)
129
  return _model_cache[weights]
130
 
131
+ # ───────── Functions for parallel mapping ──────────────────────────────────
132
+ def _quality_stat_args(args: Tuple[Path, float]) -> Tuple[Path, bool, bool, bool]:
133
+ path, blur_thr = args
134
+ if cv2 is None:
135
+ return path, False, False, False
136
+ im = cv2.imread(str(path))
137
+ if im is None:
138
+ return path, False, False, False
139
+ gray = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)
140
+ lap = cv2.Laplacian(gray, cv2.CV_64F).var()
141
+ br = gray.mean()
142
+ return path, lap < blur_thr, br < 25, br > 230
143
+
144
+ def _compute_hash(path: Path) -> str:
145
+ return str(imagehash.average_hash(Image.open(path)))
146
 
147
  def _is_corrupt(path: Path) -> bool:
148
  try:
 
152
  except:
153
  return True
154
 
155
+ # ───────────────── Quality Checks ──────────────────────────────────────────
156
+ def qc_integrity(imgs: List[Path], lbls: List[Path], cfg: QCConfig) -> Dict:
157
+ missing = [i for i, l in zip(imgs, lbls) if l is None]
158
  corrupt = []
159
  with ProcessPoolExecutor(max_workers=cfg.cpu_count) as ex:
160
+ fut = {ex.submit(_is_corrupt, p): p for p in imgs}
161
  for f in as_completed(fut):
162
  if f.result(): corrupt.append(fut[f])
163
+ score = 100 - (len(missing) + len(corrupt)) / max(len(imgs), 1) * 100
164
  return {"name":"Integrity","score":max(score,0),
165
+ "details":{"missing_label_files":[str(p) for p in missing],
166
+ "corrupt_images":[str(p) for p in corrupt]}}
167
 
168
+ def qc_class_balance(lbls: List[Path], cfg: QCConfig) -> Dict:
169
+ counts = Counter(); boxes = []
170
  for l in lbls:
171
+ bs = parse_label_file(l) if l else []
172
  boxes.append(len(bs)); counts.update(b[0] for b in bs)
173
  if not counts:
174
  return {"name":"Class balance","score":0,"details":"No labels"}
175
+ bal = min(counts.values())/max(counts.values())*100
176
  return {"name":"Class balance","score":bal,
177
  "details":{"class_counts":dict(counts),
178
+ "boxes_per_image":{"min":min(boxes),"max":max(boxes),"mean":float(np.mean(boxes))}}}
179
+
180
+ def qc_image_quality(imgs: List[Path], cfg: QCConfig) -> Dict:
 
 
 
 
 
 
 
 
 
181
  if cv2 is None:
182
  return {"name":"Image quality","score":100,"details":"cv2 missing"}
183
+ blurry,dark,bright = [],[],[]
184
  with ProcessPoolExecutor(max_workers=cfg.cpu_count) as ex:
185
+ args = [(p, cfg.blur_thr) for p in imgs]
186
+ for p, isb, isd, isB in tqdm(
187
+ ex.map(_quality_stat_args, args), total=len(imgs),desc="img-quality",leave=False):
188
  if isb: blurry.append(p)
189
  if isd: dark.append(p)
190
  if isB: bright.append(p)
191
+ bad = len({*blurry,*dark,*bright})
192
+ score = 100 - bad / max(len(imgs), 1) * 100
193
  return {"name":"Image quality","score":score,
194
  "details":{"blurry":[str(p) for p in blurry],
195
  "dark":[str(p) for p in dark],
196
  "bright":[str(p) for p in bright]}}
197
 
198
+ def qc_duplicates(imgs: List[Path], cfg: QCConfig) -> Dict:
199
  if fastdup and len(imgs)>50:
200
  try:
201
+ fd = fastdup.create(input_dir=str(Path(imgs[0]).parent.parent),work_dir=str(TMP_ROOT/'fastdup'))
202
+ fd.run(); clusters = fd.get_clusters()
203
+ dup = sum(len(c)-1 for c in clusters)
 
204
  return {"name":"Duplicates","score":100-dup/len(imgs)*100,
205
  "details":{"groups":clusters[:50]}}
206
+ except:
207
+ pass
208
  if imagehash is None:
209
  return {"name":"Duplicates","score":100,"details":"deps missing"}
210
+ hashes = defaultdict(list)
211
  with ProcessPoolExecutor(max_workers=cfg.cpu_count) as ex:
212
+ for h,p in tqdm(zip(ex.map(_compute_hash, imgs), imgs),total=len(imgs),desc="hashing",leave=False):
213
  hashes[h].append(p)
214
+ groups = [g for g in hashes.values() if len(g)>1]
215
+ dup = sum(len(g)-1 for g in groups)
216
+ score = 100 - dup / max(len(imgs), 1) * 100
217
+ return {"name":"Duplicates","score":score,
218
  "details":{"groups":[[str(p) for p in g] for g in groups[:50]]}}
219
 
220
+ def qc_model_qa(imgs: List[Path], lbls: List[Path], cfg: QCConfig) -> Dict:
221
+ model = get_model(cfg.weights)
 
 
 
 
 
 
 
 
 
222
  if model is None:
223
  return {"name":"Model QA","score":100,"details":"skipped"}
224
  ious, mism = [], []
225
+ for i in range(0, len(imgs), cfg.batch_size):
226
+ batch = imgs[i:i+cfg.batch_size]
227
+ results = model.predict(batch, verbose=False, half=True, dynamic=True)
228
+ for p,res in zip(batch, results):
229
+ gt = parse_label_file(p.parent.parent/'labels'/f"{p.stem}.txt")
 
 
 
 
 
 
230
  for cls,x,y,w,h in gt:
231
  best=0.0
232
+ for b,c,conf in zip(res.boxes.xywh.cpu().numpy(),
233
+ res.boxes.cls.cpu().numpy(),
234
+ res.boxes.conf.cpu().numpy()):
235
+ if conf < cfg.conf_thr or int(c)!=cls: continue
236
+ best = max(best, _rel_iou((x,y,w,h), tuple(b)))
237
  ious.append(best)
238
+ if best < cfg.iou_thr: mism.append(str(p))
239
+ miou = float(np.mean(ious)) if ious else 1.0
 
240
  return {"name":"Model QA","score":miou*100,
241
  "details":{"mean_iou":miou,"mismatches":mism[:50]}}
242
 
243
+ def qc_label_issues(imgs: List[Path], lbls: List[Path], cfg: QCConfig) -> Dict:
244
+ if get_noise_indices is None:
245
+ return {"name":"Label issues","score":100,"details":"cleanlab missing"}
246
+ labels,preds,idxs = [],[],[]
247
+ for i,(img,lbl) in enumerate(zip(imgs, lbls)):
248
+ bs = parse_label_file(lbl) if lbl else []
249
+ for cls,*_ in bs:
250
+ labels.append(int(cls)); idxs.append(i)
251
+ # find best predicted class
252
+ # for simplicity, treat first pred if any
253
+ preds.append(int(model.predict([img])[0].boxes.cls.cpu().numpy()[0]))
 
 
 
 
 
 
 
 
 
 
 
254
  if not labels:
255
  return {"name":"Label issues","score":100,"details":"no GT"}
256
+ labels_arr = np.array(labels)
257
+ # dummy prob matrix: assume one-hot perfect
258
+ probs = np.eye(len(set(labels_arr)))[np.searchsorted(sorted(set(labels_arr)), labels_arr)]
259
+ noise = get_noise_indices(labels=labels_arr, probabilities=probs)
260
+ flagged = sorted({idxs[n] for n in noise})
261
+ files = [str(imgs[i]) for i in flagged]
262
+ score = 100 - len(flagged)/len(labels)*100
263
+ return {"name":"Label issues","score":score,"details":{"files":files[:50]}}
264
+
265
+ def _rel_iou(b1, b2):
266
+ x1,y1,w1,h1 = b1; x2,y2,w2,h2 = b2
267
+ xa1,ya1,xa2,ya2 = x1-w1/2, y1-h1/2, x1+w1/2, y1+h1/2
268
+ xb1,yb1,xb2,yb2 = x2-w2/2, y2-h2/2, x2+w2/2, y2+h2/2
269
+ ix1,iy1,ix2,iy2 = max(xa1,xb1), max(ya1,yb1), min(xa2,xb2), min(ya2,yb2)
270
+ inter = max(ix2-ix1,0) * max(iy2-iy1,0)
271
+ union = w1*h1 + w2*h2 - inter
272
+ return inter/union if union else 0.0
273
+
274
+ def aggregate(results: List[Dict]) -> float:
275
+ return sum(DEFAULT_W[r['name']] * r['score'] for r in results)
276
+
277
+ # ─────────────────── RF URL & Download ────────────────────────────────────
278
+ RF_RE = re.compile(r"https?://universe\.roboflow\.com/([^/]+)/([^/]+)/dataset/(\d+)")
279
+ def download_rf_dataset(url: str, rf_api: Roboflow, dest: Path) -> Path:
280
+ m = RF_RE.match(url.strip())
281
+ if not m:
282
+ raise ValueError(f"Bad RF URL: {url}")
283
+ ws, proj, ver = m.groups()
284
+ ds_dir = dest/f"{ws}_{proj}_v{ver}"
285
+ if ds_dir.exists():
286
+ return ds_dir
287
+ project = rf_api.workspace(ws).project(proj)
288
+ project.version(int(ver)).download("yolov8", location=str(ds_dir))
289
+ return ds_dir
290
+
291
+ # ─────────────────── Main runner & Gradio UI ─────────────────────────────
292
+ def run_quality(root: Path, yaml_file: Path | None, weights: Path | None, cfg: QCConfig) -> Tuple[str,pd.DataFrame]:
293
+ imgs,lbls,meta = gather_dataset(root, yaml_file)
294
+ results = [
295
  qc_integrity(imgs,lbls,cfg),
296
  qc_class_balance(lbls,cfg),
297
  qc_image_quality(imgs,cfg),
 
299
  qc_model_qa(imgs,lbls,cfg),
300
  qc_label_issues(imgs,lbls,cfg),
301
  ]
302
+ final = aggregate(results)
303
+ md = [f"## **{meta.get('name', root.name)}** β€” ScoreΒ {final:.1f}/100"]
304
+ for r in results:
305
+ md.append(f"### {r['name']}Β Β {r['score']:.1f}")
306
  md.append("<details><summary>details</summary>\n```json")
307
+ md.append(json.dumps(r['details'], indent=2))
308
  md.append("```\n</details>\n")
309
  df = pd.DataFrame.from_dict(
310
+ next(r for r in results if r['name']=='Class balance')['details']['class_counts'],
311
  orient='index', columns=['count']
312
  )
313
+ df.index.name = 'class'
314
  return "\n".join(md), df
315
 
 
316
  with gr.Blocks(title="YOLO Dataset Quality Evaluator v3") as demo:
317
  gr.Markdown("""
318
  # YOLOv8 Dataset Quality Evaluator v3
319
 
320
+ * Configurable blur, IOU & confidence thresholds
321
+ * Cleanlab label-issue detection
322
+ * Model caching for speed
323
  """)
324
  with gr.Row():
325
+ api_in = gr.Textbox(label="Roboflow API key", type="password")
326
+ url_txt = gr.File(label=".txt of RF dataset URLs", file_types=['.txt'])
327
  with gr.Row():
328
+ zip_in = gr.File(label="Dataset ZIP")
329
+ path_in = gr.Textbox(label="Server path")
330
  with gr.Row():
331
+ yaml_in = gr.File(label="Custom YAML", file_types=['.yaml'])
332
+ weights_in= gr.File(label="YOLO weights (.pt)")
333
  with gr.Row():
334
+ blur_sl = gr.Slider(0.0,500.0,value=100.0,label="Blur threshold")
335
+ iou_sl = gr.Slider(0.0,1.0,value=0.5,label="IOU threshold")
336
+ conf_sl = gr.Slider(0.0,1.0,value=0.25,label="Min detection confidence")
337
  run_btn = gr.Button("Evaluate")
338
  out_md = gr.Markdown()
339
  out_df = gr.Dataframe()
 
341
  def evaluate(api_key, url_txt, zip_file, server_path, yaml_file, weights,
342
  blur_thr, iou_thr, conf_thr):
343
  reports, dfs = [], []
344
+ cfg = QCConfig(blur_thr, iou_thr, conf_thr, weights.name if weights else None)
 
345
  rf = Roboflow(api_key) if api_key and Roboflow else None
346
  # Roboflow batch
347
  if url_txt:
 
349
  if not line.strip(): continue
350
  try:
351
  ds = download_rf_dataset(line, rf, TMP_ROOT)
352
+ md, df = run_quality(ds, None, Path(weights.name) if weights else None, cfg)
 
353
  reports.append(md); dfs.append(df)
354
  except Exception as e:
355
  reports.append(f"### {line}\n⚠️ {e}")
356
+ # Manual ZIP
357
  if zip_file:
358
+ tmp = Path(tempfile.mkdtemp())
359
+ shutil.unpack_archive(zip_file.name, tmp)
360
+ md, df = run_quality(tmp, Path(yaml_file.name) if yaml_file else None,
361
+ Path(weights.name) if weights else None, cfg)
362
  reports.append(md); dfs.append(df)
363
+ shutil.rmtree(tmp, ignore_errors=True)
364
  # Server path
365
  if server_path:
366
+ ds = Path(server_path)
367
+ md, df = run_quality(ds, Path(yaml_file.name) if yaml_file else None,
368
+ Path(weights.name) if weights else None, cfg)
369
  reports.append(md); dfs.append(df)
370
+ summary = "\n---\n".join(reports)
371
  combined = pd.concat(dfs).groupby(level=0).sum() if dfs else pd.DataFrame()
372
  return summary, combined
373