wuhp commited on
Commit
ea2cc6e
Β·
verified Β·
1 Parent(s): 72f83c1

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +175 -124
app.py CHANGED
@@ -6,7 +6,7 @@ import os
6
  import re
7
  import shutil
8
  import tempfile
9
- from collections import Counter, defaultdict
10
  from concurrent.futures import ThreadPoolExecutor, as_completed
11
  from dataclasses import dataclass
12
  from pathlib import Path
@@ -47,9 +47,9 @@ except ImportError:
47
  # ───────────────── Config & Constants ───────────────────────────────────────
48
  TMP_ROOT = Path(tempfile.gettempdir()) / "rf_datasets"
49
  TMP_ROOT.mkdir(parents=True, exist_ok=True)
50
- CPU_COUNT = int(os.getenv("QC_CPU", 1)) # force single-core by default
51
- BATCH_SIZE = int(os.getenv("QC_BATCH", 4)) # small batches
52
- SAMPLE_LIMIT = int(os.getenv("QC_SAMPLE", 200))
53
 
54
  DEFAULT_W = {
55
  "Integrity": 0.25,
@@ -64,13 +64,13 @@ _model_cache: dict[str, YOLO] = {}
64
 
65
  @dataclass
66
  class QCConfig:
67
- blur_thr: float
68
- iou_thr: float
69
- conf_thr: float
70
- weights: str | None
71
- cpu_count: int = CPU_COUNT
72
- batch_size: int = BATCH_SIZE
73
- sample_limit: int = SAMPLE_LIMIT
74
 
75
  # ─────────── Helpers & Caching ─────────────────────────────────────────────
76
  def load_yaml(path: Path) -> Dict:
@@ -89,8 +89,13 @@ def parse_label_file(path: Path) -> list[tuple[int, float, float, float, float]]
89
  return []
90
 
91
  def guess_image_dirs(root: Path) -> List[Path]:
92
- candidates = [root/'images', root/'train'/'images', root/'valid'/'images',
93
- root/'val'/'images', root/'test'/'images']
 
 
 
 
 
94
  return [d for d in candidates if d.exists()]
95
 
96
  def gather_dataset(root: Path, yaml_path: Path | None):
@@ -105,8 +110,10 @@ def gather_dataset(root: Path, yaml_path: Path | None):
105
  raise FileNotFoundError("images/ directory missing")
106
  imgs = [p for d in img_dirs for p in d.rglob('*.*') if imghdr.what(p)]
107
  labels_roots = {d.parent/'labels' for d in img_dirs}
108
- lbls = [next((lr/f"{p.stem}.txt" for lr in labels_roots if (lr/f"{p.stem}.txt").exists()), None)
109
- for p in imgs]
 
 
110
  return imgs, lbls, meta
111
 
112
  def get_model(weights: str) -> YOLO | None:
@@ -139,31 +146,45 @@ def _is_corrupt(path: Path) -> bool:
139
 
140
  # ───────────────── Quality Checks ──────────────────────────────────────────
141
  def qc_integrity(imgs: List[Path], lbls: List[Path], cfg: QCConfig) -> Dict:
142
- missing = [i for i,l in zip(imgs, lbls) if l is None]
143
  corrupt = []
 
144
  with ThreadPoolExecutor(max_workers=cfg.cpu_count) as ex:
145
- fut = {ex.submit(_is_corrupt, p): p for p in imgs[:cfg.sample_limit]}
146
  for f in as_completed(fut):
147
  if f.result():
148
  corrupt.append(fut[f])
149
- score = 100 - (len(missing)+len(corrupt)) / max(len(imgs), 1) * 100
150
- return {"name":"Integrity","score":max(score, 0),
151
- "details":{"missing_label_files":[str(p) for p in missing],
152
- "corrupt_images":[str(p) for p in corrupt]}}
 
 
 
 
 
153
 
154
  def qc_class_balance(lbls: List[Path], cfg: QCConfig) -> Dict:
155
  counts, boxes = Counter(), []
156
  for l in lbls[:cfg.sample_limit]:
157
  bs = parse_label_file(l) if l else []
158
- boxes.append(len(bs)); counts.update(b[0] for b in bs)
 
159
  if not counts:
160
  return {"name":"Class balance","score":0,"details":"No labels"}
161
  bal = min(counts.values()) / max(counts.values()) * 100
162
- return {"name":"Class balance","score":bal,
163
- "details":{"class_counts":dict(counts),
164
- "boxes_per_image":{
165
- "min":min(boxes),"max":max(boxes),
166
- "mean":float(np.mean(boxes))}}}
 
 
 
 
 
 
 
167
 
168
  def qc_image_quality(imgs: List[Path], cfg: QCConfig) -> Dict:
169
  if cv2 is None:
@@ -178,10 +199,15 @@ def qc_image_quality(imgs: List[Path], cfg: QCConfig) -> Dict:
178
  if isB: bright.append(p)
179
  bad = len({*blurry, *dark, *bright})
180
  score = 100 - bad / max(len(sample), 1) * 100
181
- return {"name":"Image quality","score":score,
182
- "details":{"blurry":[str(p) for p in blurry],
183
- "dark":[str(p) for p in dark],
184
- "bright":[str(p) for p in bright]}}
 
 
 
 
 
185
 
186
  def qc_duplicates(imgs: List[Path], cfg: QCConfig) -> Dict:
187
  if fastdup is not None and len(imgs) > 50:
@@ -193,13 +219,14 @@ def qc_duplicates(imgs: List[Path], cfg: QCConfig) -> Dict:
193
  fd.run()
194
  clusters = fd.get_clusters()
195
  dup = sum(len(c)-1 for c in clusters)
196
- return {"name":"Duplicates","score":100-dup/len(imgs)*100,
197
- "details":{"groups":clusters[:50]}}
 
 
 
198
  except Exception as e:
199
- return {"name":"Duplicates","score":100,
200
- "details":{"fastdup_error":str(e)}}
201
- return {"name":"Duplicates","score":100,
202
- "details":{"note":"fastdup not available or small dataset"}}
203
 
204
  def qc_model_qa(imgs: List[Path], lbls: List[Path], cfg: QCConfig) -> Dict:
205
  model = get_model(cfg.weights)
@@ -211,57 +238,67 @@ def qc_model_qa(imgs: List[Path], lbls: List[Path], cfg: QCConfig) -> Dict:
211
  batch = sample[i:i+cfg.batch_size]
212
  results = model.predict(batch, verbose=False, half=True, dynamic=True)
213
  for p, res in zip(batch, results):
214
- gt = parse_label_file(p.parent.parent/'labels'/f"{p.stem}.txt")
215
- for cls,x,y,w,h in gt:
216
  best = 0.0
217
- for b, c, conf in zip(res.boxes.xywh.cpu().numpy(),
218
- res.boxes.cls.cpu().numpy(),
219
- res.boxes.conf.cpu().numpy()):
 
 
220
  if conf < cfg.conf_thr or int(c) != cls:
221
  continue
222
- best = max(best, _rel_iou((x,y,w,h), tuple(b)))
223
  ious.append(best)
224
  if best < cfg.iou_thr:
225
  mism.append(str(p))
226
  miou = float(np.mean(ious)) if ious else 1.0
227
- return {"name":"Model QA","score":miou*100,
228
- "details":{"mean_iou":miou,"mismatches":mism[:50]}}
 
 
 
229
 
230
  def qc_label_issues(imgs: List[Path], lbls: List[Path], cfg: QCConfig) -> Dict:
231
  if get_noise_indices is None:
232
- return {"name":"Label issues","score":100,"details":"cleanlab missing"}
233
- labels, preds, idxs = [], [], []
234
- model = get_model(cfg.weights)
235
  sample = imgs[:cfg.sample_limit]
236
- for i, (img, lbl) in enumerate(zip(sample, lbls[:cfg.sample_limit])):
237
- bs = parse_label_file(lbl) if lbl else []
 
238
  for cls, *_ in bs:
239
- labels.append(int(cls)); idxs.append(i)
240
- res = model.predict([img], verbose=False)[0]
241
- pred_cls = int(res.boxes.cls.cpu().numpy()[0]) if len(res.boxes)>0 else -1
242
- preds.append(pred_cls)
243
  if not labels:
244
  return {"name":"Label issues","score":100,"details":"no GT"}
245
  labels_arr = np.array(labels)
246
- uniq = sorted(set(labels_arr))
247
- probs = np.eye(len(uniq))[np.searchsorted(uniq, labels_arr)]
248
- noise = get_noise_indices(labels=labels_arr, probabilities=probs)
249
- flags = sorted({idxs[n] for n in noise})
250
- files = [str(sample[i]) for i in flags]
251
- score = 100 - len(flags)/len(labels)*100
252
- return {"name":"Label issues","score":score,
253
- "details":{"files":files[:50]}}
 
 
 
254
 
255
  def _rel_iou(b1, b2):
256
- x1,y1,w1,h1 = b1; x2,y2,w2,h2 = b2
257
- xa1,ya1,xa2,ya2 = x1-w1/2, y1-h1/2, x1+w1/2, y1+h1/2
258
- xb1,yb1,xb2,yb2 = x2-w2/2, y2-h2/2, x2+w2/2, y2+h2/2
259
- ix1,iy1,ix2,iy2 = max(xa1,xb1), max(ya1,yb1), min(xa2,xb2), min(ya2,yb2)
260
- inter = max(ix2-ix1,0)*max(iy2-iy1,0)
 
 
 
 
261
  union = w1*h1 + w2*h2 - inter
262
  return inter/union if union else 0.0
263
 
264
- def aggregate(results: List[Drawable]) -> float:
265
  return sum(DEFAULT_W[r['name']]*r['score'] for r in results)
266
 
267
  RF_RE = re.compile(r"https?://universe\.roboflow\.com/([^/]+)/([^/]+)/dataset/(\d+)")
@@ -272,31 +309,29 @@ def download_rf_dataset(url: str, rf_api: Roboflow, dest: Path) -> Path:
272
  raise ValueError(f"Bad RF URL: {url}")
273
  ws, proj, ver = m.groups()
274
  ds_dir = dest/f"{ws}_{proj}_v{ver}"
275
- if ds_dir.exists(): return ds_dir
 
276
  pr = rf_api.workspace(ws).project(proj)
277
  pr.version(int(ver)).download("yolov8", location=str(ds_dir))
278
  return ds_dir
279
 
280
- def run_quality(root: Path, yaml_file: Path | None, weights: Path | None, cfg: QCConfig,
281
- run_dup: bool, run_modelqa: bool) -> Tuple[str, pd.DataFrame]:
 
 
 
 
 
 
282
  imgs, lbls, meta = gather_dataset(root, yaml_file)
283
  results = [
284
  qc_integrity(imgs, lbls, cfg),
285
  qc_class_balance(lbls, cfg),
286
- qc_image_quality(imgs, cfg)
 
 
 
287
  ]
288
- # conditional duplicates
289
- if run_dup:
290
- results.append(qc_duplicates(imgs, cfg))
291
- else:
292
- results.append({"name":"Duplicates","score":100,"details":"skipped"})
293
- # conditional model QA & label issues
294
- if run_modelqa:
295
- results.append(qc_model_qa(imgs, lbls, cfg))
296
- results.append(qc_label_issues(imgs, lbls, cfg))
297
- else:
298
- results.append({"name":"Model QA","score":100,"details":"skipped"})
299
- results.append({"name":"Label issues","score":100,"details":"skipped"})
300
  final = aggregate(results)
301
 
302
  md = [f"## **{meta.get('name', root.name)}** β€” Score {final:.1f}/100"]
@@ -317,48 +352,56 @@ 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
- * Optional duplicates (fastdup)
322
- * Optional Model QA & cleanlab label-issue detection
323
- * Model caching for speed
324
  """)
325
  with gr.Row():
326
- api_in = gr.Textbox(label="Roboflow API key", type="password")
327
- url_txt = gr.File(label=".txt of RF dataset URLs", file_types=['.txt'])
328
  with gr.Row():
329
- zip_in = gr.File(label="Dataset ZIP")
330
- path_in = gr.Textbox(label="Server path")
331
  with gr.Row():
332
- yaml_in = gr.File(label="Custom YAML", file_types=['.yaml'])
333
- weights_in= gr.File(label="YOLO weights (.pt)")
334
  with gr.Row():
335
- blur_sl = gr.Slider(0.0, 500.0, value=100.0, label="Blur threshold")
336
- iou_sl = gr.Slider(0.0, 1.0, value=0.5, label="IOU threshold")
337
- conf_sl = gr.Slider(0.0, 1.0, value=0.25, label="Min detection confidence")
338
  with gr.Row():
339
  run_dup = gr.Checkbox(label="Check duplicates (fastdup)", value=False)
340
- run_modelqa = gr.Checkbox(label="Run Model QA & cleanlab", value=False)
341
  run_btn = gr.Button("Evaluate")
342
- out_md = gr.Markdown()
343
- out_df = gr.Dataframe()
344
 
345
- def evaluate(api_key, url_txt, zip_file, server_path, yaml_file, weights,
346
- blur_thr, iou_thr, conf_thr, run_dup, run_modelqa):
 
 
347
  reports, dfs = [], []
348
- cfg = QCConfig(blur_thr, iou_thr, conf_thr,
349
- weights.name if weights else None)
 
 
350
  rf = Roboflow(api_key) if api_key and Roboflow else None
351
 
352
  # Roboflow URLs
353
  if url_txt:
354
  for line in Path(url_txt.name).read_text().splitlines():
355
- if not line.strip(): continue
 
356
  try:
357
  ds = download_rf_dataset(line, rf, TMP_ROOT)
358
- md, df = run_quality(ds, None,
359
- Path(weights.name) if weights else None,
360
- cfg, run_dup, run_modelqa)
361
- reports.append(md); dfs.append(df)
 
 
 
362
  except Exception as e:
363
  reports.append(f"### {line}\n⚠️ {e}")
364
 
@@ -366,30 +409,38 @@ with gr.Blocks(title="YOLO Dataset Quality Evaluator v3") as demo:
366
  if zip_file:
367
  tmp = Path(tempfile.mkdtemp())
368
  shutil.unpack_archive(zip_file.name, tmp)
369
- md, df = run_quality(tmp,
370
- Path(yaml_file.name) if yaml_file else None,
371
- Path(weights.name) if weights else None,
372
- cfg, run_dup, run_modelqa)
373
- reports.append(md); dfs.append(df)
 
 
 
374
  shutil.rmtree(tmp, ignore_errors=True)
375
 
376
  # Server path
377
  if server_path:
378
  ds = Path(server_path)
379
- md, df = run_quality(ds,
380
- Path(yaml_file.name) if yaml_file else None,
381
- Path(weights.name) if weights else None,
382
- cfg, run_dup, run_modelqa)
383
- reports.append(md); dfs.append(df)
 
 
 
384
 
385
- summary = "\n---\n".join(reports)
386
  combined = pd.concat(dfs).groupby(level=0).sum() if dfs else pd.DataFrame()
387
  return summary, combined
388
 
389
- run_btn.click(evaluate,
390
- inputs=[api_in, url_txt, zip_in, Path, yaml_in, weights_in,
391
- blur_sl, iou_sl, conf_sl, run_dup, run_modelqa],
392
- outputs=[out_md, out_df])
 
 
393
 
394
  if __name__ == '__main__':
395
  demo.launch(server_name='0.0.0.0', server_port=int(os.getenv('PORT', 7860)))
 
6
  import re
7
  import shutil
8
  import tempfile
9
+ from collections import Counter
10
  from concurrent.futures import ThreadPoolExecutor, as_completed
11
  from dataclasses import dataclass
12
  from pathlib import Path
 
47
  # ───────────────── Config & Constants ───────────────────────────────────────
48
  TMP_ROOT = Path(tempfile.gettempdir()) / "rf_datasets"
49
  TMP_ROOT.mkdir(parents=True, exist_ok=True)
50
+ CPU_COUNT = int(os.getenv("QC_CPU", 1)) # force single-core by default
51
+ BATCH_SIZE = int(os.getenv("QC_BATCH", 4)) # small batches
52
+ SAMPLE_LIMIT = int(os.getenv("QC_SAMPLE", 200))
53
 
54
  DEFAULT_W = {
55
  "Integrity": 0.25,
 
64
 
65
  @dataclass
66
  class QCConfig:
67
+ blur_thr: float
68
+ iou_thr: float
69
+ conf_thr: float
70
+ weights: str | None
71
+ cpu_count: int = CPU_COUNT
72
+ batch_size: int = BATCH_SIZE
73
+ sample_limit:int = SAMPLE_LIMIT
74
 
75
  # ─────────── Helpers & Caching ─────────────────────────────────────────────
76
  def load_yaml(path: Path) -> Dict:
 
89
  return []
90
 
91
  def guess_image_dirs(root: Path) -> List[Path]:
92
+ candidates = [
93
+ root/'images',
94
+ root/'train'/'images',
95
+ root/'valid'/'images',
96
+ root/'val' /'images',
97
+ root/'test' /'images',
98
+ ]
99
  return [d for d in candidates if d.exists()]
100
 
101
  def gather_dataset(root: Path, yaml_path: Path | None):
 
110
  raise FileNotFoundError("images/ directory missing")
111
  imgs = [p for d in img_dirs for p in d.rglob('*.*') if imghdr.what(p)]
112
  labels_roots = {d.parent/'labels' for d in img_dirs}
113
+ lbls = [
114
+ next((lr/f"{p.stem}.txt" for lr in labels_roots if (lr/f"{p.stem}.txt").exists()), None)
115
+ for p in imgs
116
+ ]
117
  return imgs, lbls, meta
118
 
119
  def get_model(weights: str) -> YOLO | None:
 
146
 
147
  # ───────────────── Quality Checks ──────────────────────────────────────────
148
  def qc_integrity(imgs: List[Path], lbls: List[Path], cfg: QCConfig) -> Dict:
149
+ missing = [i for i, l in zip(imgs, lbls) if l is None]
150
  corrupt = []
151
+ sample = imgs[:cfg.sample_limit]
152
  with ThreadPoolExecutor(max_workers=cfg.cpu_count) as ex:
153
+ fut = {ex.submit(_is_corrupt, p): p for p in sample}
154
  for f in as_completed(fut):
155
  if f.result():
156
  corrupt.append(fut[f])
157
+ score = 100 - (len(missing) + len(corrupt)) / max(len(imgs), 1) * 100
158
+ return {
159
+ "name": "Integrity",
160
+ "score": max(score, 0),
161
+ "details": {
162
+ "missing_label_files": [str(p) for p in missing],
163
+ "corrupt_images": [str(p) for p in corrupt],
164
+ }
165
+ }
166
 
167
  def qc_class_balance(lbls: List[Path], cfg: QCConfig) -> Dict:
168
  counts, boxes = Counter(), []
169
  for l in lbls[:cfg.sample_limit]:
170
  bs = parse_label_file(l) if l else []
171
+ boxes.append(len(bs))
172
+ 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 {
177
+ "name":"Class balance",
178
+ "score":bal,
179
+ "details":{
180
+ "class_counts": dict(counts),
181
+ "boxes_per_image": {
182
+ "min": min(boxes),
183
+ "max": max(boxes),
184
+ "mean": float(np.mean(boxes))
185
+ }
186
+ }
187
+ }
188
 
189
  def qc_image_quality(imgs: List[Path], cfg: QCConfig) -> Dict:
190
  if cv2 is None:
 
199
  if isB: bright.append(p)
200
  bad = len({*blurry, *dark, *bright})
201
  score = 100 - bad / max(len(sample), 1) * 100
202
+ return {
203
+ "name":"Image quality",
204
+ "score":score,
205
+ "details":{
206
+ "blurry": [str(p) for p in blurry],
207
+ "dark": [str(p) for p in dark],
208
+ "bright": [str(p) for p in bright]
209
+ }
210
+ }
211
 
212
  def qc_duplicates(imgs: List[Path], cfg: QCConfig) -> Dict:
213
  if fastdup is not None and len(imgs) > 50:
 
219
  fd.run()
220
  clusters = fd.get_clusters()
221
  dup = sum(len(c)-1 for c in clusters)
222
+ return {
223
+ "name":"Duplicates",
224
+ "score":100-dup/len(imgs)*100,
225
+ "details":{"groups":clusters[:50]}
226
+ }
227
  except Exception as e:
228
+ return {"name":"Duplicates","score":100,"details":{"fastdup_error":str(e)}}
229
+ return {"name":"Duplicates","score":100,"details":{"note":"skipped"}}
 
 
230
 
231
  def qc_model_qa(imgs: List[Path], lbls: List[Path], cfg: QCConfig) -> Dict:
232
  model = get_model(cfg.weights)
 
238
  batch = sample[i:i+cfg.batch_size]
239
  results = model.predict(batch, verbose=False, half=True, dynamic=True)
240
  for p, res in zip(batch, results):
241
+ gt = parse_label_file(Path(p).parent.parent/'labels'/f"{Path(p).stem}.txt")
242
+ for cls, x, y, w, h in gt:
243
  best = 0.0
244
+ for b, c, conf in zip(
245
+ res.boxes.xywh.cpu().numpy(),
246
+ res.boxes.cls.cpu().numpy(),
247
+ res.boxes.conf.cpu().numpy()
248
+ ):
249
  if conf < cfg.conf_thr or int(c) != cls:
250
  continue
251
+ best = max(best, _rel_iou((x, y, w, h), tuple(b)))
252
  ious.append(best)
253
  if best < cfg.iou_thr:
254
  mism.append(str(p))
255
  miou = float(np.mean(ious)) if ious else 1.0
256
+ return {
257
+ "name":"Model QA",
258
+ "score":miou*100,
259
+ "details":{"mean_iou":miou, "mismatches":mism[:50]}
260
+ }
261
 
262
  def qc_label_issues(imgs: List[Path], lbls: List[Path], cfg: QCConfig) -> Dict:
263
  if get_noise_indices is None:
264
+ return {"name":"Label issues","score":100,"details":"skipped"}
265
+ labels, idxs = [], []
 
266
  sample = imgs[:cfg.sample_limit]
267
+ model = get_model(cfg.weights)
268
+ for i, p in enumerate(sample):
269
+ bs = parse_label_file(lbls[i]) if lbls[i] else []
270
  for cls, *_ in bs:
271
+ labels.append(int(cls))
272
+ idxs.append(i)
 
 
273
  if not labels:
274
  return {"name":"Label issues","score":100,"details":"no GT"}
275
  labels_arr = np.array(labels)
276
+ uniq = sorted(set(labels_arr))
277
+ probs = np.eye(len(uniq))[np.searchsorted(uniq, labels_arr)]
278
+ noise = get_noise_indices(labels=labels_arr, probabilities=probs)
279
+ flags = sorted({idxs[n] for n in noise})
280
+ files = [str(sample[i]) for i in flags]
281
+ score = 100 - len(flags)/len(labels)*100
282
+ return {
283
+ "name":"Label issues",
284
+ "score":score,
285
+ "details":{"files":files[:50]}
286
+ }
287
 
288
  def _rel_iou(b1, b2):
289
+ x1, y1, w1, h1 = b1
290
+ x2, y2, w2, h2 = b2
291
+ xa1, ya1 = x1-w1/2, y1-h1/2
292
+ xa2, ya2 = x1+w1/2, y1+h1/2
293
+ xb1, yb1 = x2-w2/2, y2-h2/2
294
+ xb2, yb2 = x2+w2/2, y2+h2/2
295
+ ix1 = max(xa1, xb1); iy1 = max(ya1, yb1)
296
+ ix2 = min(xa2, xb2); iy2 = min(ya2, yb2)
297
+ inter = max(ix2-ix1, 0) * max(iy2-iy1, 0)
298
  union = w1*h1 + w2*h2 - inter
299
  return inter/union if union else 0.0
300
 
301
+ def aggregate(results: List[Dict]) -> float:
302
  return sum(DEFAULT_W[r['name']]*r['score'] for r in results)
303
 
304
  RF_RE = re.compile(r"https?://universe\.roboflow\.com/([^/]+)/([^/]+)/dataset/(\d+)")
 
309
  raise ValueError(f"Bad RF URL: {url}")
310
  ws, proj, ver = m.groups()
311
  ds_dir = dest/f"{ws}_{proj}_v{ver}"
312
+ if ds_dir.exists():
313
+ return ds_dir
314
  pr = rf_api.workspace(ws).project(proj)
315
  pr.version(int(ver)).download("yolov8", location=str(ds_dir))
316
  return ds_dir
317
 
318
+ def run_quality(
319
+ root: Path,
320
+ yaml_file: Path | None,
321
+ weights: Path | None,
322
+ cfg: QCConfig,
323
+ run_dup: bool,
324
+ run_modelqa: bool
325
+ ) -> Tuple[str, pd.DataFrame]:
326
  imgs, lbls, meta = gather_dataset(root, yaml_file)
327
  results = [
328
  qc_integrity(imgs, lbls, cfg),
329
  qc_class_balance(lbls, cfg),
330
+ qc_image_quality(imgs, cfg),
331
+ qc_duplicates(imgs, cfg) if run_dup else {"name":"Duplicates","score":100,"details":"skipped"},
332
+ qc_model_qa(imgs, lbls, cfg) if run_modelqa else {"name":"Model QA","score":100,"details":"skipped"},
333
+ qc_label_issues(imgs, lbls, cfg) if run_modelqa else {"name":"Label issues","score":100,"details":"skipped"},
334
  ]
 
 
 
 
 
 
 
 
 
 
 
 
335
  final = aggregate(results)
336
 
337
  md = [f"## **{meta.get('name', root.name)}** β€” Score {final:.1f}/100"]
 
352
  gr.Markdown("""
353
  # YOLOv8 Dataset Quality Evaluator v3
354
 
355
+ * Configurable blur, IOU & confidence thresholds
356
+ * Optional duplicates (fastdup)
357
+ * Optional Model QA & cleanlab label-issue detection
358
+ * Model caching for speed
359
  """)
360
  with gr.Row():
361
+ api_in = gr.Textbox(label="Roboflow API key", type="password")
362
+ url_txt = gr.File(label=".txt of RF dataset URLs", file_types=['.txt'])
363
  with gr.Row():
364
+ zip_in = gr.File(label="Dataset ZIP")
365
+ path_in = gr.Textbox(label="Server path")
366
  with gr.Row():
367
+ yaml_in = gr.File(label="Custom YAML", file_types=['.yaml'])
368
+ weights_in = gr.File(label="YOLO weights (.pt)")
369
  with gr.Row():
370
+ blur_sl = gr.Slider(0.0, 500.0, value=100.0, label="Blur threshold")
371
+ iou_sl = gr.Slider(0.0, 1.0, value=0.5, label="IOU threshold")
372
+ conf_sl = gr.Slider(0.0, 1.0, value=0.25, label="Min detection confidence")
373
  with gr.Row():
374
  run_dup = gr.Checkbox(label="Check duplicates (fastdup)", value=False)
375
+ run_modelqa = gr.Checkbox(label="Run Model QA & cleanlab", value=False)
376
  run_btn = gr.Button("Evaluate")
377
+ out_md = gr.Markdown()
378
+ out_df = gr.Dataframe()
379
 
380
+ def evaluate(
381
+ api_key, url_txt, zip_file, server_path, yaml_file, weights,
382
+ blur_thr, iou_thr, conf_thr, run_dup, run_modelqa
383
+ ):
384
  reports, dfs = [], []
385
+ cfg = QCConfig(
386
+ blur_thr, iou_thr, conf_thr,
387
+ weights.name if weights else None
388
+ )
389
  rf = Roboflow(api_key) if api_key and Roboflow else None
390
 
391
  # Roboflow URLs
392
  if url_txt:
393
  for line in Path(url_txt.name).read_text().splitlines():
394
+ if not line.strip():
395
+ continue
396
  try:
397
  ds = download_rf_dataset(line, rf, TMP_ROOT)
398
+ md, df = run_quality(
399
+ ds, None,
400
+ Path(weights.name) if weights else None,
401
+ cfg, run_dup, run_modelqa
402
+ )
403
+ reports.append(md)
404
+ dfs.append(df)
405
  except Exception as e:
406
  reports.append(f"### {line}\n⚠️ {e}")
407
 
 
409
  if zip_file:
410
  tmp = Path(tempfile.mkdtemp())
411
  shutil.unpack_archive(zip_file.name, tmp)
412
+ md, df = run_quality(
413
+ tmp,
414
+ Path(yaml_file.name) if yaml_file else None,
415
+ Path(weights.name) if weights else None,
416
+ cfg, run_dup, run_modelqa
417
+ )
418
+ reports.append(md)
419
+ dfs.append(df)
420
  shutil.rmtree(tmp, ignore_errors=True)
421
 
422
  # Server path
423
  if server_path:
424
  ds = Path(server_path)
425
+ md, df = run_quality(
426
+ ds,
427
+ Path(yaml_file.name) if yaml_file else None,
428
+ Path(weights.name) if weights else None,
429
+ cfg, run_dup, run_modelqa
430
+ )
431
+ reports.append(md)
432
+ dfs.append(df)
433
 
434
+ summary = "\n---\n".join(reports)
435
  combined = pd.concat(dfs).groupby(level=0).sum() if dfs else pd.DataFrame()
436
  return summary, combined
437
 
438
+ run_btn.click(
439
+ evaluate,
440
+ inputs=[api_in, url_txt, zip_in, path_in, yaml_in, weights_in,
441
+ blur_sl, iou_sl, conf_sl, run_dup, run_modelqa],
442
+ outputs=[out_md, out_df]
443
+ )
444
 
445
  if __name__ == '__main__':
446
  demo.launch(server_name='0.0.0.0', server_port=int(os.getenv('PORT', 7860)))