From e3acc36a89afc0587168706caafdf2ca33a41bad Mon Sep 17 00:00:00 2001 From: Rafhan Mazaya Fathurrahman Date: Thu, 2 Jul 2026 16:39:13 +0700 Subject: [PATCH] feat: add compare_accuracy script and public comparison_report Excel --- backend/compare_accuracy.py | 304 ++++++++++++++++++ .../pfm-web-app/public/comparison_report.xlsx | Bin 0 -> 21432 bytes 2 files changed, 304 insertions(+) create mode 100644 backend/compare_accuracy.py create mode 100644 backend/pfm-web-app/public/comparison_report.xlsx diff --git a/backend/compare_accuracy.py b/backend/compare_accuracy.py new file mode 100644 index 0000000..95a34f8 --- /dev/null +++ b/backend/compare_accuracy.py @@ -0,0 +1,304 @@ +import json +import os +import glob +import pandas as pd +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + +def clean_val(val): + if val is None: + return "" + return str(val).strip().upper() + +def main(): + jsonl_file = "backend/uploads/test_images_results.jsonl" + manual_labels_pattern = "backend/uploads/manual_label_*.json" + xlsx_file = "backend/pfm-web-app/public/comparison_report.xlsx" + + if not os.path.exists(jsonl_file): + # Fallback to backend/uploads if run from different dir + jsonl_file = "uploads/test_images_results.jsonl" + manual_labels_pattern = "uploads/manual_label_*.json" + xlsx_file = "pfm-web-app/public/comparison_report.xlsx" + + if not os.path.exists(jsonl_file): + print(f"Error: JSONL file not found at {jsonl_file}") + return + + # Load automated results + auto_results = {} + with open(jsonl_file, "r") as f: + for line in f: + if not line.strip(): + continue + try: + data = json.loads(line) + filename = data.get("filename") + if filename: + auto_results[filename] = data + except Exception as e: + print(f"Skipping line: {e}") + + # Load manual labels + manual_files = glob.glob(manual_labels_pattern) + manual_labels = {} + for mf in manual_files: + try: + with open(mf, "r") as f: + data = json.load(f) + filename = data.get("filename") + if filename: + manual_labels[filename] = data + except Exception as e: + print(f"Error reading manual label {mf}: {e}") + + print(f"Loaded {len(auto_results)} automated results.") + print(f"Loaded {len(manual_labels)} manual labels.") + + # Fields to compare in headers + header_fields = [ + ("noPO", "noPO", "PO Number"), + ("noSO", "noSO", "SO Number"), + ("noDO", "noDO", "DO Number"), + ("tanggal", "tanggal", "Date"), + ("plat", "platTruk", "Plat Nomor"), + ("customer", "customerInfo", "Customer Name"), + ("store", "orderUntuk", "Store Name"), + ("alamat", "alamat", "Alamat") + ] + + doc_comparison_rows = [] + item_comparison_rows = [] + + # Counters for accuracy calculation + stats = { + "PO Number": {"match": 0, "total": 0}, + "SO Number": {"match": 0, "total": 0}, + "DO Number": {"match": 0, "total": 0}, + "Date": {"match": 0, "total": 0}, + "Plat Nomor": {"match": 0, "total": 0}, + "Customer Name": {"match": 0, "total": 0}, + "Store Name": {"match": 0, "total": 0}, + "Alamat": {"match": 0, "total": 0}, + "Item SKU": {"match": 0, "total": 0}, + "Item Banyak": {"match": 0, "total": 0}, + "Item Jumlah": {"match": 0, "total": 0} + } + + for filename, manual in manual_labels.items(): + auto = auto_results.get(filename) + if not auto: + print(f"Warning: Automated result not found for {filename}") + continue + + auto_meta = auto.get("metadata", {}) + + # 1. Compare header fields + for manual_key, auto_key, field_label in header_fields: + m_val = clean_val(manual.get(manual_key)) + a_val = clean_val(auto_meta.get(auto_key)) + is_match = (m_val == a_val) + + doc_comparison_rows.append({ + "Filename": filename, + "Field": field_label, + "Automated Value (OCR)": a_val if a_val else "(empty)", + "Manual Value (Ground Truth)": m_val if m_val else "(empty)", + "Match": "Match" if is_match else "Mismatch" + }) + + stats[field_label]["total"] += 1 + if is_match: + stats[field_label]["match"] += 1 + + # 2. Compare items + m_items = manual.get("items", []) + # We also look at auto.get("items") or auto_meta.get("items") + a_items = auto.get("items", []) + if not a_items and "items" in auto_meta: + a_items = auto_meta.get("items", []) + + # Create dictionaries of items indexed by codeBarang (SKU) + m_items_dict = {clean_val(item.get("kodeBarang")): item for item in m_items if clean_val(item.get("kodeBarang"))} + a_items_dict = {clean_val(item.get("kodeBarang")): item for item in a_items if clean_val(item.get("kodeBarang"))} + + # Check all unique SKUs across both manual and automated + all_skus = set(list(m_items_dict.keys()) + list(a_items_dict.keys())) + + for sku in all_skus: + m_item = m_items_dict.get(sku) + a_item = a_items_dict.get(sku) + + # Check SKU existence match + sku_match = (m_item is not None) and (a_item is not None) + stats["Item SKU"]["total"] += 1 + if sku_match: + stats["Item SKU"]["match"] += 1 + + m_banyak = clean_val(m_item.get("banyak")) if m_item else "" + a_banyak = clean_val(a_item.get("banyak")) if a_item else "" + banyak_match = (m_banyak == a_banyak) + + stats["Item Banyak"]["total"] += 1 + if banyak_match: + stats["Item Banyak"]["match"] += 1 + + m_jumlah = clean_val(m_item.get("jumlah")) if m_item else "" + a_jumlah = clean_val(a_item.get("jumlah")) if a_item else "" + jumlah_match = (m_jumlah == a_jumlah) + + stats["Item Jumlah"]["total"] += 1 + if jumlah_match: + stats["Item Jumlah"]["match"] += 1 + + # Log code comparison + item_comparison_rows.append({ + "Filename": filename, + "Kode Barang (SKU)": sku, + "Field": "SKU Existence", + "Automated Value (OCR)": sku if a_item else "(not found)", + "Manual Value (Ground Truth)": sku if m_item else "(not found)", + "Match": "Match" if sku_match else "Mismatch" + }) + + # Log Banyak comparison + item_comparison_rows.append({ + "Filename": filename, + "Kode Barang (SKU)": sku, + "Field": "Banyak (Qty Package)", + "Automated Value (OCR)": a_banyak if a_banyak else "(empty)", + "Manual Value (Ground Truth)": m_banyak if m_banyak else "(empty)", + "Match": "Match" if banyak_match else "Mismatch" + }) + + # Log Jumlah comparison + item_comparison_rows.append({ + "Filename": filename, + "Kode Barang (SKU)": sku, + "Field": "Jumlah (Qty Unit)", + "Automated Value (OCR)": a_jumlah if a_jumlah else "(empty)", + "Manual Value (Ground Truth)": m_jumlah if m_jumlah else "(empty)", + "Match": "Match" if jumlah_match else "Mismatch" + }) + + # Prepare summary data + summary_rows = [] + total_matches = 0 + total_fields = 0 + for field_label, counts in stats.items(): + match_cnt = counts["match"] + total_cnt = counts["total"] + pct = (match_cnt / total_cnt * 100.0) if total_cnt > 0 else 100.0 + summary_rows.append({ + "Field / Area": field_label, + "Total Checks": total_cnt, + "Matches": match_cnt, + "Mismatches": total_cnt - match_cnt, + "Accuracy (%)": round(pct, 2) + }) + total_matches += match_cnt + total_fields += total_cnt + + overall_accuracy = (total_matches / total_fields * 100.0) if total_fields > 0 else 100.0 + summary_rows.append({ + "Field / Area": "OVERALL TOTAL", + "Total Checks": total_fields, + "Matches": total_matches, + "Mismatches": total_fields - total_matches, + "Accuracy (%)": round(overall_accuracy, 2) + }) + + df_summary = pd.DataFrame(summary_rows) + df_docs = pd.DataFrame(doc_comparison_rows) + df_items = pd.DataFrame(item_comparison_rows) + + # Styling setup + font_family = "Segoe UI" + header_font = Font(name=font_family, size=11, bold=True, color="FFFFFF") + regular_font = Font(name=font_family, size=10) + bold_font = Font(name=font_family, size=10, bold=True) + + header_fill = PatternFill(start_color="1F4E78", end_color="1F4E78", fill_type="solid") # Dark Blue + zebra_fill = PatternFill(start_color="F2F5F8", end_color="F2F5F8", fill_type="solid") # Zebra light blue-gray + match_fill = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") # Light green + mismatch_fill = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") # Light orange + + center_align = Alignment(horizontal="center", vertical="center") + left_align = Alignment(horizontal="left", vertical="center") + right_align = Alignment(horizontal="right", vertical="center") + + thin_side = Side(border_style="thin", color="D9D9D9") + cell_border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side) + + # Save to Excel + os.makedirs(os.path.dirname(xlsx_file), exist_ok=True) + with pd.ExcelWriter(xlsx_file, engine='openpyxl') as writer: + df_summary.to_excel(writer, sheet_name='Summary Accuracy', index=False) + df_docs.to_excel(writer, sheet_name='Header Field Comparison', index=False) + df_items.to_excel(writer, sheet_name='Item SKU Comparison', index=False) + + # Style worksheets + for sheet_name in ['Summary Accuracy', 'Header Field Comparison', 'Item SKU Comparison']: + ws = writer.sheets[sheet_name] + max_row = ws.max_row + max_col = ws.max_column + + # Header row styling + for col in range(1, max_col + 1): + cell = ws.cell(row=1, column=col) + cell.font = header_font + cell.fill = header_fill + cell.alignment = center_align + + # Data rows styling + for row in range(2, max_row + 1): + is_zebra = (row % 2 == 0) + + # Check for Match/Mismatch to apply colors on sheets 2 & 3 + match_val = None + if sheet_name in ['Header Field Comparison', 'Item SKU Comparison']: + # Match column is the last column + match_cell = ws.cell(row=row, column=max_col) + match_val = match_cell.value + + for col in range(1, max_col + 1): + cell = ws.cell(row=row, column=col) + cell.font = regular_font + cell.border = cell_border + + # Apply alignments based on column + if sheet_name == 'Summary Accuracy': + if col == 1: + cell.alignment = left_align + else: + cell.alignment = right_align + + # Highlight overall total row + if row == max_row: + cell.font = bold_font + cell.fill = match_fill if overall_accuracy > 80 else mismatch_fill + else: + # For detail sheets + if col in [1, 3, 4]: + cell.alignment = left_align + else: + cell.alignment = center_align + + # Color match / mismatch + if match_val == "Match": + cell.fill = match_fill + elif match_val == "Mismatch": + cell.fill = mismatch_fill + elif is_zebra: + cell.fill = zebra_fill + + # Auto-fit columns + for col in ws.columns: + max_len = max(len(str(cell.value or '')) for cell in col) + col_letter = get_column_letter(col[0].column) + ws.column_dimensions[col_letter].width = max(max_len + 4, 12) + + print(f"Comparison report generated at {xlsx_file}") + +if __name__ == "__main__": + main() diff --git a/backend/pfm-web-app/public/comparison_report.xlsx b/backend/pfm-web-app/public/comparison_report.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..732e1b2af8c965799523c8a51f497658787daf9e GIT binary patch literal 21432 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
x+h>Q_h0ou+lCFjxwaY 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 literal 0 HcmV?d00001