"""
Aesthetic Dissection Panel
Upload an image → LAION score + auditable dimension proxies + plain-English rationales.
Deploy: Hugging Face Spaces (Gradio SDK) · same file as local.
"""
from __future__ import annotations
import html
import json
from pathlib import Path
import gradio as gr
import numpy as np
from PIL import Image
from aesthetic_core import compute_dissection, laion_aesthetic_score
from aesthetic_core.config import DEVICE
from aesthetic_core.depth_layers import separate_depth_layers
from aesthetic_core.visualize import build_diagnostic_bundle
from src.config import BASE_DIR, LOG_DIR, SHARE_LOCAL
from src.rationale import generate_rationales
from src.test_log import csv_log_path, persist_analysis
def _gradio_allowed_paths() -> list[str]:
candidates = {
str(LOG_DIR),
str(LOG_DIR.resolve()),
"/data/aesthetic_logs",
"/data",
str(BASE_DIR / "output"),
str(BASE_DIR),
str(Path(__file__).resolve().parent),
}
return sorted(p for p in candidates if p)
CSS_PATH = Path(__file__).parent / "css" / "custom.css"
CUSTOM_CSS = CSS_PATH.read_text(encoding="utf-8") if CSS_PATH.exists() else ""
LANGS = {
"en": {
"eyebrow": "HCI · Aesthetics · XAI-light",
"title": "Aesthetic Dissection",
"subtitle": "Operationalize visual quality into auditable dimensions, instead of a black-box accept/reject score.",
"language_label": "Language / 语言",
"upload_label": "Upload image",
"analyze_btn": "Analyze",
"upload_hint": "Drop or click to upload · PNG / JPG",
"guide_title": "Before you start",
"guide_body": "Upload one image, then click Analyze. The app estimates a LAION aesthetic score, interpretable visual proxies, hue/temperature, and foreground-to-background depth layers.",
"guide_steps": [
"Use a full image instead of a screenshot crop when possible.",
"Bars are descriptive signals, not universal beauty judgments.",
"Depth is relative monocular estimation, not metric 3D reconstruction.",
],
"score_label": "LAION Aesthetic Score",
"score_hint": "Upload an image to analyze",
"score_sub": "CLIP ViT-L/14 + sa_0_4_vit_l_14_linear.pth · 0-10",
"dimensions_empty": "Dimensions will appear here after upload.",
"dimensions_title": "Dissection Proxies",
"dimensions_source_prefix": "descriptive · not prescriptive · ",
"dimensions_disclaimer": "Bars measure what is present, not whether the image is \"good.\" Pair with LAION and your own taste.",
"hue_title": "Hue & temperature",
"hue_hint": "Upload to see color ring and warm/cool bar",
"hue_caption": "Color ring · hue histogram · warm/cool bar",
"proxy_empty": "Proxy maps will appear after upload.",
"proxy_title": "Proxy diagnostics",
"proxy_desc": "Edge, saliency, composition centroid, and palette maps tied to the dissection bars.",
"cards": {
"edges": ("Edge / texture map", "Contour and fine structure"),
"saliency": ("Attention heatmap", "Where the eye is likely drawn"),
"composition": ("Composition grid", "Rule-of-thirds + visual mass"),
"palette": ("Dominant palette", "Coarse color clusters"),
},
"depth_unavailable": "Depth separation unavailable: ",
"depth_empty": "Depth layers will appear after upload.",
"depth_title": "Depth layer separation",
"depth_desc": "Foreground / midground / background split from monocular depth.",
"depth_note_prefix": "Monocular depth via ",
"depth_note_suffix": " — relative near/far split (not metric 3D).",
"near": "Near",
"mid": "Mid",
"far": "Far",
"spread": "Depth spread",
"depth_cards": {
"depth_map": ("Depth map", "Brighter = nearer to camera"),
"foreground": ("Foreground (near)", "Closest depth band"),
"midground": ("Midground", "Middle depth band"),
"background": ("Background (far)", "Farthest depth band"),
},
"visual_empty": "Upload an image to see visual analysis maps.",
"visual_label": "Visual analysis — proxies, composition, edges & depth layers",
"json_label": "Raw metrics (JSON)",
"json_copy_btn": "Copy JSON",
"json_copied_btn": "Copied!",
"waiting_json": '{\n "status": "waiting for upload"\n}',
"note": "Proxy bars are descriptive signals, not universal beauty judgments.",
"analysis_failed": "Analysis failed",
"log_idle": "Test log empty — each analysis saves the image and appends a row to analysis_log.csv.",
"log_saved": "Saved {name} · row appended to analysis_log.csv",
"log_failed": "Could not save test log: {error}",
"csv_download_label": "Download analysis CSV",
},
"zh-Hans": {
"eyebrow": "HCI · 美学 · 轻量可解释性",
"title": "审美拆解面板",
"subtitle": "把视觉质量拆成可审计的维度,而不是只给一个黑箱式的好/坏分数。",
"language_label": "语言 / Language",
"upload_label": "上传图片",
"analyze_btn": "开始分析",
"upload_hint": "拖拽或点击上传 · PNG / JPG",
"guide_title": "使用说明",
"guide_body": "上传一张图片后点击“开始分析”。系统会输出 LAION 审美分数、可解释的视觉代理指标、色相/冷暖分布,以及前中后景深度分层。",
"guide_steps": [
"尽量上传完整图片,而不是局部截图。",
"这些条形指标是描述性信号,不是绝对审美判断。",
"深度结果是相对单目估计,不是真实 3D 距离。",
],
"score_label": "LAION 审美分数",
"score_hint": "上传图片后开始分析",
"score_sub": "CLIP ViT-L/14 + sa_0_4_vit_l_14_linear.pth · 0-10",
"dimensions_empty": "上传后,这里会显示拆解维度。",
"dimensions_title": "拆解代理指标",
"dimensions_source_prefix": "描述性 · 非规定性 · ",
"dimensions_disclaimer": "这些条形图衡量的是“图中有什么”,不是“图是否好看”。请结合 LAION 分数和你自己的审美判断。",
"hue_title": "色相与冷暖",
"hue_hint": "上传后可查看色环与冷暖条",
"hue_caption": "色环 · 色相直方图 · 冷暖条",
"proxy_empty": "上传后,这里会显示代理可视化图。",
"proxy_title": "代理诊断图",
"proxy_desc": "边缘、显著性、构图质心与色板图,对应上方的拆解条形指标。",
"cards": {
"edges": ("边缘 / 纹理图", "轮廓与细节结构"),
"saliency": ("注意力热力图", "视觉更容易被吸引的位置"),
"composition": ("构图网格", "三分法 + 视觉重心"),
"palette": ("主色板", "粗粒度颜色簇"),
},
"depth_unavailable": "深度分层暂不可用:",
"depth_empty": "上传后,这里会显示前中后景深度分层。",
"depth_title": "深度图层分离",
"depth_desc": "根据单目深度估计,将图像拆成前景 / 中景 / 背景。",
"depth_note_prefix": "单目深度模型 ",
"depth_note_suffix": " · 输出相对远近关系,不是真实 3D 距离。",
"near": "前景",
"mid": "中景",
"far": "背景",
"spread": "深度离散度",
"depth_cards": {
"depth_map": ("深度图", "越亮越靠近镜头"),
"foreground": ("前景(近)", "最近的深度带"),
"midground": ("中景", "中间深度带"),
"background": ("背景(远)", "最远的深度带"),
},
"visual_empty": "上传图片后可查看可视化分析结果。",
"visual_label": "可视化分析——代理图、构图、边缘与深度图层",
"json_label": "原始指标(JSON)",
"json_copy_btn": "复制 JSON",
"json_copied_btn": "已复制",
"waiting_json": '{\n "status": "等待上传图片"\n}',
"note": "这些代理条形指标是描述性信号,不是绝对审美判断。",
"analysis_failed": "分析失败",
"log_idle": "测试记录为空 — 每次分析后会保存图片,并追加一行到 analysis_log.csv。",
"log_saved": "已保存 {name} · 已追加到 analysis_log.csv",
"log_failed": "无法保存测试记录:{error}",
"csv_download_label": "下载分析 CSV",
},
"zh-Hant": {
"eyebrow": "HCI · 美學 · 輕量可解釋性",
"title": "審美拆解面板",
"subtitle": "把視覺品質拆成可審計的維度,而不是只給一個黑箱式的好/壞分數。",
"language_label": "語言 / Language",
"upload_label": "上傳圖片",
"analyze_btn": "開始分析",
"upload_hint": "拖曳或點擊上傳 · PNG / JPG",
"guide_title": "使用說明",
"guide_body": "上傳一張圖片後點擊「開始分析」。系統會輸出 LAION 審美分數、可解釋的視覺代理指標、色相/冷暖分佈,以及前中後景深度分層。",
"guide_steps": [
"盡量上傳完整圖片,而不是局部截圖。",
"這些條形指標是描述性訊號,不是絕對審美判斷。",
"深度結果是相對單目估計,不是真實 3D 距離。",
],
"score_label": "LAION 審美分數",
"score_hint": "上傳圖片後開始分析",
"score_sub": "CLIP ViT-L/14 + sa_0_4_vit_l_14_linear.pth · 0-10",
"dimensions_empty": "上傳後,這裡會顯示拆解維度。",
"dimensions_title": "拆解代理指標",
"dimensions_source_prefix": "描述性 · 非規範性 · ",
"dimensions_disclaimer": "這些條形圖衡量的是「圖中有什麼」,不是「圖是否好看」。請結合 LAION 分數和你自己的審美判斷。",
"hue_title": "色相與冷暖",
"hue_hint": "上傳後可查看色環與冷暖條",
"hue_caption": "色環 · 色相直方圖 · 冷暖條",
"proxy_empty": "上傳後,這裡會顯示代理視覺化圖。",
"proxy_title": "代理診斷圖",
"proxy_desc": "邊緣、顯著性、構圖質心與色板圖,對應上方的拆解條形指標。",
"cards": {
"edges": ("邊緣 / 紋理圖", "輪廓與細節結構"),
"saliency": ("注意力熱力圖", "視線更容易被吸引的位置"),
"composition": ("構圖網格", "三分法 + 視覺重心"),
"palette": ("主色板", "粗粒度色彩簇"),
},
"depth_unavailable": "深度分層暫不可用:",
"depth_empty": "上傳後,這裡會顯示前中後景深度分層。",
"depth_title": "深度圖層分離",
"depth_desc": "根據單目深度估計,將圖像拆成前景 / 中景 / 背景。",
"depth_note_prefix": "單目深度模型 ",
"depth_note_suffix": " · 輸出相對遠近關係,不是真實 3D 距離。",
"near": "前景",
"mid": "中景",
"far": "背景",
"spread": "深度離散度",
"depth_cards": {
"depth_map": ("深度圖", "越亮越靠近鏡頭"),
"foreground": ("前景(近)", "最近的深度帶"),
"midground": ("中景", "中間深度帶"),
"background": ("背景(遠)", "最遠的深度帶"),
},
"visual_empty": "上傳圖片後可查看視覺化分析結果。",
"visual_label": "視覺化分析——代理圖、構圖、邊緣與深度圖層",
"json_label": "原始指標(JSON)",
"json_copy_btn": "複製 JSON",
"json_copied_btn": "已複製",
"waiting_json": '{\n "status": "等待上傳圖片"\n}',
"note": "這些代理條形指標是描述性訊號,不是絕對審美判斷。",
"analysis_failed": "分析失敗",
"log_idle": "測試記錄為空 — 每次分析後會保存圖片,並追加一行到 analysis_log.csv。",
"log_saved": "已保存 {name} · 已追加到 analysis_log.csv",
"log_failed": "無法保存測試記錄:{error}",
"csv_download_label": "下載分析 CSV",
},
}
def _lang_copy(lang: str) -> dict:
return LANGS.get(lang, LANGS["en"])
def _header_html(lang: str) -> str:
copy = _lang_copy(lang)
return f"""
{html.escape(copy["eyebrow"])} {html.escape(copy["subtitle"])}{html.escape(copy["title"])}
{html.escape(copy["guide_body"])}
{html.escape(_lang_copy(lang)['upload_hint'])}
" def _layout_mode(image) -> str: if image is None: return "empty" try: return "ready" if _coerce_pil_image(image) is not None else "empty" except Exception: return "empty" def _chrome_updates(lang: str, image): copy = _lang_copy(lang) mode = _layout_mode(image) return { "header": _header_html(lang), "guide": _guide_html(lang), "lang": gr.update(label=copy["language_label"]), "image": gr.update(label=copy["upload_label"]), "btn": gr.update(value=copy["analyze_btn"]), "visual_acc": gr.update(label=copy["visual_label"]), "json_acc": gr.update(label=copy["json_label"]), "hint": _upload_hint_html(lang), "main_row": gr.update(elem_classes=["main-row", f"main-row--{mode}"]), } def _score_color(score: float) -> str: if score >= 7.0: return "#34c759" if score >= 5.5: return "#ff9500" return "#ff3b30" def _bar_color(v: float) -> str: """Neutral intensity ramp — not a good/bad judgment.""" if v >= 0.72: return "#5856d6" if v >= 0.45: return "#007aff" return "#8e8e93" def _level_badge(level: str) -> str: colors = {"high": "#5856d6", "moderate": "#007aff", "low": "#8e8e93"} return f'{html.escape(level)}' def _render_laion(score: float | None, lang: str) -> str: copy = _lang_copy(lang) if score is None: return f""" """ color = _score_color(score) return f""" """ def _render_dimensions(dims: list[dict], rationales: list[str], source: str, lang: str) -> str: copy = _lang_copy(lang) if not dims: return f'{html.escape(interpretation)}
{f'{html.escape(rationale)}
{html.escape(copy["dimensions_disclaimer"])}
{''.join(rows)}
{html.escape(copy["depth_note_prefix"])}{html.escape(model)}{html.escape(copy["depth_note_suffix"])}
{html.escape(copy["proxy_desc"])}
{_render_proxy_diagnostics(diagnostics, lang)}{html.escape(copy["depth_desc"])}
{_render_depth_layers(depth_layers, lang, depth_error)}{html.escape(payload)}
{html.escape(copy["log_failed"].format(error=error))}
' if saved_filename: return f'{html.escape(copy["log_saved"].format(name=saved_filename))}
' return f'{html.escape(copy["log_idle"])}
' def _csv_file_update(lang: str, saved: bool = False): copy = _lang_copy(lang) path = csv_log_path() label = copy["csv_download_label"] if path and (saved or path.exists()): return gr.update(value=str(path), label=label) return gr.update(value=None, label=label) def _render_error(message: str, lang: str) -> str: copy = _lang_copy(lang) return f"""