pokemon-classifier / src /streamlit_app.py
gyann's picture
Update src/streamlit_app.py
ab5fa16 verified
Raw
History Blame Contribute Delete
16.4 kB
import streamlit as st
import torch
import requests
import os
import json
from transformers import AutoImageProcessor, AutoModelForImageClassification, AutoConfig
from peft import PeftModel, PeftConfig
from PIL import Image
import torch.nn.functional as F
import io
# 1. ํŽ˜์ด์ง€ ๊ธฐ๋ณธ ์„ค์ • (๊ฐ€์žฅ ๋จผ์ € ํ˜ธ์ถœํ•ด์•ผ ํ•จ)
st.set_page_config(page_title="Pokemon Classifier", page_icon="๐Ÿพ", layout="wide", initial_sidebar_state="collapsed")
# 2. ์ปค์Šคํ…€ CSS ์ ์šฉ (๋” ์„œ๋น„์Šค์Šค๋Ÿฌ์šด ๋А๋‚Œ์„ ์œ„ํ•ด)
st.markdown("""
<style>
.main-header {
text-align: center;
padding: 2rem 0 3rem 0;
}
.main-header h1 {
font-size: 3.5rem;
color: #ffcb05;
-webkit-text-stroke: 2px #3c5aa6;
text-shadow: 4px 4px 0px #3c5aa6;
margin-bottom: 0.5rem;
}
.main-header p {
font-size: 1.2rem;
color: #555;
font-weight: 500;
}
/* ์นด๋“œ ๋А๋‚Œ์˜ ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์œ„ํ•ด ์—ฌ๋ฐฑ ์กฐ์ • */
div[data-testid="stVerticalBlock"] {
gap: 1.2rem;
}
</style>
<div class="main-header">
<h1>Pokemon Classifier</h1>
</div>
""", unsafe_allow_html=True)
# 3. ๋งŒ๋Šฅ ๋ชจ๋ธ ๋กœ๋“œ ํ•จ์ˆ˜ (์บ์‹ฑ & ์—๋Ÿฌ ๋ฐฉ์–ด ์ ์šฉ)
@st.cache_resource(show_spinner=False)
def load_model(model_path):
try:
# ํ—ˆ๊น…ํŽ˜์ด์Šค ๋ฆฌํฌ์ง€ํ† ๋ฆฌ ๋˜๋Š” ๋กœ์ปฌ ๊ฒฝ๋กœ์—์„œ PEFT ์„ค์ • ํŒŒ์ผ ํ™•์ธ
try:
config = PeftConfig.from_pretrained(model_path)
is_peft = True
except:
is_peft = False
if is_peft:
base_model_name = config.base_model_name_or_path
try:
processor = AutoImageProcessor.from_pretrained(model_path)
except:
processor = AutoImageProcessor.from_pretrained(base_model_name)
# 1. ํฌ์ผ“๋ชฌ ๋ถ„๋ฅ˜๋Š” ๋ฌด์กฐ๊ฑด 150๊ฐœ์˜ ํด๋ž˜์Šค์ž…๋‹ˆ๋‹ค.
num_labels = 150
# 2. ๋ผ๋ฒจ ๋งคํ•‘ ๋”•์…”๋„ˆ๋ฆฌ ๊ตฌ์„ฑ (์ง์ ‘ ์ƒ์„ฑํ•˜์—ฌ ์ถฉ๋Œ ๋ฐฉ์ง€)
try:
# ๋กœ์ปฌ์— ์ €์žฅ๋œ Full FT ๋ชจ๋ธ์˜ config๊ฐ€ ์žˆ๋‹ค๋ฉด ๊ฐ€์žฅ ์™„๋ฒฝํ•œ ํฌ์ผ“๋ชฌ ์ด๋ฆ„ ๋”•์…”๋„ˆ๋ฆฌ ์‚ฌ์šฉ
config_path = "./saved_model/best_vit_full/config.json"
with open(config_path, "r", encoding="utf-8") as f:
full_config = json.load(f)
id2label = {int(k): v for k, v in full_config["id2label"].items()}
label2id = full_config["label2id"]
except Exception:
try:
# ๋กœ์ปฌ ํŒŒ์ผ์ด ์—†์œผ๋ฉด ๋ฐฐํฌ๋œ ํ—ˆ๊น…ํŽ˜์ด์Šค ์ €์žฅ์†Œ์—์„œ ์˜ฌ๋ฐ”๋ฅธ ๋งคํ•‘์„ ๊ฐ•์ œ๋กœ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค.
reference_config = AutoConfig.from_pretrained("gyann/pokemon-vit-full")
id2label = {int(k): v for k, v in reference_config.id2label.items()}
label2id = reference_config.label2id
except Exception:
# ์ตœํ›„์˜ ์ˆ˜๋‹จ์œผ๋กœ ๋”๋ฏธ ์ƒ์„ฑ
id2label = {i: f"LABEL_{i}" for i in range(num_labels)}
label2id = {f"LABEL_{i}": i for i in range(num_labels)}
# 3. Base ๋ชจ๋ธ์„ ๋กœ๋“œํ•  ๋•Œ ๋ฐ˜๋“œ์‹œ num_labels๋ฅผ ๋ช…์‹œํ•ด์•ผ classifier ํ—ค๋“œ ์‚ฌ์ด์ฆˆ๊ฐ€ 150์œผ๋กœ ์ดˆ๊ธฐํ™”๋ฉ๋‹ˆ๋‹ค.
base_model = AutoModelForImageClassification.from_pretrained(
base_model_name,
num_labels=num_labels,
id2label=id2label,
label2id=label2id,
ignore_mismatched_sizes=True
)
# 4. 150 ์‚ฌ์ด์ฆˆ๋กœ ๋งž์ถฐ์ง„ Base ๋ชจ๋ธ์— LoRA ๊ฐ€์ค‘์น˜ ๊ฒฐํ•ฉ
model = PeftModel.from_pretrained(base_model, model_path)
else:
processor = AutoImageProcessor.from_pretrained(model_path)
model = AutoModelForImageClassification.from_pretrained(model_path)
# ๋น„-PEFT ๋ชจ๋ธ๋„ config์— ์ด๋ฆ„์ด ์—†๋Š” ๊ฒฝ์šฐ(Label_15 ๋“ฑ)๋ฅผ ๋Œ€๋น„ํ•ด ๋งคํ•‘์„ ๋ฎ์–ด์”Œ์›๋‹ˆ๋‹ค.
if getattr(model.config, "id2label", {}).get(0, "") == "LABEL_0" or getattr(model.config, "id2label", {}).get("0", "") == "LABEL_0":
try:
reference_config = AutoConfig.from_pretrained("gyann/pokemon-vit-full")
model.config.id2label = {int(k): v for k, v in reference_config.id2label.items()}
model.config.label2id = reference_config.label2id
except Exception:
pass
device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(device)
model.eval()
return processor, model, device, None
except Exception as e:
return None, None, None, str(e)
# 4. PokeAPI ๋ฐ์ดํ„ฐ ํ˜ธ์ถœ ํ•จ์ˆ˜ (์ด๋ฏธ์ง€, ํƒ€์ž…, ํ‚ค, ๋ชธ๋ฌด๊ฒŒ)
@st.cache_data(show_spinner=False)
def get_pokemon_data(pokemon_name):
try:
name_lower = pokemon_name.lower().replace(" ", "-").replace(".", "").replace("'", "")
url = f"https://pokeapi.co/api/v2/pokemon/{name_lower}"
response = requests.get(url)
if response.status_code == 200:
data = response.json()
return {
"artwork_url": data["sprites"]["other"]["official-artwork"]["front_default"],
"types": [t["type"]["name"] for t in data["types"]],
"height": data["height"] / 10.0, # meters
"weight": data["weight"] / 10.0 # kg
}
return None
except:
return None
MODEL_PATHS = {
"ViT Full Fine-tuning": "gyann/pokemon-vit-full",
"ViT + LoRA": "gyann/pokemon-vit-lora",
"ViT + QLoRA (4-bit)": "gyann/pokemon-vit-qlora",
"ResNet50": "gyann/pokemon-resnet50",
"ConvNeXt": "gyann/pokemon-convnext",
"Swin Transformer": "gyann/pokemon-swin"
}
# 5. ์„ค์ • ๋ฐ ์ž…๋ ฅ ์˜์—ญ (๊น”๋”ํ•œ ์ปจํ…Œ์ด๋„ˆ UI)
with st.container(border=True):
st.markdown("### โš™๏ธ ๋ถ„์„ ์„ค์ •")
setting_col1, setting_col2 = st.columns([1, 2])
with setting_col1:
compare_models = st.toggle("๋ชจ๋ธ ๋น„๊ต ๋ชจ๋“œ ํ™œ์„ฑํ™”", value=False)
st.caption(f"๐Ÿš€ ํ˜„์žฌ ๊ฐ€์† ์žฅ์น˜: **{'GPU (CUDA)' if torch.cuda.is_available() else 'CPU'}**")
with setting_col2:
if compare_models:
col_a, col_b = st.columns(2)
with col_a:
model_name_a = st.selectbox("Model A", list(MODEL_PATHS.keys()), index=0)
with col_b:
model_name_b = st.selectbox("Model B", list(MODEL_PATHS.keys()), index=1)
with st.spinner("๋ชจ๋ธ A ๋กœ๋”ฉ ์ค‘..."):
processor_a, model_a, device_a, err_a = load_model(MODEL_PATHS[model_name_a])
with st.spinner("๋ชจ๋ธ B ๋กœ๋”ฉ ์ค‘..."):
processor_b, model_b, device_b, err_b = load_model(MODEL_PATHS[model_name_b])
if err_a: st.error(f"Model A ๋กœ๋“œ ์‹คํŒจ: {err_a}")
if err_b: st.error(f"Model B ๋กœ๋“œ ์‹คํŒจ: {err_b}")
active_models = [
("Model A", model_name_a, processor_a, model_a, device_a),
("Model B", model_name_b, processor_b, model_b, device_b)
]
else:
model_name = st.selectbox("๋ถ„์„์— ์‚ฌ์šฉํ•  ๋ชจ๋ธ", list(MODEL_PATHS.keys()))
with st.spinner("๋ชจ๋ธ ๋กœ๋”ฉ ์ค‘..."):
processor, model, device, err = load_model(MODEL_PATHS[model_name])
if err: st.error(f"๋ชจ๋ธ ๋กœ๋“œ ์‹คํŒจ: {err}")
active_models = [("Result", model_name, processor, model, device)]
with st.container(border=True):
st.markdown("### ๐Ÿ–ผ๏ธ ์ด๋ฏธ์ง€ ์„ ํƒ")
EXAMPLE_IMAGES = {
"์ง์ ‘ ํŒŒ์ผ ์—…๋กœ๋“œํ•˜๊ธฐ": None,
"์˜ˆ์‹œ: ํ”ผ์นด์ธ„ (Pikachu)": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/25.png",
"์˜ˆ์‹œ: ์ด์ƒํ•ด์”จ (Bulbasaur)": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/1.png",
"์˜ˆ์‹œ: ๊ผฌ๋ถ€๊ธฐ (Squirtle)": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/7.png",
"์˜ˆ์‹œ: ํŒŒ์ด๋ฆฌ (Charmander)": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/4.png"
}
# ๋ผ๋””์˜ค ๋ฒ„ํŠผ์„ ๊ฐ€๋กœ๋กœ ๋ฐฐ์น˜ํ•˜์—ฌ ๋ชจ๋˜ํ•˜๊ฒŒ
selected_example = st.radio("ํ…Œ์ŠคํŠธ ๋ฐฉ์‹์„ ์„ ํƒํ•˜์„ธ์š”", list(EXAMPLE_IMAGES.keys()), horizontal=True, label_visibility="collapsed")
image_to_process = None
if selected_example == "์ง์ ‘ ํŒŒ์ผ ์—…๋กœ๋“œํ•˜๊ธฐ":
uploaded_file = st.file_uploader("ํฌ์ผ“๋ชฌ ์ด๋ฏธ์ง€๋ฅผ ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญํ•˜์„ธ์š”", type=["jpg", "jpeg", "png"], label_visibility="collapsed")
if uploaded_file is not None:
image_to_process = Image.open(uploaded_file).convert("RGB")
else:
example_url = EXAMPLE_IMAGES[selected_example]
try:
response = requests.get(example_url)
if response.status_code == 200:
image_to_process = Image.open(io.BytesIO(response.content)).convert("RGB")
except Exception as e:
st.error(f"์˜ˆ์‹œ ์ด๋ฏธ์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {e}")
# 6. ์ถ”๋ก  ๋ฐ ๊ฒฐ๊ณผ ์ถœ๋ ฅ ์ปดํฌ๋„ŒํŠธ
def predict_and_display(name_prefix, model_name, processor, model, device, image):
if model is None:
st.warning(f"๋ชจ๋ธ({model_name})์ด ์ •์ƒ์ ์œผ๋กœ ๋กœ๋“œ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
return
inputs = processor(images=image, return_tensors="pt").to(device)
with torch.no_grad():
outputs = model(**inputs)
logits = outputs.logits
probs = F.softmax(logits, dim=-1)[0]
top5_prob, top5_catid = torch.topk(probs, 5)
cat_id_0 = top5_catid[0].item()
top1_label = model.config.id2label.get(cat_id_0, model.config.id2label.get(str(cat_id_0), "Unknown"))
top1_score = top5_prob[0].item()
poke_data = get_pokemon_data(top1_label)
st.markdown(f"<h5 style='text-align: center; color: #777; margin-bottom: 0;'>{name_prefix}: {model_name}</h5>", unsafe_allow_html=True)
# ๋ฉ”ํŠธ๋ฆญ
st.metric(label="๐Ÿ† Top-1 Prediction", value=top1_label.title(), delta=f"{top1_score*100:.1f}% Confidence", delta_color="normal")
tab1, tab2, tab3 = st.tabs(["๐Ÿ“ Overview", "๐Ÿ“Š Stats", "๐Ÿ” Top-5 Details"])
with tab1:
if poke_data and poke_data["artwork_url"]:
st.image(poke_data["artwork_url"], use_container_width=True)
else:
st.info("๊ณต์‹ ์ผ๋Ÿฌ์ŠคํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")
with tab2:
if poke_data:
st.markdown(f"**์†์„ฑ(Types)**: {', '.join(poke_data['types']).title()}")
st.markdown(f"**์‹ ์žฅ(Height)**: {poke_data['height']} m")
st.markdown(f"**์ฒด์ค‘(Weight)**: {poke_data['weight']} kg")
else:
st.warning("๋„๊ฐ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
with tab3:
for i in range(top5_prob.size(0)):
c_id = top5_catid[i].item()
lbl = model.config.id2label.get(c_id, model.config.id2label.get(str(c_id), "Unknown"))
scr = top5_prob[i].item()
st.caption(f"**{i+1}. {lbl.title()}**")
st.progress(scr, text=f"{scr * 100:.1f}%")
# 7. ๋ฉ”์ธ ์‹คํ–‰ ๋ถ€
if image_to_process is not None:
st.markdown("---")
with st.container():
if compare_models:
col_img, col_res = st.columns([1, 2.5])
with col_img:
st.markdown("### ๐Ÿ“ท ์ž…๋ ฅ ์ด๋ฏธ์ง€")
st.image(image_to_process, use_container_width=True)
with col_res:
st.markdown("### โœจ ๋ถ„์„ ๊ฒฐ๊ณผ")
col1, col2 = st.columns(2)
with col1:
with st.container(border=True):
m_prefix, m_name, m_proc, m_model, m_dev = active_models[0]
with st.spinner('๋ถ„์„ ์ค‘...'):
predict_and_display(m_prefix, m_name, m_proc, m_model, m_dev, image_to_process)
with col2:
with st.container(border=True):
m_prefix, m_name, m_proc, m_model, m_dev = active_models[1]
with st.spinner('๋ถ„์„ ์ค‘...'):
predict_and_display(m_prefix, m_name, m_proc, m_model, m_dev, image_to_process)
else:
col1, col2 = st.columns([1, 1.5])
with col1:
st.markdown("### ๐Ÿ“ท ์ž…๋ ฅ ์ด๋ฏธ์ง€")
st.image(image_to_process, use_container_width=True)
with col2:
st.markdown("### โœจ ๋ถ„์„ ๊ฒฐ๊ณผ")
with st.container(border=True):
m_prefix, m_name, m_proc, m_model, m_dev = active_models[0]
with st.spinner('๋ถ„์„ ์ค‘...'):
predict_and_display(m_prefix, m_name, m_proc, m_model, m_dev, image_to_process)
# 8. ๋ชจ๋ธ ์•„ํ‚คํ…์ฒ˜ ์ •๋ณด UI (์ตœํ•˜๋‹จ ๋ฐฐ์น˜)
ARCHITECTURE_INFO = {
"ViT Full Fine-tuning": {
"title": "Vision Transformer (ViT) - Full FT",
"desc": "์ด๋ฏธ์ง€๋ฅผ 16x16 ํ”ฝ์…€ ํŒจ์น˜(Patch)๋กœ ๋‚˜๋ˆ„์–ด ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฏธ์ง€ ์ „์ฒด์˜ **์ „์—ญ์  ๋ฌธ๋งฅ(Global Context)** ์„ ํŒŒ์•…ํ•˜๋Š” ๋ฐ ๋›ฐ์–ด๋‚˜๋ฉฐ, ๋ชจ๋“  ๊ฐ€์ค‘์น˜๋ฅผ ์žฌํ•™์Šตํ•˜์—ฌ ์ตœ๊ณ  ์„ฑ๋Šฅ์„ ๋„์ถœํ•˜์ง€๋งŒ ์—ฐ์‚ฐ ๋น„์šฉ์ด ๊ฐ€์žฅ ํฝ๋‹ˆ๋‹ค."
},
"ViT + LoRA": {
"title": "ViT with LoRA (Low-Rank Adaptation)",
"desc": "๊ฑฐ๋Œ€ํ•œ ์›๋ณธ ๋ชจ๋ธ์„ ์–ผ๋ ค๋‘๊ณ (Freeze), ํ•ต์‹ฌ ์—ฐ์‚ฐ์ธต์— ์•„์ฃผ ์–‡์€ **ํ•™์Šต ๊ฐ€๋Šฅํ•œ ์šฐํšŒ๋กœ(์ €๋žญํฌ ํ–‰๋ ฌ)** ๋ฅผ ๋ง๋ถ™์ž…๋‹ˆ๋‹ค. ๋‹จ 1%์˜ ํŒŒ๋ผ๋ฏธํ„ฐ๋งŒ ํ•™์Šตํ•˜์—ฌ ๋น„์šฉ์„ ๊ทน์ ์œผ๋กœ ๋‚ฎ์ถ”๋ฉด์„œ๋„ Full FT์™€ ์œ ์‚ฌํ•œ ์„ฑ๋Šฅ์„ ๋ƒ…๋‹ˆ๋‹ค."
},
"ViT + QLoRA (4-bit)": {
"title": "ViT with QLoRA (Quantized LoRA)",
"desc": "LoRA์—์„œ ํ•œ ๋ฐœ ๋” ๋‚˜์•„๊ฐ€, ์›๋ณธ ๋ชจ๋ธ์„ **4-bit ์ •๋ฐ€๋„** ๋กœ ์••์ถ•(Quantization)ํ•˜์—ฌ ๋ฉ”๋ชจ๋ฆฌ์— ์ ์žฌํ•ฉ๋‹ˆ๋‹ค. VRAM์ด ๋งค์šฐ ๋ถ€์กฑํ•œ ํ™˜๊ฒฝ์—์„œ๋„ ๋Œ€๊ทœ๋ชจ ๋ชจ๋ธ์„ ํŠœ๋‹ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•˜๋Š” ์ตœ์ ํ™” ๊ธฐ๋ฒ•์ž…๋‹ˆ๋‹ค."
},
"ResNet50": {
"title": "ResNet50 (Baseline CNN)",
"desc": "ํ•ฉ์„ฑ๊ณฑ(Convolution) ํ•„ํ„ฐ๋ฅผ ๊ฒน์ณ ์ด๋ฏธ์ง€์˜ **๊ตญ์†Œ์  ํŒจํ„ด(Local Feature)** ์„ ์ฐพ์•„๋‚ด๋Š” ์ „ํ†ต์˜ ๊ฐ•์ž์ž…๋‹ˆ๋‹ค. ์ž”์ฐจ ์—ฐ๊ฒฐ(Residual Connection)๋กœ ๊นŠ์€ ์‹ ๊ฒฝ๋ง์˜ ํ•™์Šต ์•ˆ์ •์„ฑ์„ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค."
},
"ConvNeXt": {
"title": "ConvNeXt (Modernized CNN)",
"desc": "ํŠธ๋žœ์Šคํฌ๋จธ์˜ ์„ค๊ณ„ ์ฒ ํ•™(ํฐ ์ปค๋„, LayerNorm, GELU ๋“ฑ)์„ ์—ญ์œผ๋กœ CNN์— ๋„์ž…ํ•œ **'๋ชจ๋˜ CNN'** ์ž…๋‹ˆ๋‹ค. CNN์˜ ์ง€์—ญ์  ๊ท€๋‚ฉ์  ํŽธํ–ฅ์„ ์œ ์ง€ํ•˜๋ฉด์„œ๋„ ํŠธ๋žœ์Šคํฌ๋จธ๊ธ‰ ์„ฑ๋Šฅ์„ ๋ƒ…๋‹ˆ๋‹ค."
},
"Swin Transformer": {
"title": "Swin Transformer (Hierarchical ViT)",
"desc": "CNN์ฒ˜๋Ÿผ ์ž‘์€ ์˜์—ญ(Window)๋ถ€ํ„ฐ ์ ์  ๋„“์€ ์˜์—ญ์œผ๋กœ **๊ณ„์ธต์ (Hierarchical)** ์œผ๋กœ ๋ณ‘ํ•ฉํ•˜๋ฉฐ ํ•™์Šตํ•˜๋Š” ํŠธ๋žœ์Šคํฌ๋จธ์ž…๋‹ˆ๋‹ค. ViT๊ฐ€ ๋†“์น˜๊ธฐ ์‰ฌ์šด ๋ฏธ์„ธํ•œ ๋””ํ…Œ์ผ ๊ตฌ๋ถ„์— ๊ฐ•ํ•ฉ๋‹ˆ๋‹ค."
}
}
st.markdown("---")
st.markdown("#### ๐Ÿง  ์„ ํƒ๋œ ์•„ํ‚คํ…์ฒ˜ ์•Œ์•„๋ณด๊ธฐ")
if compare_models:
col_info1, col_info2 = st.columns(2)
with col_info1:
with st.expander(f"{model_name_a} ๊ตฌ์กฐ", expanded=False):
info = ARCHITECTURE_INFO.get(model_name_a, {"title": model_name_a, "desc": "์„ค๋ช…์ด ์ค€๋น„๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."})
st.markdown(f"**{info['title']}**\n\n{info['desc']}")
with col_info2:
with st.expander(f"{model_name_b} ๊ตฌ์กฐ", expanded=False):
info = ARCHITECTURE_INFO.get(model_name_b, {"title": model_name_b, "desc": "์„ค๋ช…์ด ์ค€๋น„๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."})
st.markdown(f"**{info['title']}**\n\n{info['desc']}")
else:
with st.expander(f"{model_name} ๊ตฌ์กฐ", expanded=False):
info = ARCHITECTURE_INFO.get(model_name, {"title": model_name, "desc": "์„ค๋ช…์ด ์ค€๋น„๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."})
st.markdown(f"**{info['title']}**\n\n{info['desc']}")