"""기초 정보(혈압/BMI/허리둘레/TG/TCHL/HDL)를 표 대신 포레스트 플롯 느낌의 점(dot) + 기준선(line) 차트로 그린다. 항목마다 얇은 회색 기준선 위에 "정상" 구간을 파스텔 초록 굵은 선으로 강조하고, 이 사람의 실제 값은 그 구간에 속한 상태에 맞는 파스텔 톤 점으로 표시한다(색만으로 판단하지 않도록 값과 구간 이름을 항상 점 옆에 글자로도 같이 보여준다). 구간 경계값은 utils/clinical_reference.CHART_ZONES(=classify_*()와 같은 경계)를 그대로 쓴다. 리포트는 Gradio의 gr.Markdown 컴포넌트 하나에 문자열로 들어가므로, 새 컴포넌트를 추가하는 대신 PNG를 base64로 인코딩해 마크다운 이미지 문법 (data URI)으로 그 문자열 안에 그대로 박아 넣는다 - app.py/report_writer.py 쪽 구조를 안 바꿔도 된다. """ import base64 import colorsys import io from pathlib import Path import matplotlib matplotlib.use("Agg") # Gradio 서버 프로세스에 디스플레이가 없음 import matplotlib.font_manager as fm # noqa: E402 - matplotlib.use()가 먼저 실행돼야 함 import matplotlib.pyplot as plt # noqa: E402 from .clinical_reference import CHART_ZONES, classify_bmi, classify_hdl, classify_waist # matplotlib 기본 폰트(DejaVu Sans)엔 한글 글리프가 없어서 라벨이 네모(□)로 # 깨진다. HF Spaces 컨테이너에도 한글 폰트가 깔려있다는 보장이 없어서, # 이 차트에 실제로 쓰는 글자만 추출해 8.5KB로 서브셋한 폰트를 # assets/fonts/에 같이 커밋해두고 여기서 직접 로드한다(시스템 폰트에 # 의존하지 않음 - 차트 라벨 문구를 바꾸면 이 서브셋도 다시 만들어야 함). _FONT_PATH = Path(__file__).resolve().parent.parent / "assets" / "fonts" / "ChartFontKR-Subset.otf" fm.fontManager.addfont(str(_FONT_PATH)) _FONT_FAMILY = fm.FontProperties(fname=str(_FONT_PATH)).get_name() plt.rcParams["font.family"] = _FONT_FAMILY plt.rcParams["axes.unicode_minus"] = False # 이 폰트 서브셋엔 유니코드 마이너스 글리프가 없음 TEXT = "#0b0b0b" MUTED = "#8a8983" BASELINE = "#dedcd3" SURFACE = "#fcfcfb" def _pastel(hex_color: str, white_frac: float = 0.62) -> str: """status 팔레트 색을 흰색과 섞어 파스텔 톤으로 낮춘다(같은 색상군을 유지해 good/warning/serious/critical의 의미 순서는 그대로 두고, 채도만 눈에 편하게 낮춤 - 임의의 색을 새로 고르지 않고 계산으로 파생시킨다).""" r, g, b = (int(hex_color[i : i + 2], 16) / 255 for i in (1, 3, 5)) r = r + (1 - r) * white_frac g = g + (1 - g) * white_frac b = b + (1 - b) * white_frac return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}" def _darker_for_text(hex_color: str) -> str: """점 옆 라벨 글자용으로, 파스텔 배경에서도 읽히도록 같은 색조를 눌러 어둡게 만든다(라벨은 색이 아니라 글자로 상태를 전달하는 게 목적이라 은근하게만 - 진하게 키워봤자 보조 신호일 뿐).""" r, g, b = (int(hex_color[i : i + 2], 16) / 255 for i in (1, 3, 5)) h, l, s = colorsys.rgb_to_hls(r, g, b) r2, g2, b2 = colorsys.hls_to_rgb(h, max(l * 0.55, 0.28), min(s * 1.3, 1.0)) return f"#{int(r2 * 255):02x}{int(g2 * 255):02x}{int(b2 * 255):02x}" def _waist_zones(sex: float) -> tuple: cutoff = 90 if sex == 1 else 85 return (max(0, cutoff - 30), cutoff + 30, [ (max(0, cutoff - 30), cutoff, "#0ca30c", "정상"), (cutoff, cutoff + 30, "#d03b3b", "복부비만"), ]) def _hdl_zones(sex: float) -> tuple: cutoff = 40 if sex == 1 else 50 return (20, 90, [ (20, cutoff, "#ec835a", "낮음(위험인자)"), (cutoff, 60, "#0ca30c", "정상"), (60, 90, "#0ca30c", "높음(보호인자)"), ]) def _build_rows(input_values: dict) -> list: """반환: [(라벨, 단위, 값, (lo,hi,zones)), ...] - 값이 있는 항목만.""" rows = [] sex = input_values.get("SEX") sbp = input_values.get("SBP") if sbp is not None: rows.append(("수축기 혈압", "mmHg", sbp, CHART_ZONES["SBP"])) dbp = input_values.get("DBP") if dbp is not None: rows.append(("이완기 혈압", "mmHg", dbp, CHART_ZONES["DBP"])) height, weight = input_values.get("HEIGHT"), input_values.get("WEIGHT") if height is not None and weight is not None: bmi, _ = classify_bmi(height, weight) rows.append(("BMI", "kg/m²", bmi, CHART_ZONES["BMI"])) waist = input_values.get("WAIST") if waist is not None and sex is not None: rows.append(("허리둘레", "cm", waist, _waist_zones(sex))) tg = input_values.get("TG") if tg is not None: rows.append(("중성지방", "mg/dL", tg, CHART_ZONES["TG"])) tchl = input_values.get("TCHL") if tchl is not None: rows.append(("총콜레스테롤", "mg/dL", tchl, CHART_ZONES["TCHL"])) hdl = input_values.get("HDL") if hdl is not None and sex is not None: rows.append(("HDL", "mg/dL", hdl, _hdl_zones(sex))) return rows def _zone_for_value(value: float, zones: list) -> tuple: """value가 속한 (color, name)을 찾는다. 못 찾으면(범위 밖) 가장 가까운 구간으로 대체.""" for z_lo, z_hi, color, name in zones: if z_lo <= value < z_hi: return color, name return zones[0][2], zones[0][3] if value < zones[0][0] else (zones[-1][2], zones[-1][3]) def render_basic_info_chart_markdown(input_values: dict) -> str: """기초 정보 차트를 base64 PNG data URI를 품은 마크다운 이미지 문자열로 반환한다. 표시할 항목이 하나도 없으면 빈 문자열을 반환한다.""" rows = _build_rows(input_values) if not rows: return "" fig, axes = plt.subplots(len(rows), 1, figsize=(6.8, 0.72 * len(rows) + 0.3), facecolor=SURFACE) if len(rows) == 1: axes = [axes] for ax, (label, unit, value, (lo, hi, zones)) in zip(axes, rows): ax.set_facecolor(SURFACE) # 기준선(얇은 회색) - 이 항목이 있을 수 있는 전체 범위 ax.hlines(0, lo, hi, color=BASELINE, linewidth=2, zorder=1, capstyle="round") v = min(max(value, lo), hi) zone_color, zone_name = _zone_for_value(value, zones) dot_color = _pastel(zone_color, 0.45) label_color = _darker_for_text(zone_color) ax.plot( v, 0, marker="o", markersize=13, color=dot_color, markeredgecolor="white", markeredgewidth=1.6, zorder=5, clip_on=False, ) ax.text( v, 0.4, f"{value:g}{unit}", ha="center", va="bottom", fontsize=9.5, color=TEXT, fontweight="bold", zorder=6, ) ax.text(v, -0.42, zone_name, ha="center", va="top", fontsize=8, color=label_color, zorder=6) ax.set_xlim(lo, hi) ax.set_ylim(-0.65, 0.65) ax.set_yticks([]) ax.set_xticks([]) for spine in ax.spines.values(): spine.set_visible(False) ax.text(lo, 0, f"{label} ", ha="right", va="center", fontsize=9.5, color=TEXT, fontweight="medium") fig.tight_layout(h_pad=1.7) buf = io.BytesIO() fig.savefig(buf, format="png", dpi=160, facecolor=SURFACE, bbox_inches="tight") plt.close(fig) encoded = base64.b64encode(buf.getvalue()).decode("ascii") return f"![기초 정보 차트](data:image/png;base64,{encoded})"