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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +102 -85
app.py CHANGED
@@ -1,16 +1,3 @@
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
- β€’ Top-level functions for parallel mapping (picklable)
8
- β€’ Fastdup-only path in qc_duplicates (skips hashing fallback)
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
@@ -20,7 +7,7 @@ import re
20
  import shutil
21
  import tempfile
22
  from collections import Counter, defaultdict
23
- from concurrent.futures import ProcessPoolExecutor, as_completed
24
  from dataclasses import dataclass
25
  from pathlib import Path
26
  from typing import Dict, List, Tuple
@@ -30,7 +17,6 @@ import numpy as np
30
  import pandas as pd
31
  import yaml
32
  from PIL import Image
33
- from tqdm import tqdm
34
 
35
  # Optional heavy deps -------------------------------------------------------
36
  try:
@@ -61,8 +47,9 @@ except ImportError:
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,
@@ -83,6 +70,7 @@ class QCConfig:
83
  weights: str | None
84
  cpu_count: int = CPU_COUNT
85
  batch_size: int = BATCH_SIZE
 
86
 
87
  # ─────────── Helpers & Caching ─────────────────────────────────────────────
88
  def load_yaml(path: Path) -> Dict:
@@ -128,7 +116,7 @@ def get_model(weights: str) -> YOLO | 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, thr = args
134
  if cv2 is None:
@@ -151,25 +139,26 @@ def _is_corrupt(path: Path) -> bool:
151
 
152
  # ───────────────── Quality Checks ──────────────────────────────────────────
153
  def qc_integrity(imgs: List[Path], lbls: List[Path], cfg: QCConfig) -> Dict:
154
- missing = [i for i,l in zip(imgs,lbls) if l is None]
155
  corrupt = []
156
- with ProcessPoolExecutor(max_workers=cfg.cpu_count) as ex:
157
- fut = {ex.submit(_is_corrupt, p): p for p in imgs}
158
  for f in as_completed(fut):
159
- if f.result(): corrupt.append(fut[f])
160
- score = 100 - (len(missing)+len(corrupt))/max(len(imgs),1)*100
161
- return {"name":"Integrity","score":max(score,0),
 
162
  "details":{"missing_label_files":[str(p) for p in missing],
163
  "corrupt_images":[str(p) for p in corrupt]}}
164
 
165
  def qc_class_balance(lbls: List[Path], cfg: QCConfig) -> Dict:
166
  counts, boxes = Counter(), []
167
- for l in lbls:
168
  bs = parse_label_file(l) if l else []
169
  boxes.append(len(bs)); counts.update(b[0] for b in bs)
170
  if not counts:
171
  return {"name":"Class balance","score":0,"details":"No labels"}
172
- bal = min(counts.values())/max(counts.values())*100
173
  return {"name":"Class balance","score":bal,
174
  "details":{"class_counts":dict(counts),
175
  "boxes_per_image":{
@@ -180,24 +169,21 @@ def qc_image_quality(imgs: List[Path], cfg: QCConfig) -> Dict:
180
  if cv2 is None:
181
  return {"name":"Image quality","score":100,"details":"cv2 missing"}
182
  blurry, dark, bright = [], [], []
183
- with ProcessPoolExecutor(max_workers=cfg.cpu_count) as ex:
184
- args = [(p, cfg.blur_thr) for p in imgs]
185
- for p, isb, isd, isB in tqdm(
186
- ex.map(_quality_stat_args, args), total=len(imgs),
187
- desc="img-quality", leave=False
188
- ):
189
  if isb: blurry.append(p)
190
  if isd: dark.append(p)
191
  if isB: bright.append(p)
192
- bad = len({*blurry,*dark,*bright})
193
- score = 100 - bad/max(len(imgs),1)*100
194
  return {"name":"Image quality","score":score,
195
  "details":{"blurry":[str(p) for p in blurry],
196
  "dark":[str(p) for p in dark],
197
  "bright":[str(p) for p in bright]}}
198
 
199
  def qc_duplicates(imgs: List[Path], cfg: QCConfig) -> Dict:
200
- # fastdup-only path
201
  if fastdup is not None and len(imgs) > 50:
202
  try:
203
  fd = fastdup.create(
@@ -212,7 +198,6 @@ def qc_duplicates(imgs: List[Path], cfg: QCConfig) -> Dict:
212
  except Exception as e:
213
  return {"name":"Duplicates","score":100,
214
  "details":{"fastdup_error":str(e)}}
215
- # fallback skipped
216
  return {"name":"Duplicates","score":100,
217
  "details":{"note":"fastdup not available or small dataset"}}
218
 
@@ -221,20 +206,23 @@ def qc_model_qa(imgs: List[Path], lbls: List[Path], cfg: QCConfig) -> Dict:
221
  if model is None:
222
  return {"name":"Model QA","score":100,"details":"skipped"}
223
  ious, mism = [], []
224
- for i in range(0,len(imgs),cfg.batch_size):
225
- batch = imgs[i:i+cfg.batch_size]
 
226
  results = model.predict(batch, verbose=False, half=True, dynamic=True)
227
- for p,res in zip(batch,results):
228
  gt = parse_label_file(p.parent.parent/'labels'/f"{p.stem}.txt")
229
  for cls,x,y,w,h in gt:
230
- best=0.0
231
- for b,c,conf in zip(res.boxes.xywh.cpu().numpy(),
232
- res.boxes.cls.cpu().numpy(),
233
- res.boxes.conf.cpu().numpy()):
234
- if conf<cfg.conf_thr or int(c)!=cls: continue
235
- best = max(best,_rel_iou((x,y,w,h),tuple(b)))
 
236
  ious.append(best)
237
- if best<cfg.iou_thr: mism.append(str(p))
 
238
  miou = float(np.mean(ious)) if ious else 1.0
239
  return {"name":"Model QA","score":miou*100,
240
  "details":{"mean_iou":miou,"mismatches":mism[:50]}}
@@ -244,9 +232,10 @@ def qc_label_issues(imgs: List[Path], lbls: List[Path], cfg: QCConfig) -> Dict:
244
  return {"name":"Label issues","score":100,"details":"cleanlab missing"}
245
  labels, preds, idxs = [], [], []
246
  model = get_model(cfg.weights)
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
  res = model.predict([img], verbose=False)[0]
252
  pred_cls = int(res.boxes.cls.cpu().numpy()[0]) if len(res.boxes)>0 else -1
@@ -254,26 +243,25 @@ def qc_label_issues(imgs: List[Path], lbls: List[Path], cfg: QCConfig) -> Dict:
254
  if not labels:
255
  return {"name":"Label issues","score":100,"details":"no GT"}
256
  labels_arr = np.array(labels)
257
- # one-hot dummy
258
  uniq = sorted(set(labels_arr))
259
  probs = np.eye(len(uniq))[np.searchsorted(uniq, labels_arr)]
260
  noise = get_noise_indices(labels=labels_arr, probabilities=probs)
261
  flags = sorted({idxs[n] for n in noise})
262
- files = [str(imgs[i]) for i in flags]
263
  score = 100 - len(flags)/len(labels)*100
264
  return {"name":"Label issues","score":score,
265
  "details":{"files":files[:50]}}
266
 
267
- def _rel_iou(b1,b2):
268
- x1,y1,w1,h1=b1; x2,y2,w2,h2=b2
269
- xa1,ya1,xa2,ya2=x1-w1/2,y1-h1/2,x1+w1/2,y1+h1/2
270
- xb1,yb1,xb2,yb2=x2-w2/2,y2-h2/2,x2+w2/2,y2+h2/2
271
- ix1,iy1,ix2,iy2=max(xa1,xb1),max(ya1,yb1),min(xa2,xb2),min(ya2,yb2)
272
- inter=max(ix2-ix1,0)*max(iy2-iy1,0)
273
- union=w1*h1+w2*h2-inter
274
  return inter/union if union else 0.0
275
 
276
- def aggregate(results: List[Dict]) -> float:
277
  return sum(DEFAULT_W[r['name']]*r['score'] for r in results)
278
 
279
  RF_RE = re.compile(r"https?://universe\.roboflow\.com/([^/]+)/([^/]+)/dataset/(\d+)")
@@ -289,23 +277,35 @@ def download_rf_dataset(url: str, rf_api: Roboflow, dest: Path) -> Path:
289
  pr.version(int(ver)).download("yolov8", location=str(ds_dir))
290
  return ds_dir
291
 
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),
298
- qc_duplicates(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']
@@ -318,8 +318,8 @@ with gr.Blocks(title="YOLO Dataset Quality Evaluator v3") as demo:
318
  # YOLOv8 Dataset Quality Evaluator v3
319
 
320
  * Configurable blur, IOU & confidence thresholds
321
- * Cleanlab label-issue detection
322
- * Fastdup-only duplicates (no hashing fallback)
323
  * Model caching for speed
324
  """)
325
  with gr.Row():
@@ -332,47 +332,64 @@ with gr.Blocks(title="YOLO Dataset Quality Evaluator v3") as demo:
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
  run_btn = gr.Button("Evaluate")
339
  out_md = gr.Markdown()
340
  out_df = gr.Dataframe()
341
 
342
  def evaluate(api_key, url_txt, zip_file, server_path, yaml_file, weights,
343
- blur_thr, iou_thr, conf_thr):
344
  reports, dfs = [], []
345
- cfg = QCConfig(blur_thr, iou_thr, conf_thr, weights.name if weights else None)
 
346
  rf = Roboflow(api_key) if api_key and Roboflow else None
 
 
347
  if url_txt:
348
  for line in Path(url_txt.name).read_text().splitlines():
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
  if zip_file:
357
  tmp = Path(tempfile.mkdtemp())
358
  shutil.unpack_archive(zip_file.name, tmp)
359
- md, df = run_quality(tmp, Path(yaml_file.name) if yaml_file else None,
360
- Path(weights.name) if weights else None, cfg)
 
 
361
  reports.append(md); dfs.append(df)
362
  shutil.rmtree(tmp, ignore_errors=True)
 
 
363
  if server_path:
364
  ds = Path(server_path)
365
- md, df = run_quality(ds, Path(yaml_file.name) if yaml_file else None,
366
- Path(weights.name) if weights else None, cfg)
 
 
367
  reports.append(md); dfs.append(df)
 
368
  summary = "\n---\n".join(reports)
369
  combined = pd.concat(dfs).groupby(level=0).sum() if dfs else pd.DataFrame()
370
  return summary, combined
371
 
372
  run_btn.click(evaluate,
373
- inputs=[api_in, url_txt, zip_in, path_in, yaml_in, weights_in,
374
- blur_sl, iou_sl, conf_sl],
375
  outputs=[out_md, out_df])
376
 
377
  if __name__ == '__main__':
378
- demo.launch(server_name='0.0.0.0', server_port=int(os.getenv('PORT',7860)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import imghdr
 
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
13
  from typing import Dict, List, Tuple
 
17
  import pandas as pd
18
  import yaml
19
  from PIL import Image
 
20
 
21
  # Optional heavy deps -------------------------------------------------------
22
  try:
 
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,
 
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:
 
116
  _model_cache[weights] = YOLO(weights)
117
  return _model_cache[weights]
118
 
119
+ # ───────── Functions for I/O-bound concurrency ─────────────────────────────
120
  def _quality_stat_args(args: Tuple[Path, float]) -> Tuple[Path, bool, bool, bool]:
121
  path, thr = args
122
  if cv2 is None:
 
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":{
 
169
  if cv2 is None:
170
  return {"name":"Image quality","score":100,"details":"cv2 missing"}
171
  blurry, dark, bright = [], [], []
172
+ sample = imgs[:cfg.sample_limit]
173
+ with ThreadPoolExecutor(max_workers=cfg.cpu_count) as ex:
174
+ args = [(p, cfg.blur_thr) for p in sample]
175
+ for p, isb, isd, isB in ex.map(_quality_stat_args, args):
 
 
176
  if isb: blurry.append(p)
177
  if isd: dark.append(p)
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:
188
  try:
189
  fd = fastdup.create(
 
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
 
 
206
  if model is None:
207
  return {"name":"Model QA","score":100,"details":"skipped"}
208
  ious, mism = [], []
209
+ sample = imgs[:cfg.sample_limit]
210
+ for i in range(0, len(sample), cfg.batch_size):
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]}}
 
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
 
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+)")
 
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"]
303
  for r in results:
304
+ md.append(f"### {r['name']} {r['score']:.1f}")
305
  md.append("<details><summary>details</summary>\n```json")
306
+ md.append(json.dumps(r['details'], indent=2))
307
  md.append("```\n</details>\n")
308
+
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']
 
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():
 
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
+
365
+ # ZIP upload
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)))