Spaces:
Sleeping
Sleeping
| # app/main_batch_test.py | |
| # Batch evaluation for pill recognition accuracy (text/shape/color + per-drug stats) | |
| import time | |
| import os | |
| import re | |
| import datetime | |
| from pathlib import Path | |
| from collections import defaultdict | |
| import cv2 | |
| import app.utils.shape_color_utils as scu | |
| import datetime | |
| import os | |
| import pandas as pd | |
| import numpy as np | |
| # Reuse your existing pipeline modules | |
| from app.utils.pill_detection import ( | |
| get_det_model, | |
| _pick_crop_from_boxes # uses YOLO crop without background removal | |
| ) | |
| from app.utils.image_io import read_image_safely | |
| from app.utils.shape_color_utils import ( | |
| # extract_dominant_colors_by_ratio | |
| get_basic_color_name, | |
| get_dominant_colors, | |
| detect_shape_from_image | |
| ) | |
| # OCR helpers (use pill_detection’s OpenOCR engine & version generator) | |
| import app.utils.pill_detection as P # gives access to generate_image_versions, get_best_ocr_texts, ocr_engine | |
| # ---------- Config (edit these defaults as needed) ---------- | |
| # Excel with ground-truth | |
| DEFAULT_EXCEL = Path("data/TESTData.xlsx") | |
| # Root that contains subfolders per drug (named by 學名, “/” replaced by space) | |
| DEFAULT_IMAGES_ROOT = Path(r"C:\Users\92102\OneDrive - NTHU\桌面\大三下\畢業專題\drug_photos") | |
| # Evaluation range (用量排序) | |
| DEFAULT_START = 1 | |
| DEFAULT_END =402 | |
| # Where to write the summary workbook | |
| DEFAULT_REPORT_XLSX = Path("reports/藥物辨識成功率總表.xlsx") | |
| DEFAULT_REPORT_XLSX.parent.mkdir(parents=True, exist_ok=True) | |
| PRINT_COLOR_ERRORS = True # 途中即時印出顏色誤判明細 | |
| # ------------------------------------------------------------ | |
| def _norm_expected_text_tokens(exp: str): | |
| """ | |
| Parse expected text in 'F:...|B:...' format into token lists. | |
| Returns (front_tokens, back_tokens, is_none_expected) | |
| """ | |
| if not isinstance(exp, str): | |
| return [], [], True | |
| s = exp.strip().upper().replace(" ", "") | |
| if s in ("", "F:NONE|B:NONE"): | |
| return [], [], True | |
| # Extract F and B payloads | |
| m = re.match(r"^F:(.*?)\|B:(.*)$", s) | |
| if not m: | |
| # Fallback: treat whole string as a single side | |
| toks = re.findall(r"[A-Z0-9\-]+", s) | |
| return toks, [], False | |
| f_raw, b_raw = m.group(1), m.group(2) | |
| f_none = (f_raw == "NONE") | |
| b_none = (b_raw == "NONE") | |
| f_tokens = [] if f_none else re.findall(r"[A-Z0-9\-]+", f_raw) | |
| b_tokens = [] if b_none else re.findall(r"[A-Z0-9\-]+", b_raw) | |
| is_none_expected = (f_none and b_none) | |
| return f_tokens, b_tokens, is_none_expected | |
| def _tokens_match(recognized_str: str, tokens): | |
| """Check if all expected tokens appear in the recognized string.""" | |
| if not tokens: | |
| return False | |
| return all(tok in recognized_str for tok in tokens) | |
| def _to_color_set_exact(colors): | |
| """把預測清單轉成『嚴格』集合(去空白、去空字、去重;不做任何近似映射)。""" | |
| if not colors: | |
| return set() | |
| return set(c.strip() for c in colors if c and c.strip()) | |
| def _parse_expected_colors_exact(exp): | |
| """把 Excel 欄位 '白色|紅色' 解析成嚴格集合(不做任何近似映射)。""" | |
| if not isinstance(exp, str): | |
| return set() | |
| parts = [p.strip() for p in exp.split("|") if p.strip()] | |
| return set(parts) | |
| def _collect_images(folder: Path): | |
| """Gather image files under a drug folder, skip augmented names.""" | |
| if not folder.exists(): | |
| return [] | |
| exts = {".jpg", ".jpeg", ".png", ".heic", ".heif"} | |
| skip_keywords = ["_rot", "_bright", "_noise", "_flip", "_removed", "NEW_PHOTOS"] | |
| imgs = [] | |
| for p in folder.rglob("*"): | |
| if p.suffix.lower() in exts and not any(k in str(p) for k in skip_keywords): | |
| imgs.append(p) | |
| return imgs | |
| import app.utils.pill_detection as P | |
| def _run_single_image(img_path: Path, det_model, exp_shape=None, enable_fallback=True): | |
| """ | |
| Batch-side pipeline aligned with process_image: | |
| YOLO(conf=0.25→0.10) → (optional REMBG fallback) → shape/color → multi-version OCR | |
| Returns: {"text", "shape", "colors", "yolo_ok"} | |
| """ | |
| out = P.process_image(str(img_path)) | |
| if not out or out.get("error"): | |
| return {"out": out, "yolo_ok": False, "clusters": [], "center_ratio": 0.50, "margin_ratio": 0.08} | |
| dbg = out.get("debug", {}) or {} | |
| detsrc = dbg.get("det_source", "") | |
| yolo_ok = detsrc in ("yolo_conf_0.25", "yolo_conf_0.10") # 你要不要把 rembg 算成功自行決定 | |
| return { | |
| "out": out, | |
| "yolo_ok": yolo_ok, | |
| } | |
| def main( | |
| excel_path: Path = DEFAULT_EXCEL, | |
| images_root: Path = DEFAULT_IMAGES_ROOT, | |
| start_index: int = DEFAULT_START, | |
| end_index: int = DEFAULT_END, | |
| report_xlsx: Path = DEFAULT_REPORT_XLSX, | |
| write_report: bool = True | |
| ): | |
| if not excel_path.exists(): | |
| raise FileNotFoundError(f"Excel not found: {excel_path}") | |
| df = pd.read_excel(excel_path) | |
| df_range = df[(df["用量排序"] >= start_index) & (df["用量排序"] <= end_index)].copy() | |
| if df_range.empty: | |
| print("[WARN] No rows in the specified range.") | |
| return | |
| det_model = get_det_model() | |
| if hasattr(scu, "RATIO_LOG"): | |
| scu.RATIO_LOG.clear() | |
| # Counters | |
| total_images = 0 | |
| text_success_total = 0 | |
| shape_success_total = 0 | |
| color_success_total = 0 | |
| total_success = 0 | |
| yolo_total = 0 | |
| yolo_success = 0 | |
| # === NEW: 顏色誤判收集 === | |
| color_errors = [] # 逐張錯誤清單 | |
| color_confusion = defaultdict(int) # (expected_key, predicted_key) → 次數 | |
| per_drug_stats = defaultdict(lambda: {"total": 0, "success": 0}) | |
| missing_folders = [] | |
| t0 = time.perf_counter() | |
| for _, row in df_range.iterrows(): | |
| raw_name = str(row.get("學名", "")).strip() | |
| usage_order = int(row.get("用量排序", -1)) | |
| folder_name = raw_name.replace("/", " ") | |
| folder = images_root / folder_name | |
| if not folder.exists(): | |
| missing_folders.append((usage_order, raw_name)) | |
| continue | |
| imgs = _collect_images(folder) | |
| if not imgs: | |
| print(f"[SKIP] No images in: {folder}") | |
| continue | |
| # Expected ground truth from Excel | |
| exp_text = str(row.get("文字", "")).strip() | |
| f_tokens, b_tokens, none_expected = _norm_expected_text_tokens(exp_text) | |
| exp_shape = str(row.get("形狀", "")).strip() | |
| for img_path in imgs: | |
| total_images += 1 | |
| yolo_total += 1 | |
| res = _run_single_image(img_path, det_model, exp_shape=exp_shape) | |
| out = res["out"] | |
| if not out or out.get("error"): | |
| continue | |
| if res["yolo_ok"]: | |
| yolo_success += 1 | |
| # 取出結果 | |
| texts = out.get("文字辨識", []) or [] | |
| shape_ = (out.get("外型", "") or "").strip() | |
| colors = out.get("顏色", []) or [] | |
| # Text correctness | |
| rec_concat = "".join(texts).upper().replace(" ", "") | |
| if none_expected: | |
| is_text_correct = True | |
| else: | |
| is_text_correct = (_tokens_match(rec_concat, f_tokens) or _tokens_match(rec_concat, b_tokens)) | |
| if is_text_correct: | |
| text_success_total += 1 | |
| total_success += 1 | |
| # Shape correctness | |
| is_shape_correct = (shape_ == exp_shape) if exp_shape else False | |
| # Color correctness (order-insensitive with mapping) | |
| # Color correctness (order-insensitive, EXACT match, no near-color mapping) | |
| pred_color_set = _to_color_set_exact(colors) | |
| # print(f"pred: {pred_color_set}") | |
| exp_color_set = _parse_expected_colors_exact(row.get("顏色", "")) # 你的欄名依實際為準 | |
| # print(f"exp: {exp_color_set}") | |
| is_color_correct = ( | |
| exp_color_set and pred_color_set and len(pred_color_set.intersection(exp_color_set)) > 0) | |
| ###以下可刪### | |
| # Color correctness(嚴格集合) | |
| # === 顏色錯誤:記錄 + (可選)即時列印 === | |
| if exp_color_set and (not pred_color_set or not bool(pred_color_set & exp_color_set)): | |
| # 原樣(保留順序)字串,便於人眼比對 | |
| exp_list_raw = [p.strip() for p in str(row.get("顏色", "")).split("|") if p.strip()] | |
| pred_list_raw = colors or [] | |
| # 集合差異(缺少 / 多出) | |
| missing = sorted(list(exp_color_set - pred_color_set)) # 期望有但未預測 | |
| extra = sorted(list(pred_color_set - exp_color_set)) # 多預測出來 | |
| # 收進列表(若你有 color_errors / color_confusion) | |
| try: | |
| color_errors.append({ | |
| "用量排序": usage_order, | |
| "學名": raw_name, | |
| "圖片": str(img_path), | |
| "期望顏色": "|".join(exp_list_raw) if exp_list_raw else "", | |
| "預測顏色": "|".join(pred_list_raw) if pred_list_raw else "", | |
| "缺少": "|".join(missing), | |
| "多出": "|".join(extra), | |
| }) | |
| exp_key = "|".join(sorted(exp_color_set)) if exp_color_set else "∅" | |
| pred_key = "|".join(sorted(pred_color_set)) if pred_color_set else "∅" | |
| color_confusion[(exp_key, pred_key)] += 1 | |
| except NameError: | |
| # 若你尚未宣告 color_errors/color_confusion,也不會噴錯 | |
| pass | |
| # === 即時列印(可用開關關閉)=== | |
| if PRINT_COLOR_ERRORS: | |
| print(f"\n[COLOR ❌] [{usage_order}] {raw_name}") | |
| print(f" 期望:{'|'.join(exp_list_raw) if exp_list_raw else '∅'}") | |
| print(f" 預測:{'|'.join(pred_list_raw) if pred_list_raw else '∅'}") | |
| if missing: | |
| print(f" 缺少:{', '.join(missing)}") | |
| if extra: | |
| print(f" 多出:{', '.join(extra)}") | |
| print(f" 圖片:{img_path}") | |
| ###以上可刪### | |
| if is_text_correct: | |
| text_success_total += 1 | |
| if is_shape_correct: | |
| shape_success_total += 1 | |
| if is_color_correct: | |
| color_success_total += 1 | |
| per_drug_stats[raw_name]["total"] += 1 | |
| if is_text_correct: | |
| per_drug_stats[raw_name]["success"] += 1 | |
| # ---------- Print summary ---------- | |
| if missing_folders: | |
| print("\n[Missing folders]") | |
| for uo, name in missing_folders: | |
| print(f" 用量排序={uo} 學名={name}") | |
| print(f"Total missing: {len(missing_folders)}") | |
| if total_images == 0: | |
| print("\n[RESULT] No images processed.") | |
| return | |
| text_rate = text_success_total / total_images | |
| shape_rate = shape_success_total / total_images | |
| color_rate = color_success_total / total_images | |
| match_rate = total_success / total_images | |
| yolo_rate = yolo_success / max(1, yolo_total) | |
| roboflow_total = yolo_total | |
| roboflow_success = yolo_success | |
| print("\n📊 總體統計:") | |
| print("🔠 文字辨識:") | |
| print(f" - 辨識結果:{text_success_total} 張正確") | |
| print(f" - 正式結果:{total_images} 張(總圖片數)") | |
| print(f" - 辨識成功率:{text_rate:.2%}" if total_images else " - 辨識成功率:N/A") | |
| print("\n🟫 外型辨識:") | |
| print(f" - 辨識結果:{shape_success_total} 張正確") | |
| print(f" - 正確結果:{total_images} 張(總圖片數)") | |
| print(f" - 辨識成功率:{shape_rate:.2%}" if total_images else " - 辨識成功率:N/A") | |
| print("\n🎨 顏色辨識:") | |
| print(f" - 辨識結果:{color_success_total} 張正確") | |
| print(f" - 正確結果:{total_images} 張(總圖片數)") | |
| print(f" - 辨識成功率:{color_rate:.2%}" if total_images else " - 辨識成功率:N/A") | |
| print("\n💊 藥品名稱比對:") | |
| print(f" - 辨識結果:{total_success} 張比對成功") | |
| print(f" - 正確結果:{total_images} 張(總圖片數)") | |
| print( | |
| f" - 整體辨識成功率(以文字為主):{match_rate:.2%}" if total_images else " - 整體辨識成功率(以文字為主):N/A") | |
| print("\n🔍 Roboflow 偵測統計:") | |
| print(f" - 成功偵測圖片數:{roboflow_success} / {roboflow_total}") | |
| print(f" - 偵測成功率:{yolo_rate:.2%}" if roboflow_total else " - 偵測成功率:N/A") | |
| # 以下顏色辨識可刪## | |
| # === NEW: 列印部分誤判樣本 & 匯出 Excel === | |
| if color_errors: | |
| print("\n🎨 顏色誤判樣本(最多列出前 30 筆):") | |
| for e in color_errors[:30]: | |
| print(f" [{e['用量排序']}] {e['學名']}") | |
| print(f" 期望:{e['期望顏色']} | 預測:{e['預測顏色']} | 缺少:{e['缺少']} | 多出:{e['多出']}") | |
| # 轉成 DataFrame | |
| err_df = pd.DataFrame(color_errors) | |
| # 混淆表 | |
| cf_rows = [ | |
| {"expected": ek, "predicted": pk, "count": v} | |
| for (ek, pk), v in color_confusion.items() | |
| ] | |
| cf_df = pd.DataFrame(cf_rows) | |
| if not cf_df.empty: | |
| cf_pivot = cf_df.pivot(index="expected", columns="predicted", values="count").fillna(0).astype(int) | |
| else: | |
| cf_pivot = pd.DataFrame() | |
| # 輸出到 reports/color_errors.xlsx | |
| os.makedirs("reports", exist_ok=True) | |
| out_xlsx = Path("reports/color_errors.xlsx") | |
| with pd.ExcelWriter(out_xlsx, engine="openpyxl") as writer: | |
| err_df.to_excel(writer, sheet_name="errors", index=False) | |
| if not cf_pivot.empty: | |
| cf_pivot.to_excel(writer, sheet_name="confusion") | |
| print(f"📝 顏色誤判已輸出:{out_xlsx}") | |
| else: | |
| print("\n🎨 顏色誤判:無(全部顏色都符合期望)。") | |
| # 以上顏色辨識可刪## | |
| # print("📦 各藥品辨識情況:") | |
| # for drug, stats in per_drug_stats.items(): | |
| # print(f"- {drug}: {stats['success']} / {stats['total']} 成功") | |
| # ---------- Write to report workbook ---------- | |
| # Column name: today (or _v2, _v3 if exists) | |
| # ---------- 寫回「藥物辨識成功率總表.xlsx」:索引/欄位名稱完全沿用舊版 ---------- | |
| # 路徑請依你目前環境調整;若不在 Colab,改成本機路徑即可 | |
| text_rate = round(text_success_total / total_images, 4) if total_images else None | |
| shape_rate = round(shape_success_total / total_images, 4) if total_images else None | |
| color_rate = round(color_success_total / total_images, 4) if total_images else None | |
| match_rate = round(total_success / total_images, 4) if total_images else None | |
| roboflow_rate = round(roboflow_success / total_images, 4) if total_images else None | |
| if write_report: | |
| rate_excel_path = r"reports\藥物辨識成功率總表.xlsx" | |
| base_col_name = datetime.datetime.now().strftime("%Y-%m-%d") | |
| col_name = base_col_name | |
| # === [2] 自訂 index:403 個藥物 + 15 統計欄位 === | |
| drug_indexes = [str(i) for i in range(1, 404)] | |
| stat_indexes = [ | |
| "文字辨識正確數", | |
| "文字辨識總數", | |
| "文字成功率", | |
| "外型辨識正確數", | |
| "外型辨識總數", | |
| "外型成功率", | |
| "顏色辨識正確數", | |
| "顏色辨識總數", | |
| "顏色成功率", | |
| "藥名比對正確數", | |
| "藥名比對總數", | |
| "藥名比對成功率", | |
| "Roboflow 正確數", | |
| "Roboflow 總數", | |
| "Roboflow 成功率" | |
| ] | |
| custom_index = drug_indexes + stat_indexes | |
| # === [3] 讀取或初始化目標 Excel === | |
| if os.path.exists(rate_excel_path): | |
| rate_df = pd.read_excel(rate_excel_path, index_col=0) | |
| rate_df.index = rate_df.index.astype(str) | |
| # 確保 DataFrame 有正確的 index(重新索引,缺失的用 NaN 填充) | |
| rate_df = rate_df.reindex(custom_index) | |
| else: | |
| rate_df = pd.DataFrame(index=custom_index) | |
| # === [4] 欄位名稱自動加版本避免覆蓋 === | |
| version = 1 | |
| while col_name in rate_df.columns: | |
| version += 1 | |
| col_name = f"{base_col_name}_v{version}" | |
| # === [5] 個別藥品成功率(403 筆) === | |
| new_col = [] | |
| for idx in range(1, 404): | |
| matched_rows = df[df["用量排序"] == idx] | |
| if matched_rows.empty: | |
| new_col.append(None) | |
| continue | |
| drug_name = str(matched_rows["學名"].values[0]).strip() | |
| stat = per_drug_stats.get(drug_name, None) | |
| if stat and stat["total"] > 0: | |
| success_rate = stat["success"] / stat["total"] | |
| else: | |
| success_rate = None | |
| new_col.append(success_rate) | |
| # === [6] 總體統計資料 === | |
| # === [7] 附加統計欄位 === | |
| new_col += [ | |
| text_success_total, total_images, text_rate, | |
| shape_success_total, total_images, shape_rate, | |
| color_success_total, total_images, color_rate, | |
| total_success, total_images, match_rate, | |
| roboflow_success, total_images, roboflow_rate | |
| ] | |
| if len(new_col) != len(rate_df.index): | |
| raise ValueError(f"資料長度不匹配!new_col: {len(new_col)}, DataFrame index: {len(rate_df.index)}") | |
| # === [9] 寫入並儲存 === | |
| rate_df[col_name] = new_col | |
| os.makedirs(os.path.dirname(rate_excel_path), exist_ok=True) | |
| rate_df.to_excel(rate_excel_path, engine="openpyxl") | |
| print(f"✅ 已成功將辨識結果寫入 Excel:{rate_excel_path}(欄位:{col_name})") | |
| t2 = time.perf_counter() | |
| print(f"完成,總耗時 {t2 - t0:.2f}s") | |
| return shape_success_total / total_images if total_images else 0.0 | |
| def _set_shape_thresholds(circle_lo, circle_hi, ellipse_hi): | |
| # 匯入你剛剛加了全域參數的模組 | |
| import app.utils.shape_color_utils as scu | |
| scu.set_shape_thresholds(circle_lo, circle_hi, ellipse_hi) | |
| if __name__ == "__main__": | |
| # Simple CLI via env vars or edit defaults at top | |
| excel = Path(os.environ.get("BATCH_EXCEL", DEFAULT_EXCEL)) | |
| root = Path(os.environ.get("BATCH_IMAGES_ROOT", DEFAULT_IMAGES_ROOT)) | |
| start = int(os.environ.get("BATCH_START", DEFAULT_START)) | |
| end = int(os.environ.get("BATCH_END", DEFAULT_END)) | |
| report = Path(os.environ.get("BATCH_REPORT", DEFAULT_REPORT_XLSX)) | |
| DO_SEARCH = False # 想直接跑單次就設 False | |
| # DO_SEARCH = False # 想直接跑單次就設 False | |
| _set_shape_thresholds(1.00, 1.20, 3.80) | |
| if not DO_SEARCH: | |
| # 單次跑:用目前預設門檻 | |
| acc = main(excel, root, start, end, report, write_report=True) # 或 main(..., write_report=True) | |
| print(f"[RUN] shape accuracy = {acc:.4%}") | |
| else: | |
| grid_lo = [1.00] | |
| grid_hi = [1.10, 1.15, 1.20, 1.25] | |
| grid_ehi = [2.0, 2.5, 3.0, 3.5] | |
| # === Top 10 (coarse) === | |
| # 1) acc=87.6143% circle=(1.00,1.20) ellipse<=3.80 | |
| # 2) acc=87.2486% circle=(1.00,1.24) ellipse<=3.80 | |
| # 3) acc=86.8830% circle=(1.00,1.16) ellipse<=3.80 | |
| best = [] | |
| for lo in grid_lo: | |
| for hi in grid_hi: | |
| # 不需要寬度檢查了,因為 lo 固定 1.00 | |
| for ehi in grid_ehi: | |
| if ehi <= hi: # 橢圓上限要高於圓形上限 | |
| continue | |
| _set_shape_thresholds(lo, hi, ehi) | |
| print(f"\n[SEARCH] circle=({lo:.2f},{hi:.2f}) ellipse<={ehi:.2f}") | |
| acc = main(excel, root, start, end, report, write_report=False) # 先不要寫報表 | |
| best.append((acc, lo, hi, ehi)) | |
| print(f"[SEARCH] shape acc = {acc:.4%}") | |
| best.sort(key=lambda x: x[0], reverse=True) | |
| print("=== Top 10 (coarse) ===") | |
| for i, (acc, lo, hi, ehi) in enumerate(best[:10], 1): | |
| print(f"{i}) acc={acc:.4%} circle=({lo:.2f},{hi:.2f}) ellipse<={ehi:.2f}") | |
| # 用最佳組合正式跑一次並寫入報表 | |
| best_acc, best_lo, best_hi, best_ehi = best[0] | |
| _set_shape_thresholds(best_lo, best_hi, best_ehi) | |
| print(f"\n[FINAL] 使用最佳組合 circle=({best_lo},{best_hi}), ellipse<={best_ehi} 寫入報表") | |
| _ = main(excel, root, start, end, report, write_report=True) | |