Spaces:
Build error
Build error
| import streamlit as st | |
| import pandas as pd | |
| import plotly.graph_objects as go | |
| import plotly.io as pio | |
| from zipfile import ZipFile | |
| from io import BytesIO | |
| import matplotlib.pyplot as plt | |
| import os | |
| import matplotlib.font_manager as fm | |
| # ============================ | |
| # 載入本地字型檔與設定 | |
| # ============================ | |
| font_path = "NotoSansTC-Regular.ttf" # 確認此字型檔路徑正確 | |
| if os.path.exists(font_path): | |
| fm.fontManager.addfont(font_path) | |
| font_prop = fm.FontProperties(fname=font_path) | |
| CHINESE_FONT = font_prop.get_name() # 例如 "Noto Sans TC" | |
| else: | |
| st.error(f"找不到字型檔: {font_path}") | |
| CHINESE_FONT = "Noto Sans TC" # fallback | |
| # 配置 Kaleido 的預設參數與額外字型設定 | |
| pio.kaleido.scope.default_font = CHINESE_FONT | |
| pio.kaleido.scope.default_format = "png" | |
| pio.kaleido.scope.default_width = 1200 | |
| pio.kaleido.scope.default_height = 900 | |
| pio.kaleido.scope.default_scale = 2 | |
| pio.kaleido.scope.extra_fonts = {CHINESE_FONT: font_path} | |
| # ============================ | |
| # 系統字型檢查與安裝 | |
| # ============================ | |
| def setup_chinese_font(): | |
| """檢查系統中文字型是否可用,若無則嘗試安裝""" | |
| try: | |
| test_fig, ax = plt.subplots() | |
| ax.text(0.5, 0.5, "中文測試", fontfamily=CHINESE_FONT) | |
| test_fig.savefig("font_test.png") | |
| plt.close(test_fig) | |
| except Exception as e: | |
| st.warning("正在安裝中文字型...") | |
| os.system('apt-get update && apt-get install -y --force-yes fonts-noto-cjk') | |
| st.experimental_rerun() | |
| # ============================ | |
| # 資料讀取 | |
| # ============================ | |
| def load_data(uploaded_file): | |
| """從 CSV 檔案中讀取資料""" | |
| try: | |
| df = pd.read_csv(uploaded_file, encoding='utf-8').dropna(how='all') | |
| if '姓名' not in df.columns: | |
| raise ValueError("CSV檔案中缺少必要欄位:姓名") | |
| numeric_columns = [] | |
| potential_numeric = ['平均', '總分', '國文', '英文', '數學', '自科', | |
| '社會', '地理', '歷史', '公民', '物理', '化學', '生物'] | |
| for col in potential_numeric: | |
| if col in df.columns: | |
| try: | |
| df[col] = pd.to_numeric(df[col], errors='coerce') | |
| numeric_columns.append(col) | |
| except: | |
| pass | |
| if not numeric_columns: | |
| raise ValueError("CSV檔案中未找到有效的數值欄位") | |
| return df, numeric_columns | |
| except Exception as e: | |
| st.error(f"數據加載錯誤:{str(e)}") | |
| return None, None | |
| # ============================ | |
| # 生成雷達圖 | |
| # ============================ | |
| def create_radar_chart(df, selected_rows, selected_columns, student_name=""): | |
| """生成雷達圖 (包含圖例、極座標軸等設定)""" | |
| fig = go.Figure() | |
| line_styles = ['solid', 'dot', 'dash', 'longdash', 'dashdot'] | |
| colors = ['#1F77B4', '#FF7F0E', '#2CA02C', '#D62728', '#9467BD'] | |
| for i, row_name in enumerate(selected_rows): | |
| row_data = df[df['姓名'] == row_name][selected_columns].iloc[0] | |
| fig.add_trace(go.Scatterpolar( | |
| r=row_data.values, | |
| theta=selected_columns, | |
| fill='toself', | |
| name=row_name, | |
| line=dict( | |
| color=colors[i % len(colors)], | |
| dash=line_styles[i % len(line_styles)], | |
| width=2 | |
| ), | |
| marker=dict(opacity=0.5) | |
| )) | |
| max_value = df[selected_columns].max().max() * 1.1 if selected_columns else 100 | |
| fig.update_layout( | |
| polar=dict( | |
| radialaxis=dict( | |
| visible=True, | |
| range=[0, max_value], | |
| tickfont=dict(size=14, color='black', family=CHINESE_FONT) | |
| ), | |
| angularaxis=dict( | |
| tickfont=dict(size=16, color='black', family=CHINESE_FONT), | |
| rotation=20 | |
| ) | |
| ), | |
| showlegend=True, | |
| legend=dict( | |
| font=dict(size=14, color='black', family=CHINESE_FONT), | |
| orientation="v", | |
| yanchor="middle", | |
| xanchor="left", | |
| x=1.2, | |
| y=0.5 | |
| ), | |
| title=dict( | |
| text=f'{student_name} 成績比較圖' if student_name else '學生成績雷達圖', | |
| font=dict(size=24, family=CHINESE_FONT), | |
| x=0.5, | |
| xanchor='center' | |
| ), | |
| margin=dict(t=80, b=120, r=200), | |
| plot_bgcolor='white', | |
| paper_bgcolor='white', | |
| font=dict(family=CHINESE_FONT, size=14) | |
| ) | |
| return fig | |
| # ============================ | |
| # 生成圖片 | |
| # ============================ | |
| def generate_radar_image(fig): | |
| """生成圖片 (重設 Kaleido 配置與更新字型設定)""" | |
| try: | |
| # 重設 kaleido 配置(含 extra_fonts) | |
| pio.kaleido.scope.default_font = CHINESE_FONT | |
| pio.kaleido.scope.default_format = "png" | |
| pio.kaleido.scope.default_width = 1200 | |
| pio.kaleido.scope.default_height = 900 | |
| pio.kaleido.scope.default_scale = 2 | |
| pio.kaleido.scope.extra_fonts = {CHINESE_FONT: font_path} | |
| # 強制更新圖表內所有文字字型設定 | |
| fig.update_layout( | |
| font=dict( | |
| family=CHINESE_FONT, | |
| size=14 | |
| ), | |
| title=dict( | |
| font=dict( | |
| family=CHINESE_FONT, | |
| size=24 | |
| ) | |
| ), | |
| legend=dict( | |
| font=dict( | |
| family=CHINESE_FONT, | |
| size=14 | |
| ) | |
| ), | |
| polar=dict( | |
| radialaxis=dict( | |
| tickfont=dict( | |
| family=CHINESE_FONT, | |
| size=14 | |
| ) | |
| ), | |
| angularaxis=dict( | |
| tickfont=dict( | |
| family=CHINESE_FONT, | |
| size=16 | |
| ) | |
| ) | |
| ) | |
| ) | |
| return pio.to_image(fig, format="png", engine="kaleido") | |
| except Exception as e: | |
| st.error(f"圖片生成失敗: {str(e)}") | |
| return None | |
| # ============================ | |
| # 主程式 | |
| # ============================ | |
| def main(): | |
| st.title('學生成績雷達圖產生器') | |
| st.markdown(""" | |
| <style> | |
| .stDownloadButton>button { | |
| background-color: #4CAF50 !important; | |
| color: white !important; | |
| } | |
| .stProgress > div > div > div { | |
| background-color: #4CAF50; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| setup_chinese_font() # 檢查中文字型 | |
| uploaded_file = st.file_uploader("上傳CSV檔案", type=['csv']) | |
| if uploaded_file is not None: | |
| df, numeric_columns = load_data(uploaded_file) | |
| if df is not None and numeric_columns: | |
| st.write("### 選擇要比較的欄位") | |
| preset_columns = ['平均', '國文', '英文', '數學', '自科', '社會'] | |
| use_preset = st.checkbox("使用五科分數與平均比較") | |
| available_preset = [col for col in preset_columns if col in numeric_columns] | |
| selected_columns = st.multiselect( | |
| '選擇科目', | |
| numeric_columns, | |
| default=available_preset if use_preset else numeric_columns[:5] | |
| ) | |
| st.write("### 選擇要比較的對象") | |
| all_students = df['姓名'].tolist() | |
| selected_rows = st.multiselect('選擇學生', all_students) | |
| if selected_columns and selected_rows: | |
| try: | |
| fig = create_radar_chart(df, selected_rows, selected_columns) | |
| st.plotly_chart(fig, use_container_width=True) | |
| except Exception as e: | |
| st.error(f"生成雷達圖錯誤:{str(e)}") | |
| st.write("### 批次下載全班學生雷達圖") | |
| comparison_items = st.multiselect("選擇比較基準", all_students) | |
| if comparison_items and selected_columns: | |
| progress_bar = st.progress(0) | |
| image_bytes = {} | |
| current_columns = selected_columns.copy() | |
| current_comparison = comparison_items.copy() | |
| target_students = [s for s in all_students if s not in current_comparison] | |
| for idx, student in enumerate(target_students): | |
| try: | |
| # 動態組合比較組(比較基準加上當前學生) | |
| fig = create_radar_chart( | |
| df, | |
| current_comparison + [student], | |
| current_columns, | |
| student_name=student | |
| ) | |
| img = generate_radar_image(fig) | |
| if img: | |
| image_bytes[student] = img | |
| except Exception as e: | |
| st.error(f"生成 {student} 圖表失敗:{str(e)}") | |
| progress_bar.progress((idx + 1) / len(target_students)) | |
| if image_bytes: | |
| st.write("### 圖表預覽") | |
| cols = st.columns(3) | |
| for i, (student, img) in enumerate(image_bytes.items()): | |
| with cols[i % 3]: | |
| st.image(img, use_container_width=True) | |
| st.caption(f"{student} 比較圖") | |
| zip_buffer = BytesIO() | |
| with ZipFile(zip_buffer, "w") as zip_file: | |
| for student, img in image_bytes.items(): | |
| zip_file.writestr(f"{student}_成績比較圖.png", img) | |
| zip_buffer.seek(0) | |
| st.download_button( | |
| label="⬇️ 下載全部圖表 (ZIP)", | |
| data=zip_buffer, | |
| file_name="學生成績比較圖.zip", | |
| mime="application/zip", | |
| use_container_width=True | |
| ) | |
| if __name__ == "__main__": | |
| main() |