Pill_Identification / main_batch_test.py
Rushikesh-Sontakke
Complete project
4adc200
Raw
History Blame Contribute Delete
20.9 kB
# 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)