AI_Challenge2026 / utils /basic_info_chart.py
SeoYun
Claude Opus 5.5
feat: ์„œ๋น„์Šคํ˜• UI ๊ฐœํŽธ ๋ฐ ๋‹น๋‡จ๋ณ‘ ํ™•๋ฅ  ๋ณด์ •
679c619
Raw History Blame Contribute Delete
7.53 kB
"""๊ธฐ์ดˆ ์ •๋ณด(ํ˜ˆ์••/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})"