Bladeren bron

增强:HTML 综合报告内嵌 4 张可视化图表

新增缺陷类型分布条形图、每日趋势折线图、设备分布条形图、严重程度饼图,
通过 matplotlib 生成后转 base64 data URI 嵌入 HTML,无需外部 JS 依赖。
leod 3 dagen geleden
bovenliggende
commit
7fa0c7f799
2 gewijzigde bestanden met toevoegingen van 143 en 2 verwijderingen
  1. 6 0
      app.py
  2. 137 2
      app_utils.py

+ 6 - 0
app.py

@@ -34,6 +34,7 @@ from app_utils import (
     calculate_kpis,
     calculate_spc_metrics,
     generate_industry_diagnosis,
+    generate_report_charts,
     get_missing_required_columns,
     normalize_defect_schema,
     TEMPLATE_COLUMNS,
@@ -2592,6 +2593,10 @@ if current_config["show_export"]:
         "建议检查设备健康评分",
     ]
 
+    # 7. 生成报告图表
+    daily_for_chart = filtered_df.groupby("day").size().rename("缺陷数").reset_index() if len(filtered_df) >= 2 else None
+    report_charts = generate_report_charts(filtered_df, daily_trend_df=daily_for_chart)
+
     full_report_html = build_html_report(
         generated_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
         date_range_text=f"{start_date.strftime('%Y-%m-%d')} ~ {end_date.strftime('%Y-%m-%d')}",
@@ -2605,6 +2610,7 @@ if current_config["show_export"]:
         trend_summary=trend_summary,
         anomaly_rows=anomaly_rows,
         recommendations=recommendations_r,
+        charts=report_charts,
     )
 
     col_exp1, col_exp2, col_exp3 = st.columns(3)

+ 137 - 2
app_utils.py

@@ -1,9 +1,39 @@
 """缺陷分析页面的可测试业务逻辑。"""
 
+import base64
 import html
+import io
+import os
+
+import matplotlib
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+from matplotlib import font_manager as fm
 import numpy as np
 import pandas as pd
 
+
+def _setup_chinese_font():
+    """配置 matplotlib 中文字体,与 app.py 保持一致。"""
+    font_paths = [
+        r"C:\Windows\Fonts\msyh.ttc",
+        r"C:\Windows\Fonts\simhei.ttf",
+        r"C:\Windows\Fonts\simsun.ttc",
+        r"C:\Windows\Fonts\malgun.ttf",
+    ]
+    for fp in font_paths:
+        if os.path.exists(fp):
+            font_prop = fm.FontProperties(fname=fp)
+            plt.rcParams["font.family"] = font_prop.get_name()
+            plt.rcParams["axes.unicode_minus"] = False
+            return font_prop
+    plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "Arial Unicode MS"]
+    plt.rcParams["axes.unicode_minus"] = False
+    return None
+
+
+_CHINESE_FONT_PROP = _setup_chinese_font()
+
 from defect_analysis.ml.model_bundle import create_model_bundle
 from defect_analysis.ml.predict import predict_key_factors
 from defect_analysis.root_cause import EXTENDED_ROOT_CAUSE_DIMENSIONS, build_extended_root_causes
@@ -379,6 +409,103 @@ def build_ml_factor_insights(
     return base
 
 
+def _fig_to_base64(fig, *, dpi=120):
+    """把 matplotlib Figure 转成 base64 PNG data URI。"""
+    buf = io.BytesIO()
+    fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", facecolor="white")
+    buf.seek(0)
+    encoded = base64.b64encode(buf.read()).decode("utf-8")
+    buf.close()
+    plt.close(fig)
+    return f"data:image/png;base64,{encoded}"
+
+
+def generate_report_charts(filtered_df, *, daily_trend_df=None):
+    """生成报告内嵌的三张核心图表,返回 dict of base64 data URIs。"""
+    charts = {}
+
+    # --- 1. 缺陷类型分布条形图 ---
+    type_counts = filtered_df["defect_type"].value_counts().head(10)
+    if not type_counts.empty:
+        fig, ax = plt.subplots(figsize=(7, 3.5))
+        colors = ["#0f766e", "#14b8a6", "#22d3ee", "#38bdf8", "#60a5fa",
+                  "#a78bfa", "#c084fc", "#e879f9", "#f472b6", "#fb7185"]
+        bars = ax.barh(
+            range(len(type_counts)),
+            type_counts.values,
+            color=colors[: len(type_counts)],
+        )
+        ax.set_yticks(range(len(type_counts)))
+        ax.set_yticklabels(type_counts.index, fontsize=11)
+        ax.invert_yaxis()
+        for i, (bar, val) in enumerate(zip(bars, type_counts.values)):
+            ax.text(bar.get_width() + max(type_counts.values) * 0.01,
+                    bar.get_y() + bar.get_height() / 2,
+                    str(val), va="center", fontsize=10, fontweight="bold")
+        ax.set_title("缺陷类型 TOP 10", fontsize=13, fontweight="bold", pad=12)
+        ax.spines["top"].set_visible(False)
+        ax.spines["right"].set_visible(False)
+        ax.set_xlabel("缺陷数")
+        charts["type_distribution"] = _fig_to_base64(fig)
+
+    # --- 2. 每日趋势折线图 ---
+    if daily_trend_df is not None and not daily_trend_df.empty:
+        daily = daily_trend_df.copy()
+        daily["day"] = pd.to_datetime(daily["day"])
+        daily = daily.sort_values("day")
+        fig, ax = plt.subplots(figsize=(7, 3))
+        ax.plot(daily["day"], daily["缺陷数"], marker="o", linewidth=2,
+                markersize=5, color="#0f766e")
+        ax.fill_between(daily["day"], daily["缺陷数"], alpha=0.15, color="#0f766e")
+        ax.set_title("每日缺陷数趋势", fontsize=13, fontweight="bold", pad=10)
+        ax.spines["top"].set_visible(False)
+        ax.spines["right"].set_visible(False)
+        ax.tick_params(axis="x", rotation=30)
+        charts["daily_trend"] = _fig_to_base64(fig)
+
+    # --- 3. 设备缺陷分布 ---
+    eq_counts = filtered_df.get("equipment_id")
+    if eq_counts is not None:
+        eq_counts = eq_counts.value_counts().head(8)
+        if not eq_counts.empty:
+            fig, ax = plt.subplots(figsize=(7, 3))
+            ax.bar(
+                range(len(eq_counts)),
+                eq_counts.values,
+                color=["#1e3a5f", "#2563eb", "#3b82f6", "#60a5fa",
+                       "#93c5fd", "#0d9488", "#14b8a6", "#2dd4bf"][: len(eq_counts)],
+            )
+            ax.set_xticks(range(len(eq_counts)))
+            ax.set_xticklabels(eq_counts.index, rotation=25, ha="right", fontsize=10)
+            ax.set_title("设备缺陷分布 TOP 8", fontsize=13, fontweight="bold", pad=10)
+            ax.spines["top"].set_visible(False)
+            ax.spines["right"].set_visible(False)
+            ax.set_ylabel("缺陷数")
+            for i, val in enumerate(eq_counts.values):
+                ax.text(i, val + max(eq_counts.values) * 0.02, str(val),
+                        ha="center", fontsize=9, fontweight="bold")
+            charts["equipment_distribution"] = _fig_to_base64(fig)
+
+    # --- 4. 严重程度饼图 ---
+    if "severity" in filtered_df.columns and not filtered_df.empty:
+        sev_counts = filtered_df["severity"].value_counts()
+        if not sev_counts.empty:
+            fig, ax = plt.subplots(figsize=(4.5, 3.5))
+            sev_colors = {"轻微": "#22c55e", "一般": "#f59e0b", "严重": "#ef4444"}
+            colors = [sev_colors.get(name, "#94a3b8") for name in sev_counts.index]
+            wedges, texts, autotexts = ax.pie(
+                sev_counts.values, labels=sev_counts.index,
+                autopct="%1.1f%%", colors=colors, startangle=90,
+                textprops={"fontsize": 11},
+            )
+            for at in autotexts:
+                at.set_fontweight("bold")
+            ax.set_title("严重程度占比", fontsize=13, fontweight="bold", pad=10)
+            charts["severity_pie"] = _fig_to_base64(fig)
+
+    return charts
+
+
 def _escape(value):
     return html.escape(str(value), quote=True)
 
@@ -403,10 +530,12 @@ def build_html_report(
     trend_summary="-",
     anomaly_rows=None,
     recommendations=None,
+    charts=None,
 ):
     """生成可直接在浏览器打开的自包含综合 HTML 报告。"""
     anomaly_rows = anomaly_rows or []
     recommendations = recommendations or []
+    charts = charts or {}
     type_rows = _series_rows(type_counts)
     equipment_rows = _series_rows(equipment_counts)
     seat_rows = _series_rows(seat_top)
@@ -492,6 +621,7 @@ def build_html_report(
     .note {{ color: var(--muted); font-size: 13px; margin-top: 8px; }}
     .recommend {{ border-left: 5px solid var(--brand); }}
     li {{ margin: 8px 0; }}
+    .chart {{ max-width: 100%; border-radius: 12px; margin-top: 8px; }}
     @media print {{
       body {{ background: white; }}
       .page {{ max-width: none; padding: 20px; }}
@@ -529,18 +659,21 @@ def build_html_report(
       <div class="card">
         <h2>2. 趋势分析</h2>
         <p>{_escape(trend_summary)}</p>
+        {('<img class="chart" src="' + charts["daily_trend"] + '" alt="每日缺陷数趋势"/>') if "daily_trend" in charts else ""}
         <p class="note">建议结合 SPC 控制图确认是否越过预警线或控制线。</p>
       </div>
     </section>
 
     <section class="card">
       <h2>3. 缺陷类型分布</h2>
+      {('<img class="chart" src="' + charts["type_distribution"] + '" alt="缺陷类型分布"/>') if "type_distribution" in charts else ""}
       <table><tr><th>缺陷类型</th><th>缺陷数</th><th>占比</th></tr>{type_items}</table>
     </section>
 
     <section class="two">
       <div class="card">
         <h2>4. 设备分布</h2>
+        {('<img class="chart" src="' + charts["equipment_distribution"] + '" alt="设备缺陷分布"/>') if "equipment_distribution" in charts else ""}
         <table><tr><th>设备</th><th>缺陷数</th></tr>{equipment_items}</table>
       </div>
       <div class="card">
@@ -549,13 +682,15 @@ def build_html_report(
       </div>
     </section>
 
+    {('<section class="card"><h2>6. 严重程度占比</h2><div style="display:flex;justify-content:center;"><img class="chart" src="' + charts["severity_pie"] + '" alt="严重程度占比" style="max-width:320px;"/></div></section>') if "severity_pie" in charts else ""}
+
     <section class="card">
-      <h2>6. 异常检测</h2>
+      <h2>{"6. 异常检测" if "severity_pie" not in charts else "7. 异常检测"}</h2>
       <table><tr><th>设备</th><th>座号</th><th>缺陷数</th></tr>{anomaly_items}</table>
     </section>
 
     <section class="card recommend">
-      <h2>7. 排查建议</h2>
+      <h2>{"7. 排查建议" if "severity_pie" not in charts else "8. 排查建议"}</h2>
       <ul>{recommendation_items}</ul>
     </section>