Silvio0's picture
Update src/streamlit_app.py
34dbef9 verified
Raw
History Blame Contribute Delete
40.2 kB
"""
streamlit_app.py — ArtLens: AI vs Real Art Detector
Hugging Face Space: Silvio0/Ai-Generated-vs-Real-Prediction
"""
import io
import numpy as np
import streamlit as st
from pathlib import Path
from PIL import Image
# ── Path Configuration ───────────────────────────────────────────────────────
SRC_DIR = Path(__file__).resolve().parent
BASE_DIR = SRC_DIR.parent
MODEL_PATH = SRC_DIR / "model_final_ai_vs_real.keras"
IMG_DIR = BASE_DIR / "img"
AI_ART_DIR = IMG_DIR / "AiArt"
REAL_ART_DIR = IMG_DIR / "RealArt"
AI_SAMPLES = [AI_ART_DIR / f"Ai-Image-{i}.jpg" for i in range(1, 4)]
REAL_SAMPLES = [REAL_ART_DIR / f"Real-Image-{i}.jpg" for i in range(1, 4)]
IMG_SIZE = (224, 224)
# ── Config TOML: XSRF fix + Force Light Mode ────────────────────────────────
_config_dir = Path.home() / ".streamlit"
_config_file = _config_dir / "config.toml"
_config_dir.mkdir(parents=True, exist_ok=True)
if not _config_file.exists():
_config_file.write_text(
"[server]\n"
"enableXsrfProtection = false\n"
"enableCORS = false\n"
"maxUploadSize = 200\n\n"
"[theme]\n"
'base = "light"\n'
'primaryColor = "#0ea5e9"\n'
'backgroundColor = "#f0f9ff"\n'
'secondaryBackgroundColor = "#e0f2fe"\n'
'textColor = "#0c1a2e"\n'
)
# ── Page Config ──────────────────────────────────────────────────────────────
st.set_page_config(
page_title="ArtLens — AI vs Real Art",
page_icon="🔍",
layout="wide",
initial_sidebar_state="collapsed",
)
# ── Custom CSS ───────────────────────────────────────────────────────────────
st.markdown("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap');
body, .stApp,
p, h1, h2, h3, h4, h5, h6, a,
div[class*="st-"], div[data-testid*="st"],
.stMarkdown, .stText, .stCaption,
button[kind], button[data-testid],
input, textarea, select,
td, th, li {
font-family: 'Plus Jakarta Sans', sans-serif !important;
}
html, body {
background-color: #f0f9ff !important;
}
.stApp {
background: #f0f9ff !important;
}
/* Hide Streamlit default chrome */
#MainMenu, footer, header { visibility: hidden; }
.block-container { padding-top: 1.2rem !important; padding-bottom: 2.5rem !important; }
/* ══ HERO BANNER ══ */
.artlens-hero {
background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 50%, #38bdf8 100%);
border-radius: 22px;
padding: 32px 42px;
margin-bottom: 22px;
box-shadow: 0 12px 40px rgba(14, 165, 233, 0.28);
position: relative;
overflow: hidden;
}
.artlens-hero::before {
content: '';
position: absolute;
top: -50px; right: -50px;
width: 200px; height: 200px;
background: rgba(255,255,255,0.07);
border-radius: 50%;
}
.artlens-hero::after {
content: '';
position: absolute;
bottom: -40px; left: 40px;
width: 130px; height: 130px;
background: rgba(255,255,255,0.05);
border-radius: 50%;
}
.hero-badge {
display: inline-block;
background: rgba(255,255,255,0.22);
border: 1px solid rgba(255,255,255,0.35);
color: white;
border-radius: 30px;
padding: 4px 14px;
font-size: 0.73rem;
font-weight: 700;
margin-bottom: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.hero-title {
color: white;
font-size: 2.2rem;
font-weight: 800;
margin: 0 0 6px 0;
letter-spacing: -0.02em;
text-shadow: 0 2px 10px rgba(0,0,0,0.12);
}
.hero-sub {
color: rgba(255,255,255,0.90);
font-size: 0.96rem;
margin: 0;
font-weight: 500;
max-width: 520px;
}
/* ══ TABS ══ */
.stTabs [data-baseweb="tab-list"] {
background: white !important;
border-radius: 14px !important;
padding: 6px !important;
gap: 4px !important;
box-shadow: 0 3px 18px rgba(14,165,233,0.13) !important;
border: 1.5px solid #e0f2fe !important;
}
.stTabs [role="tab"] {
border-radius: 10px !important;
color: #64748b !important;
font-weight: 600 !important;
padding: 8px 24px !important;
transition: all 0.2s ease !important;
font-size: 0.9rem !important;
}
.stTabs [role="tab"][aria-selected="true"] {
background: linear-gradient(135deg, #0ea5e9, #38bdf8) !important;
color: white !important;
box-shadow: 0 4px 14px rgba(14,165,233,0.32) !important;
}
.stTabs [data-baseweb="tab-highlight"] { display: none !important; }
/* ══ SECTION LABELS ══ */
.section-head {
font-size: 0.73rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.09em;
color: #0ea5e9;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.section-head::after {
content: '';
flex: 1;
height: 1.5px;
background: linear-gradient(to right, #bae6fd, transparent);
margin-left: 6px;
}
/* ══ SELECTED PREVIEW BANNER ══ */
.selected-banner {
background: linear-gradient(135deg, #0ea5e9, #38bdf8);
color: white;
border-radius: 12px;
padding: 10px 18px;
font-size: 0.88rem;
font-weight: 700;
text-align: center;
margin-bottom: 14px;
letter-spacing: 0.01em;
box-shadow: 0 4px 14px rgba(14,165,233,0.25);
}
/* ══ RESULT CARDS ══ */
.result-ai {
background: linear-gradient(135deg, #fff7ed, #ffedd5);
border: 2.5px solid #fb923c;
border-radius: 18px;
padding: 20px 24px;
text-align: center;
margin-bottom: 16px;
box-shadow: 0 4px 16px rgba(251,146,60,0.18);
}
.result-real {
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
border: 2.5px solid #22c55e;
border-radius: 18px;
padding: 20px 24px;
text-align: center;
margin-bottom: 16px;
box-shadow: 0 4px 16px rgba(34,197,94,0.18);
}
.result-label-text {
font-size: 1.55rem;
font-weight: 800;
color: #1e293b;
margin: 0;
letter-spacing: -0.01em;
}
.result-conf-text {
font-size: 0.93rem;
color: #64748b;
margin: 6px 0 0 0;
font-weight: 500;
}
/* ══ CONFIDENCE BAR LABELS ══ */
.conf-row {
display: flex;
justify-content: space-between;
align-items: center;
margin: 10px 0 2px 0;
font-size: 0.88rem;
font-weight: 600;
color: #334155;
}
.conf-pct {
background: #f1f5f9;
border-radius: 6px;
padding: 2px 8px;
font-size: 0.82rem;
color: #0284c7;
font-weight: 700;
}
/* ══ BUTTONS ══ */
div[data-testid="stButton"] > button {
background: linear-gradient(135deg, #0ea5e9, #38bdf8) !important;
color: white !important;
border: none !important;
border-radius: 10px !important;
font-weight: 700 !important;
font-size: 0.85rem !important;
padding: 7px 18px !important;
box-shadow: 0 4px 12px rgba(14,165,233,0.28) !important;
transition: all 0.2s ease !important;
font-family: 'Plus Jakarta Sans', sans-serif !important;
}
div[data-testid="stButton"] > button:hover {
background: linear-gradient(135deg, #0284c7, #0ea5e9) !important;
box-shadow: 0 6px 18px rgba(14,165,233,0.38) !important;
transform: translateY(-1px) !important;
}
div[data-testid="stButton"] > button:active {
transform: translateY(0) !important;
}
/* ══ FILE UPLOADER ══ */
/* Hide the label text that bleeds into/overlaps the button */
div[data-testid="stFileUploader"] > label,
div[data-testid="stFileUploader"] > div > label {
display: none !important;
height: 0 !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
}
section[data-testid="stFileUploaderDropzone"] {
background: #f0f9ff !important;
border: 2px dashed #7dd3fc !important;
border-radius: 14px !important;
transition: all 0.2s;
}
section[data-testid="stFileUploaderDropzone"] > div {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
padding: 0 !important;
gap: 14px !important;
}
section[data-testid="stFileUploaderDropzone"] > div > div,
section[data-testid="stFileUploaderDropzone"] > div > span,
section[data-testid="stFileUploaderDropzone"] > div > small {
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
section[data-testid="stFileUploaderDropzone"]:hover {
border-color: #0ea5e9 !important;
background: #e0f2fe !important;
}
/* Style the Browse Files button inside uploader */
section[data-testid="stFileUploaderDropzone"] button,
section[data-testid="stFileUploaderDropzone"] button:focus {
background: linear-gradient(135deg, #0ea5e9, #38bdf8) !important;
color: white !important;
border: none !important;
border-radius: 10px !important;
font-weight: 700 !important;
font-size: 0.85rem !important;
font-family: 'Plus Jakarta Sans', sans-serif !important;
padding: 7px 20px !important;
box-shadow: 0 4px 12px rgba(14,165,233,0.28) !important;
cursor: pointer !important;
margin: 0 auto !important;
display: block !important;
}
/* Force upload arrow icon to WHITE — brightness(100) keeps it white on the blue button */
section[data-testid="stFileUploaderDropzone"] button svg,
section[data-testid="stFileUploaderDropzone"] button svg *,
section[data-testid="stFileUploaderDropzone"] button span svg,
section[data-testid="stFileUploaderDropzone"] button span svg * {
fill: white !important;
color: white !important;
stroke: none !important;
filter: brightness(100) invert(0) !important;
}
section[data-testid="stFileUploaderDropzone"] button:hover {
background: linear-gradient(135deg, #0284c7, #0ea5e9) !important;
box-shadow: 0 6px 18px rgba(14,165,233,0.38) !important;
}
/* ══ EXPANDER ══ */
details > summary {
color: #0369a1 !important;
font-weight: 700 !important;
font-size: 0.88rem !important;
}
/* Hide the arrow icon completely — prevents "_arrow_right/_arrow_down" text artifact */
details > summary > div > div:first-child,
details > summary svg,
[data-testid="stExpanderToggleIcon"],
[data-testid="stExpanderToggleIcon"] * {
display: none !important;
}
/* ══ CAMERA ══ */
div[data-testid="stCameraInput"] > div {
border: 2px solid #7dd3fc !important;
border-radius: 14px !important;
overflow: hidden;
}
/* ══ RADIO BUTTONS ══ */
div[data-testid="stRadio"] label {
color: #1e293b !important;
font-weight: 600 !important;
}
/* ══ PROGRESS BAR ══ */
div[role="progressbar"] > div {
background: linear-gradient(90deg, #0ea5e9, #38bdf8) !important;
border-radius: 6px !important;
}
.stProgress > div > div {
height: 10px !important;
border-radius: 10px !important;
background: #e0f2fe !important;
}
/* ══ METRICS ══ */
div[data-testid="stMetric"] {
background: white !important;
border: 1.5px solid #e0f2fe !important;
border-radius: 14px !important;
padding: 16px !important;
box-shadow: 0 2px 10px rgba(14,165,233,0.07) !important;
}
div[data-testid="stMetricValue"] {
color: #0284c7 !important;
font-weight: 800 !important;
font-family: 'Plus Jakarta Sans', sans-serif !important;
}
div[data-testid="stMetricLabel"] {
color: #64748b !important;
font-weight: 600 !important;
}
/* ══ IMAGES ══ */
div[data-testid="stImage"] img {
border-radius: 12px !important;
box-shadow: 0 3px 16px rgba(0,0,0,0.09) !important;
}
/* ══ ALERTS ══ */
div[data-testid="stAlert"] {
border-radius: 12px !important;
font-family: 'Plus Jakarta Sans', sans-serif !important;
}
/* ══ DATAFRAME ══ */
div[data-testid="stDataFrame"] {
border-radius: 14px !important;
overflow: hidden !important;
box-shadow: 0 2px 14px rgba(14,165,233,0.09) !important;
border: 1.5px solid #e0f2fe !important;
}
/* ══ DIVIDER ══ */
hr { border-color: #bae6fd !important; }
/* ══ CAPTION ══ */
div[data-testid="stCaptionContainer"] p {
color: #64748b !important;
font-family: 'Plus Jakarta Sans', sans-serif !important;
}
/* ══ SPINNER ══ */
div[data-testid="stSpinner"] {
color: #0ea5e9 !important;
}
/* ══ CODE BLOCKS ══ */
code, pre {
background: #f0f9ff !important;
border-radius: 8px !important;
border: 1px solid #bae6fd !important;
font-size: 0.82rem !important;
}
/* ══ TEXT COLOR FIX — force dark text on light backgrounds ══ */
.stApp p, .stMarkdown p, div[data-testid="stMarkdownContainer"] p,
div[data-testid="stMarkdownContainer"] li,
div[data-testid="stMarkdownContainer"] span,
div[data-testid="stMarkdownContainer"] td,
div[data-testid="stMarkdownContainer"] th { color: #0c1a2e; }
/* Re-declare white for intentional white-on-blue elements */
.hero-badge { color: white !important; }
.hero-title { color: white !important; }
.hero-sub { color: rgba(255,255,255,0.90) !important; }
.selected-banner { color: white !important; }
</style>
""", unsafe_allow_html=True)
# ── Model Loading ────────────────────────────────────────────────────────────
@st.cache_resource(show_spinner="⏳ Loading model, please wait...")
def load_model():
try:
import tensorflow as tf
if not MODEL_PATH.exists():
st.error(f"❌ Model not found at: `{MODEL_PATH}`")
return None
return tf.keras.models.load_model(str(MODEL_PATH))
except Exception as exc:
st.error(f"❌ Failed to load model: {exc}")
return None
# ── Inference ────────────────────────────────────────────────────────────────
def predict_image(model, pil_img: Image.Image) -> dict:
img = pil_img.convert("RGB").resize(IMG_SIZE)
arr = np.array(img, dtype=np.float32) / 255.0
arr = np.expand_dims(arr, axis=0)
raw = float(model.predict(arr, verbose=0)[0][0])
if raw >= 0.5:
label, conf_real, conf_ai = "🖼️ Real Art", raw, 1.0 - raw
else:
label, conf_ai, conf_real = "🤖 AI Generated", 1.0 - raw, raw
return {"label": label, "conf_ai": conf_ai, "conf_real": conf_real, "raw_prob": raw}
# ── Helpers ──────────────────────────────────────────────────────────────────
def safe_open_image(path: Path) -> "Image.Image | None":
try:
if not path.exists():
return None
img = Image.open(path)
img.load() # Force load seluruh data ke memory agar tidak ada lazy I/O
return img
except Exception:
return None
# Cached loader khusus untuk Example Images — cegah reload dari disk tiap rerun
@st.cache_data(show_spinner=False)
def load_example_thumbnail(path_str: str, target_w: int = 900, target_h: int = 675) -> "Image.Image | None":
"""Load + resize ke ukuran konsisten sehingga layout tidak bergeser."""
path = Path(path_str)
try:
if not path.exists():
return None
img = Image.open(path).convert("RGB")
img.load()
# Crop tengah ke rasio 4:3 lalu resize — pastikan semua thumbnail sama tingginya
orig_w, orig_h = img.size
target_ratio = target_w / target_h
orig_ratio = orig_w / orig_h
if orig_ratio > target_ratio:
# Terlalu lebar → crop kiri-kanan
new_w = int(orig_h * target_ratio)
left = (orig_w - new_w) // 2
img = img.crop((left, 0, left + new_w, orig_h))
else:
# Terlalu tinggi → crop atas-bawah
new_h = int(orig_w / target_ratio)
top = (orig_h - new_h) // 2
img = img.crop((0, top, orig_w, top + new_h))
return img.resize((target_w, target_h), Image.LANCZOS)
except Exception:
return None
def bytes_to_pil(data: bytes) -> Image.Image:
return Image.open(io.BytesIO(data))
def render_prediction(result: dict):
label = result["label"]
conf_ai = result["conf_ai"]
conf_real = result["conf_real"]
dominant = max(conf_ai, conf_real) * 100
if "Real" in label:
st.markdown(f"""
<div class="result-real">
<p class="result-label-text">{label}</p>
<p class="result-conf-text">Model confidence: <strong>{dominant:.1f}%</strong></p>
</div>
""", unsafe_allow_html=True)
else:
st.markdown(f"""
<div class="result-ai">
<p class="result-label-text">{label}</p>
<p class="result-conf-text">Model confidence: <strong>{dominant:.1f}%</strong></p>
</div>
""", unsafe_allow_html=True)
st.markdown(f"""
<div class="conf-row">
<span>🤖 AI Generated</span>
<span class="conf-pct">{conf_ai*100:.1f}%</span>
</div>
""", unsafe_allow_html=True)
st.progress(conf_ai)
st.markdown(f"""
<div class="conf-row">
<span>🖼️ Real Art</span>
<span class="conf-pct">{conf_real*100:.1f}%</span>
</div>
""", unsafe_allow_html=True)
st.progress(conf_real)
# ── Load Model ───────────────────────────────────────────────────────────────
model = load_model()
# ── Hero Banner ──────────────────────────────────────────────────────────────
st.markdown("""
<div class="artlens-hero">
<div class="hero-badge">✦ Powered by ResNet50V2 Transfer Learning</div>
<p class="hero-title">🔍 ArtLens</p>
<p class="hero-sub">Detect whether artwork was created by AI or a human — fast, accurate, and easy to use.</p>
</div>
""", unsafe_allow_html=True)
# ── Tabs ─────────────────────────────────────────────────────────────────────
tab_home, tab_single, tab_batch, tab_example, tab_info = st.tabs([
"🏠 Home",
"🖼️ Single Image",
"📂 Batch Analysis",
"📌 Example Images",
"ℹ️ Model Info",
])
# ── Auto-switch flag — JS akan diinjeksi di BAWAH setelah semua tab di-render ──
_should_switch_to_single = st.session_state.pop("go_to_single", False)
# ═════════════════════════════════════════════════════════════════════════════
# =============================================================================
# TAB 0 — HOME
# =============================================================================
with tab_home:
st.markdown('<div class="section-head">👋 Welcome to ArtLens</div>', unsafe_allow_html=True)
# What is ArtLens card
st.markdown("""
<div style="background:white;border:1.5px solid #e0f2fe;border-radius:18px;padding:28px 32px;
box-shadow:0 4px 18px rgba(14,165,233,0.08);margin-bottom:20px;">
<p style="font-size:1.05rem;font-weight:700;color:#0284c7;margin:0 0 6px 0;">🔍 What is ArtLens?</p>
<p style="color:#334155;margin:0;line-height:1.7;">
ArtLens is an AI-powered detector that tells you whether artwork was created by a
<strong>human</strong> or generated by <strong>AI</strong>.
It uses a ResNet50V2 model trained on thousands of paintings and AI-generated images.
</p>
</div>
""", unsafe_allow_html=True)
# How to use card
st.markdown("""
<div style="background:white;border:1.5px solid #e0f2fe;border-radius:18px;padding:28px 32px;
box-shadow:0 4px 18px rgba(14,165,233,0.08);margin-bottom:20px;">
<p style="font-size:1.05rem;font-weight:700;color:#0284c7;margin:0 0 20px 0;">🚀 How to Use</p>
<div style="display:flex;align-items:flex-start;gap:16px;margin-bottom:20px;">
<div style="background:linear-gradient(135deg,#0ea5e9,#38bdf8);color:white;border-radius:50%;
min-width:34px;height:34px;display:flex;align-items:center;justify-content:center;
font-weight:800;font-size:1rem;">1</div>
<div>
<p style="margin:0;font-weight:700;color:#1e293b;">Go to the <em>Single Image</em> tab</p>
<p style="margin:4px 0 0 0;color:#64748b;font-size:0.9rem;">
Submit one image and see the prediction result right away.
</p>
</div>
</div>
<div style="display:flex;align-items:flex-start;gap:16px;margin-bottom:20px;">
<div style="background:linear-gradient(135deg,#0ea5e9,#38bdf8);color:white;border-radius:50%;
min-width:34px;height:34px;display:flex;align-items:center;justify-content:center;
font-weight:800;font-size:1rem;">2</div>
<div>
<p style="margin:0;font-weight:700;color:#1e293b;">Choose your input method</p>
<p style="margin:4px 0 0 0;color:#64748b;font-size:0.9rem;">
<strong>Upload File</strong> — drag &amp; drop or browse a JPG / PNG / WEBP from your device.<br>
<strong>Live Camera</strong> — point your camera at an artwork and capture a photo on the spot.
</p>
</div>
</div>
<div style="display:flex;align-items:flex-start;gap:16px;margin-bottom:20px;">
<div style="background:linear-gradient(135deg,#0ea5e9,#38bdf8);color:white;border-radius:50%;
min-width:34px;height:34px;display:flex;align-items:center;justify-content:center;
font-weight:800;font-size:1rem;">3</div>
<div>
<p style="margin:0;font-weight:700;color:#1e293b;">Or try an example image first</p>
<p style="margin:4px 0 0 0;color:#64748b;font-size:0.9rem;">
Head to the <strong>📌 Example Images</strong> tab.
Pick the <strong>🤖 AI Art</strong> or <strong>🖼️ Real Art</strong> sub-tab,
then click <strong>Use</strong> on any image.<br>
Once the button shows <strong>✅ Selected</strong>, head back to the <em>Single Image</em> tab yourself — the prediction will run right away.
</p>
</div>
</div>
<div style="display:flex;align-items:flex-start;gap:16px;">
<div style="background:linear-gradient(135deg,#0ea5e9,#38bdf8);color:white;border-radius:50%;
min-width:34px;height:34px;display:flex;align-items:center;justify-content:center;
font-weight:800;font-size:1rem;">4</div>
<div>
<p style="margin:0;font-weight:700;color:#1e293b;">Read the result</p>
<p style="margin:4px 0 0 0;color:#64748b;font-size:0.9rem;">
The right panel shows <strong>AI Generated</strong> or <strong>Real Art</strong>,
with a confidence score and breakdown bar for both classes.
</p>
</div>
</div>
</div>
""", unsafe_allow_html=True)
# Quick tips card
st.markdown("""
<div style="background:linear-gradient(135deg,#f0fdf4,#dcfce7);border:1.5px solid #86efac;
border-radius:18px;padding:20px 28px;box-shadow:0 4px 14px rgba(34,197,94,0.10);">
<p style="font-size:0.95rem;font-weight:700;color:#15803d;margin:0 0 8px 0;">💡 Quick Tips</p>
<ul style="color:#334155;margin:0;padding-left:18px;line-height:1.9;font-size:0.9rem;">
<li>Use <strong>clear, high-resolution</strong> images for the best accuracy.</li>
<li>Analyse <strong>multiple images</strong> at once with the <strong>📂 Batch Analysis</strong> tab.</li>
<li>Curious about the model? Check the <strong>ℹ️ Model Info</strong> tab.</li>
</ul>
</div>
""", unsafe_allow_html=True)
# TAB 1 — SINGLE IMAGE
# ═════════════════════════════════════════════════════════════════════════════
with tab_single:
col_left, col_right = st.columns([1, 1], gap="large")
# ── Left Column: Input ─────────────────────────────────────────────────
with col_left:
st.markdown('<div class="section-head">📥 Image Input</div>', unsafe_allow_html=True)
input_mode = st.radio(
"mode",
options=["📁 Upload File", "📷 Live Camera"],
horizontal=True,
label_visibility="collapsed",
key="input_mode",
)
pil_input: "Image.Image | None" = None
# ── Upload File ──────────────────────────────────────────────────
if "Upload" in input_mode:
uploaded = st.file_uploader(
" ",
type=["jpg", "jpeg", "png", "webp"],
label_visibility="collapsed",
key="file_uploader",
)
st.caption("Supports JPG · PNG · WEBP · up to 200 MB")
if uploaded is not None:
# Cache ke session_state agar tidak re-read bytes tiap rerun → cegah flicker
if st.session_state.get("_upload_cached_name") != uploaded.name:
raw_img = bytes_to_pil(uploaded.read())
raw_img.load() # Force load ke memory
st.session_state["_upload_cached_pil"] = raw_img
st.session_state["_upload_cached_name"] = uploaded.name
pil_input = st.session_state["_upload_cached_pil"]
st.session_state.pop("camera_result", None)
st.session_state.pop("example_img", None)
st.session_state.pop("example_name", None)
st.markdown(
f'<div class="selected-banner">📎 {uploaded.name}</div>',
unsafe_allow_html=True
)
st.image(pil_input, caption=f"📎 {uploaded.name}", use_container_width=True)
# ── Live Camera ────────────────────────────────────────────────
else:
st.info("📷 Point your camera at the artwork, then click **Take Photo** — preview and prediction will appear instantly.")
cam_photo = st.camera_input(
"Take a live photo",
label_visibility="collapsed",
key="camera_widget",
)
if cam_photo is not None:
# Cache camera photo in session_state to prevent flicker on rerun
cam_id = hash(cam_photo.getvalue())
if st.session_state.get("_cam_cached_id") != cam_id:
raw_cam = bytes_to_pil(cam_photo.getvalue())
raw_cam.load() # Force full load into memory
st.session_state["_cam_cached_pil"] = raw_cam
st.session_state["_cam_cached_id"] = cam_id
pil_input = st.session_state["_cam_cached_pil"]
st.session_state.pop("example_img", None)
st.session_state.pop("example_name", None)
# No manual preview here — st.camera_input already shows the captured photo
# ── Selected Example Preview ──────────────────────────────────
if pil_input is None and "example_img" in st.session_state:
ex_name = st.session_state.get("example_name", "Example Image")
st.markdown(
f'<div class="selected-banner">✅ Using: {ex_name}</div>',
unsafe_allow_html=True
)
st.image(
st.session_state["example_img"],
caption=ex_name,
use_container_width=True,
)
_, col_del2 = st.columns([3, 1])
with col_del2:
if st.button("🗑️ Delete", key="clear_example"):
st.session_state.pop("example_img", None)
st.session_state.pop("example_name", None)
st.rerun()
# Use example if no other input available
if pil_input is None and "example_img" in st.session_state:
pil_input = st.session_state["example_img"]
# ── Right Column: Prediction Results ───────────────────────────────────────
with col_right:
st.markdown('<div class="section-head">🔮 Prediction Results</div>', unsafe_allow_html=True)
if pil_input is None:
st.info(
"👈 **Start here!** \n"
"Upload an image, take a photo, or pick one from the **📌 Example Images** tab."
)
elif model is None:
st.error("❌ Model failed to load. Make sure the `.keras` file is available in the `src/` folder.")
else:
with st.spinner("🔍 Analyzing image..."):
result = predict_image(model, pil_input)
render_prediction(result)
# ═════════════════════════════════════════════════════════════════════════════
# TAB 2 — BATCH ANALYSIS
# ═════════════════════════════════════════════════════════════════════════════
with tab_batch:
st.markdown('<div class="section-head">📂 Batch Analysis</div>', unsafe_allow_html=True)
st.caption("Upload multiple images at once for simultaneous analysis.")
batch_files = st.file_uploader(
"Upload images (can be more than one)",
type=["jpg", "jpeg", "png", "webp"],
accept_multiple_files=True,
key="batch_uploader",
)
if batch_files:
if model is None:
st.error("❌ Model failed to load.")
else:
results = []
progress_bar = st.progress(0, text="Analyzing...")
preview_cols = st.columns(min(len(batch_files), 4))
for i, f in enumerate(batch_files):
img = bytes_to_pil(f.read())
res = predict_image(model, img)
results.append({
"File Name": f.name,
"Prediction": res["label"],
"Conf. AI (%)": f"{res['conf_ai']*100:.1f}",
"Conf. Real Art (%)": f"{res['conf_real']*100:.1f}",
})
with preview_cols[i % 4]:
st.image(img, caption=f.name[:20], use_container_width=True)
if "Real" in res["label"]:
st.success(res["label"], icon="🖼️")
else:
st.warning(res["label"], icon="🤖")
progress_bar.progress(
(i + 1) / len(batch_files),
text=f"Analyzing {i+1}/{len(batch_files)}..."
)
progress_bar.empty()
st.divider()
st.markdown(f'<div class="section-head">📊 Results — {len(results)} Images</div>', unsafe_allow_html=True)
try:
import pandas as pd
df = pd.DataFrame(results)
st.dataframe(df, use_container_width=True)
ai_count = sum(1 for r in results if "AI" in r["Prediction"])
real_count = len(results) - ai_count
c1, c2, c3 = st.columns(3)
c1.metric("Total Images", len(results))
c2.metric("🤖 AI Generated", ai_count)
c3.metric("🖼️ Real Art", real_count)
except ImportError:
for r in results:
st.write(r)
# ═════════════════════════════════════════════════════════════════════════════
# TAB 3 — EXAMPLE IMAGES
# ═════════════════════════════════════════════════════════════════════════════
with tab_example:
st.markdown('<div class="section-head">📌 Example Images</div>', unsafe_allow_html=True)
st.caption("Click **Use** on any image to try it — you will be taken to the Single Image tab automatically.")
# Sub-tabs: AI Art vs Real Art — single column layout inside each
ex_tab_ai, ex_tab_real = st.tabs(["🤖 AI Art", "🖼️ Real Art"])
with ex_tab_ai:
any_ai = False
for i, path in enumerate(AI_SAMPLES):
img = load_example_thumbnail(str(path))
if img:
any_ai = True
is_selected = st.session_state.get("example_name") == f"🤖 AI Art #{i+1}"
st.image(img, caption=f"AI Art #{i+1}", use_container_width=True)
btn_label = "✅ Selected" if is_selected else f"Use AI Art #{i+1}"
if st.button(btn_label, key=f"ex_use_ai_{i}", disabled=is_selected, use_container_width=True):
st.session_state["example_img"] = safe_open_image(path)
st.session_state["example_name"] = f"🤖 AI Art #{i+1}"
st.session_state.pop("camera_result", None)
st.session_state["go_to_single"] = True
st.rerun()
if not any_ai:
st.warning("⚠️ AI Art example images not found. Make sure the `img/AiArt/` folder exists.")
with ex_tab_real:
any_real = False
for i, path in enumerate(REAL_SAMPLES):
img = load_example_thumbnail(str(path))
if img:
any_real = True
is_selected = st.session_state.get("example_name") == f"🖼️ Real Art #{i+1}"
st.image(img, caption=f"Real Art #{i+1}", use_container_width=True)
btn_label = "✅ Selected" if is_selected else f"Use Real Art #{i+1}"
if st.button(btn_label, key=f"ex_use_real_{i}", disabled=is_selected, use_container_width=True):
st.session_state["example_img"] = safe_open_image(path)
st.session_state["example_name"] = f"🖼️ Real Art #{i+1}"
st.session_state.pop("camera_result", None)
st.session_state["go_to_single"] = True
st.rerun()
if not any_real:
st.warning("⚠️ Real Art example images not found. Make sure the `img/RealArt/` folder exists.")
# ═════════════════════════════════════════════════════════════════════════════
# TAB 4 — MODEL INFO
# ═════════════════════════════════════════════════════════════════════════════
with tab_info:
st.markdown('<div class="section-head">ℹ️ Model Information</div>', unsafe_allow_html=True)
col_a, col_b = st.columns(2)
with col_a:
st.markdown("**📋 Configuration**")
st.markdown(f"""
| Item | Value |
|------|-------|
| Architecture | Transfer Learning (ResNet50V2) |
| Input Size | `{IMG_SIZE[0]} × {IMG_SIZE[1]}` px |
| Output | Binary (AI / Real) |
| Threshold | `0.5` (sigmoid) |
| Model | `{MODEL_PATH.name}` |
| Status | `{"✅ Available" if MODEL_PATH.exists() else "❌ Not found"}` |
""")
with col_b:
st.markdown("**📁 Path Structure**")
st.code(
f"BASE_DIR : {BASE_DIR}\n"
f"SRC_DIR : {SRC_DIR}\n"
f"IMG_DIR : {IMG_DIR}\n"
f" AiArt/ : {AI_ART_DIR}\n"
f" Real/ : {REAL_ART_DIR}"
)
st.divider()
if model is not None:
st.success("✅ Model loaded successfully and ready to use.")
with st.expander("Model Summary — click to view"):
lines: list = []
model.summary(print_fn=lambda x: lines.append(x))
st.code("\n".join(lines), language="text")
else:
st.error("❌ Model failed to load. Make sure the `.keras` file is in the `src/` folder.")
st.divider()
st.markdown("""
**ℹ️ Deployment Notes on HuggingFace Spaces**
If you encounter an `AxiosError 403` error when uploading images, add a `.streamlit/config.toml` file to the root of your repo:
```toml
[server]
enableXsrfProtection = false
enableCORS = false
maxUploadSize = 200
```
Or make sure your Dockerfile includes:
```dockerfile
COPY .streamlit /app/.streamlit
```
""")
# ═════════════════════════════════════════════════════════════════════════════
# AUTO-SWITCH — Injeksi JS DI SINI, setelah SEMUA tab selesai di-render
# Dengan retry logic: coba klik sampai 15x tiap 100ms jika tab belum siap
# ═════════════════════════════════════════════════════════════════════════════
if _should_switch_to_single:
st.markdown("""
<script>
(function() {
function tryClickFirstTab(attemptsLeft) {
var tabs = window.parent.document.querySelectorAll('[data-baseweb="tab"]');
if (tabs && tabs.length > 1) {
tabs[1].click();
} else if (attemptsLeft > 0) {
setTimeout(function() { tryClickFirstTab(attemptsLeft - 1); }, 100);
}
}
// Start after 200ms, then retry up to 15x every 100ms = 1.7s total window
setTimeout(function() { tryClickFirstTab(15); }, 200);
})();
</script>
""", unsafe_allow_html=True)
# ═════════════════════════════════════════════════════════════════════════════
# FOOTER
# ═════════════════════════════════════════════════════════════════════════════
st.markdown("""
<div style="text-align:center; padding: 24px 0 8px 0; margin-top: 30px;
border-top: 1px solid #e0f2fe; color: #94a3b8; font-size: 0.85rem;">
<p style="margin:0 0 8px 0; font-weight:700; color:#0284c7;">Developed by</p>
<p style="margin:0; line-height:1.8;">
Gabriella Jovanka Bustan — A11.2023.14861<br>
Silvio Christian, Joe — A11.2023.14864<br>
Muhamad Taqi — A11.2023.14888<br>
Hanaafi Arya Ditta — A11.2023.15132
</p>
</div>
""", unsafe_allow_html=True)