| """ |
| 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 |
|
|
| |
| 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_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' |
| ) |
|
|
| |
| st.set_page_config( |
| page_title="ArtLens — AI vs Real Art", |
| page_icon="🔍", |
| layout="wide", |
| initial_sidebar_state="collapsed", |
| ) |
|
|
| |
| 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) |
|
|
|
|
| |
| @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 |
|
|
|
|
| |
| 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} |
|
|
|
|
| |
| def safe_open_image(path: Path) -> "Image.Image | None": |
| try: |
| if not path.exists(): |
| return None |
| img = Image.open(path) |
| img.load() |
| return img |
| except Exception: |
| return None |
|
|
| |
| @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() |
| |
| orig_w, orig_h = img.size |
| target_ratio = target_w / target_h |
| orig_ratio = orig_w / orig_h |
| if orig_ratio > target_ratio: |
| |
| new_w = int(orig_h * target_ratio) |
| left = (orig_w - new_w) // 2 |
| img = img.crop((left, 0, left + new_w, orig_h)) |
| else: |
| |
| 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) |
|
|
|
|
| |
| model = load_model() |
|
|
| |
| 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) |
|
|
| |
| tab_home, tab_single, tab_batch, tab_example, tab_info = st.tabs([ |
| "🏠 Home", |
| "🖼️ Single Image", |
| "📂 Batch Analysis", |
| "📌 Example Images", |
| "ℹ️ Model Info", |
| ]) |
|
|
|
|
| |
| _should_switch_to_single = st.session_state.pop("go_to_single", False) |
|
|
|
|
| |
|
|
| |
| |
| |
| with tab_home: |
| st.markdown('<div class="section-head">👋 Welcome to ArtLens</div>', unsafe_allow_html=True) |
|
|
| |
| 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) |
|
|
| |
| 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 & 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) |
|
|
| |
| 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) |
|
|
|
|
| |
| |
| with tab_single: |
| col_left, col_right = st.columns([1, 1], gap="large") |
|
|
| |
| 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 |
|
|
| |
| 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: |
| |
| if st.session_state.get("_upload_cached_name") != uploaded.name: |
| raw_img = bytes_to_pil(uploaded.read()) |
| raw_img.load() |
| 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) |
|
|
| |
| 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: |
| |
| 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() |
| 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) |
| |
|
|
| |
| 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() |
|
|
| |
| if pil_input is None and "example_img" in st.session_state: |
| pil_input = st.session_state["example_img"] |
|
|
| |
| 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) |
|
|
|
|
| |
| |
| |
| 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) |
|
|
|
|
| |
| |
| |
| 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.") |
|
|
| |
| 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.") |
|
|
|
|
| |
| |
| |
| 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 |
| ``` |
| """) |
|
|
|
|
| |
| |
| |
| |
| 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) |
|
|
| |
| |
| |
| 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) |