Spaces:
Sleeping
Sleeping
Full Viral Images v1.0 implementation - all modules and configs
Browse files- app.py +552 -1
- compare/comparator.py +99 -0
- configs/model_config.yaml +27 -0
- configs/scoring_weights.yaml +131 -0
- configs/suggestion_rules.yaml +129 -0
- features/__init__.py +0 -0
- features/heuristics.py +73 -0
- features/neural_richness.py +81 -0
- features/ocr.py +88 -0
- features/quality.py +107 -0
- features/saliency.py +62 -0
- features/semantic.py +124 -0
- models/loader.py +66 -0
- recommendations/engine.py +196 -0
- scoring/engine.py +282 -0
- scoring/normalizers.py +125 -0
- utils/__init__.py +0 -0
- utils/formatting.py +126 -0
- utils/preprocessing.py +63 -0
app.py
CHANGED
|
@@ -1 +1,552 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Viral Images — Image Scoring System
|
| 3 |
+
Upload an image, score it, and improve it.
|
| 4 |
+
|
| 5 |
+
Deployed on HuggingFace Spaces: https://huggingface.co/spaces/Babajaan/viral-images
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
import time
|
| 11 |
+
import traceback
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
# Ensure our modules are importable
|
| 15 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 16 |
+
|
| 17 |
+
import gradio as gr
|
| 18 |
+
import pandas as pd
|
| 19 |
+
from PIL import Image
|
| 20 |
+
|
| 21 |
+
from utils.preprocessing import preprocess_image, validate_image
|
| 22 |
+
from utils.formatting import (
|
| 23 |
+
format_overall_score,
|
| 24 |
+
format_sub_scores,
|
| 25 |
+
format_strengths,
|
| 26 |
+
format_weaknesses,
|
| 27 |
+
format_suggestions,
|
| 28 |
+
format_projected_improvement,
|
| 29 |
+
format_comparison,
|
| 30 |
+
)
|
| 31 |
+
from scoring.engine import ScoringEngine, ScoreResponse
|
| 32 |
+
from recommendations.engine import RecommendationEngine, compute_improvement_potential
|
| 33 |
+
from compare.comparator import compare_scores
|
| 34 |
+
from models.loader import ModelLoader
|
| 35 |
+
|
| 36 |
+
# ───────────────────────────────────────────────────────────────
|
| 37 |
+
# Global state (models loaded once at startup)
|
| 38 |
+
# ───────────────────────────────────────────────────────────────
|
| 39 |
+
|
| 40 |
+
MODEL_LOADER = None
|
| 41 |
+
SCORING_ENGINE = None
|
| 42 |
+
REC_ENGINE = None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _init_engines():
|
| 46 |
+
"""Initialize scoring engines on startup."""
|
| 47 |
+
global MODEL_LOADER, SCORING_ENGINE, REC_ENGINE
|
| 48 |
+
|
| 49 |
+
print("[ViralImages] Initializing engines...")
|
| 50 |
+
t0 = time.time()
|
| 51 |
+
|
| 52 |
+
MODEL_LOADER = ModelLoader()
|
| 53 |
+
|
| 54 |
+
# Try to find config files
|
| 55 |
+
config_paths = {
|
| 56 |
+
"scoring": None,
|
| 57 |
+
"rules": None,
|
| 58 |
+
"model": None,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
for base in ["configs", "viral-images/configs", os.path.join(os.path.dirname(__file__), "configs")]:
|
| 62 |
+
for key, fname in [("scoring", "scoring_weights.yaml"),
|
| 63 |
+
("rules", "suggestion_rules.yaml"),
|
| 64 |
+
("model", "model_config.yaml")]:
|
| 65 |
+
if config_paths[key] is None:
|
| 66 |
+
p = os.path.join(base, fname)
|
| 67 |
+
if os.path.exists(p):
|
| 68 |
+
config_paths[key] = p
|
| 69 |
+
|
| 70 |
+
SCORING_ENGINE = ScoringEngine(
|
| 71 |
+
model_loader=MODEL_LOADER,
|
| 72 |
+
config_path=config_paths["scoring"]
|
| 73 |
+
)
|
| 74 |
+
REC_ENGINE = RecommendationEngine(
|
| 75 |
+
rules_path=config_paths["rules"]
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Pre-warm (load CLIP if available)
|
| 79 |
+
try:
|
| 80 |
+
SCORING_ENGINE.warmup()
|
| 81 |
+
print(f"[ViralImages] Engines ready in {time.time()-t0:.1f}s")
|
| 82 |
+
except Exception as e:
|
| 83 |
+
print(f"[ViralImages] Warmup failed: {e}")
|
| 84 |
+
print("[ViralImages] Running in fallback mode (no CLIP).")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# Initialize on module load
|
| 88 |
+
_init_engines()
|
| 89 |
+
|
| 90 |
+
# ───────────────────────────────────────────────────────────────
|
| 91 |
+
# Score Image Tab
|
| 92 |
+
# ───────────────────────────────────────────────────────────────
|
| 93 |
+
|
| 94 |
+
def score_image(image_input, concept, audience, use_case, detail_level):
|
| 95 |
+
"""
|
| 96 |
+
Score an uploaded image.
|
| 97 |
+
|
| 98 |
+
Returns:
|
| 99 |
+
(overall_html, scores_df, strengths_text, weaknesses_text,
|
| 100 |
+
suggestions_text, projected_html)
|
| 101 |
+
"""
|
| 102 |
+
if image_input is None:
|
| 103 |
+
return (
|
| 104 |
+
"<div style='padding:20px; color:#e74c3c;'><b>⚠️ Please upload an image first.</b></div>",
|
| 105 |
+
pd.DataFrame(),
|
| 106 |
+
"", "", "",
|
| 107 |
+
"<div style='padding:12px; color:#999;'>No image to analyze.</div>"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
img = preprocess_image(image_input)
|
| 112 |
+
val_error = validate_image(img)
|
| 113 |
+
if val_error:
|
| 114 |
+
return (
|
| 115 |
+
f"<div style='padding:20px; color:#e74c3c;'><b>⚠️ {val_error}</b></div>",
|
| 116 |
+
pd.DataFrame(),
|
| 117 |
+
"", "", "",
|
| 118 |
+
"<div style='padding:12px; color:#999;'>Image validation failed.</div>"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# Score
|
| 122 |
+
response = SCORING_ENGINE.score(img, concept or "", audience, use_case)
|
| 123 |
+
|
| 124 |
+
# Generate suggestions
|
| 125 |
+
suggestions = REC_ENGINE.generate(
|
| 126 |
+
response.sub_scores,
|
| 127 |
+
response.raw_features,
|
| 128 |
+
use_case
|
| 129 |
+
)
|
| 130 |
+
response.suggestions = [
|
| 131 |
+
{
|
| 132 |
+
"priority": s.priority,
|
| 133 |
+
"sub_score_target": s.sub_score_target,
|
| 134 |
+
"message": s.message,
|
| 135 |
+
"projected_gain": s.projected_gain,
|
| 136 |
+
"trigger_feature": s.trigger_feature,
|
| 137 |
+
"trigger_value": s.trigger_value,
|
| 138 |
+
}
|
| 139 |
+
for s in suggestions
|
| 140 |
+
]
|
| 141 |
+
|
| 142 |
+
# Projected improvement
|
| 143 |
+
projected = REC_ENGINE.estimate_improvement(
|
| 144 |
+
response.sub_scores,
|
| 145 |
+
suggestions,
|
| 146 |
+
use_case
|
| 147 |
+
)
|
| 148 |
+
response.projected_improvement = projected
|
| 149 |
+
|
| 150 |
+
# Format outputs
|
| 151 |
+
overall_html = format_overall_score(response.overall_score)
|
| 152 |
+
scores_df = format_sub_scores(response.sub_scores)
|
| 153 |
+
strengths_html = format_strengths(response.strengths)
|
| 154 |
+
weaknesses_html = format_weaknesses(response.weaknesses)
|
| 155 |
+
suggestions_html = format_suggestions(response.suggestions)
|
| 156 |
+
projected_html = format_projected_improvement(
|
| 157 |
+
response.overall_score, projected
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
# Metadata footer
|
| 161 |
+
meta = response.metadata
|
| 162 |
+
meta_html = f"<div style='font-size:11px; color:#999; margin-top:8px;'>⏱ {meta.get('processing_time_ms', '?')}ms | Mode: {meta.get('neural_richness_mode', '?')} | Confidence: {response.confidence:.0%}</div>"
|
| 163 |
+
|
| 164 |
+
return (
|
| 165 |
+
overall_html + meta_html,
|
| 166 |
+
scores_df,
|
| 167 |
+
strengths_html,
|
| 168 |
+
weaknesses_html,
|
| 169 |
+
suggestions_html,
|
| 170 |
+
projected_html,
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
traceback.print_exc()
|
| 175 |
+
return (
|
| 176 |
+
f"<div style='padding:20px; color:#e74c3c;'><b>⚠️ Error during scoring:</b><br/>{str(e)}</div>",
|
| 177 |
+
pd.DataFrame(),
|
| 178 |
+
"", "", "",
|
| 179 |
+
"<div style='padding:12px; color:#999;'>Scoring failed.</div>"
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ───────────────────────────────────────────────────────────────
|
| 184 |
+
# Compare Mode Tab
|
| 185 |
+
# ───────────────────────────────────────────────────────────────
|
| 186 |
+
|
| 187 |
+
def compare_images(original_img, revised_img, concept, audience, use_case):
|
| 188 |
+
"""
|
| 189 |
+
Compare before/after images.
|
| 190 |
+
|
| 191 |
+
Returns:
|
| 192 |
+
(original_scores_label, revised_scores_label, comparison_html, next_steps_text)
|
| 193 |
+
"""
|
| 194 |
+
if original_img is None or revised_img is None:
|
| 195 |
+
return (
|
| 196 |
+
{}, {},
|
| 197 |
+
"<div style='padding:20px; color:#e74c3c;'><b>⚠️ Please upload both images.</b></div>",
|
| 198 |
+
"Upload both original and revised images to compare."
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
try:
|
| 202 |
+
# Score both
|
| 203 |
+
original = SCORING_ENGINE.score(original_img, concept or "", audience, use_case)
|
| 204 |
+
revised = SCORING_ENGINE.score(revised_img, concept or "", audience, use_case)
|
| 205 |
+
|
| 206 |
+
# Compare
|
| 207 |
+
result = compare_scores(original, revised)
|
| 208 |
+
|
| 209 |
+
# Format
|
| 210 |
+
orig_label = {k: v / 100 for k, v in original.sub_scores.items()}
|
| 211 |
+
rev_label = {k: v / 100 for k, v in revised.sub_scores.items()}
|
| 212 |
+
|
| 213 |
+
comp_html = format_comparison_result(result)
|
| 214 |
+
|
| 215 |
+
next_steps = "\n".join(
|
| 216 |
+
f"• {rec['message']}" for rec in result["next_recommendations"]
|
| 217 |
+
) if result["next_recommendations"] else "No further improvements needed — your revised image scores well!"
|
| 218 |
+
|
| 219 |
+
return (
|
| 220 |
+
orig_label,
|
| 221 |
+
rev_label,
|
| 222 |
+
comp_html,
|
| 223 |
+
next_steps,
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
except Exception as e:
|
| 227 |
+
traceback.print_exc()
|
| 228 |
+
return (
|
| 229 |
+
{}, {},
|
| 230 |
+
f"<div style='padding:20px; color:#e74c3c;'><b>⚠️ Error:</b><br/>{str(e)}</div>",
|
| 231 |
+
"Comparison failed. Please try again."
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def format_comparison_result(result: dict) -> str:
|
| 236 |
+
"""Format comparison as HTML."""
|
| 237 |
+
orig = result["original_overall"]
|
| 238 |
+
rev = result["revised_overall"]
|
| 239 |
+
delta = result["delta_overall"]
|
| 240 |
+
|
| 241 |
+
color = "#27ae60" if delta > 0 else "#e74c3c" if delta < 0 else "#999"
|
| 242 |
+
emoji = "📈" if delta > 0 else "📉" if delta < 0 else "➡️"
|
| 243 |
+
|
| 244 |
+
html = f"""
|
| 245 |
+
<div style="padding:16px; background:#f8f9fa; border-radius:8px;">
|
| 246 |
+
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
| 247 |
+
<div style="text-align:center; flex:1;">
|
| 248 |
+
<div style="font-size:12px; color:#666;">Original</div>
|
| 249 |
+
<div style="font-size:36px; font-weight:bold; color:#999;">{orig:.0f}</div>
|
| 250 |
+
</div>
|
| 251 |
+
<div style="font-size:28px; color:#ccc; padding:0 16px;">→</div>
|
| 252 |
+
<div style="text-align:center; flex:1;">
|
| 253 |
+
<div style="font-size:12px; color:#666;">Revised</div>
|
| 254 |
+
<div style="font-size:36px; font-weight:bold; color:{color};">{rev:.0f}</div>
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
<div style="text-align:center; color:{color}; font-weight:bold; font-size:16px; margin-bottom:12px;">
|
| 258 |
+
{emoji} Overall: {abs(delta):.0f} point{'s' if abs(delta) != 1 else ''} {'gained' if delta > 0 else 'lost' if delta < 0 else 'unchanged'}
|
| 259 |
+
</div>
|
| 260 |
+
"""
|
| 261 |
+
|
| 262 |
+
if result["improvements"]:
|
| 263 |
+
html += "<div style='font-weight:bold; color:#27ae60; margin-top:8px;'>✅ Improvements:</div><ul style='margin:4px 0; padding-left:20px;'>"
|
| 264 |
+
for name, val in result["improvements"]:
|
| 265 |
+
html += f"<li><b>{name.replace('_', ' ').title()}:</b> +{val:.0f} points</li>"
|
| 266 |
+
html += "</ul>"
|
| 267 |
+
|
| 268 |
+
if result["regressions"]:
|
| 269 |
+
html += "<div style='font-weight:bold; color:#e74c3c; margin-top:8px;'>⚠️ Regressions:</div><ul style='margin:4px 0; padding-left:20px;'>"
|
| 270 |
+
for name, val in result["regressions"]:
|
| 271 |
+
html += f"<li><b>{name.replace('_', ' ').title()}:</b> {val:.0f} points</li>"
|
| 272 |
+
html += "</ul>"
|
| 273 |
+
|
| 274 |
+
if result["next_recommendations"]:
|
| 275 |
+
html += "<div style='font-weight:bold; color:#2980b9; margin-top:8px;'>📋 Next Steps:</div><div style='margin:4px 0;'>"
|
| 276 |
+
for rec in result["next_recommendations"]:
|
| 277 |
+
html += f"<div style='margin:4px 0; padding:6px 10px; background:#e3f2fd; border-radius:4px; font-size:13px;'>{rec['message']}</div>"
|
| 278 |
+
html += "</div>"
|
| 279 |
+
|
| 280 |
+
html += "</div>"
|
| 281 |
+
return html
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
# ───────────────────────────────────────────────────────────────
|
| 285 |
+
# Gradio App
|
| 286 |
+
# ───────────────────────────────────────────────────────────────
|
| 287 |
+
|
| 288 |
+
ABOUT_TEXT = """
|
| 289 |
+
## 🧠 About Viral Images
|
| 290 |
+
|
| 291 |
+
**Viral Images** scores any image across 8 dimensions and suggests concrete improvements.
|
| 292 |
+
|
| 293 |
+
### What It Measures
|
| 294 |
+
| Dimension | Description |
|
| 295 |
+
|-----------|-------------|
|
| 296 |
+
| **Concept Match** | Does the image convey your intended topic? |
|
| 297 |
+
| **Visual Focus** | Is there a clear focal point that draws the eye? |
|
| 298 |
+
| **Readability** | Is text legible and well-sized? |
|
| 299 |
+
| **Complexity Balance** | Is detail level appropriate — not empty, not cluttered? |
|
| 300 |
+
| **Communication Clarity** | Is the visual hierarchy clear and well-organized? |
|
| 301 |
+
| **Predicted Neural Richness** | How visually engaging is the image? |
|
| 302 |
+
| **Memorability** | Will viewers remember it after one viewing? |
|
| 303 |
+
| **Improvement Potential** | How much room for improvement remains? |
|
| 304 |
+
|
| 305 |
+
### How to Use
|
| 306 |
+
1. **Upload an image** (JPG, PNG, or any common format)
|
| 307 |
+
2. **Describe the concept** — what should this image communicate?
|
| 308 |
+
3. **Select audience and use case**
|
| 309 |
+
4. Click **"Score Image"**
|
| 310 |
+
5. Review scores, strengths, weaknesses, and suggestions
|
| 311 |
+
|
| 312 |
+
### Compare Mode
|
| 313 |
+
Upload an **original** and a **revised** version to see what improved and what to focus on next.
|
| 314 |
+
|
| 315 |
+
### ⚠️ Important Disclaimer
|
| 316 |
+
- Scores are **AI-predicted proxies**, not measurements of real brain activity.
|
| 317 |
+
- Scores do **not guarantee engagement, virality, or aesthetic quality**.
|
| 318 |
+
- Use as **directional feedback** to complement your own judgment.
|
| 319 |
+
- Predicted Neural Richness is estimated via computational proxy models, not from actual fMRI data.
|
| 320 |
+
|
| 321 |
+
### Technical Details
|
| 322 |
+
Models used: CLIP ViT-B/32 (concept matching), heuristic analysis (OpenCV),
|
| 323 |
+
saliency heuristics, OCR heuristics, and aesthetic proxies.
|
| 324 |
+
Neural richness is estimated via proxy, not measured.
|
| 325 |
+
"""
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def build_app():
|
| 329 |
+
"""Build and return the Gradio app."""
|
| 330 |
+
|
| 331 |
+
with gr.Blocks() as demo:
|
| 332 |
+
gr.Markdown(
|
| 333 |
+
"""
|
| 334 |
+
# 🧠 Viral Images
|
| 335 |
+
### *Upload an image, score it, and improve it.*
|
| 336 |
+
"""
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
# ── TAB 1: Score Image ──
|
| 340 |
+
with gr.Tab("📊 Score Image"):
|
| 341 |
+
with gr.Row():
|
| 342 |
+
# Input column
|
| 343 |
+
with gr.Column(scale=2):
|
| 344 |
+
image_input = gr.Image(
|
| 345 |
+
type="pil",
|
| 346 |
+
label="📷 Upload Image",
|
| 347 |
+
sources=["upload", "clipboard"],
|
| 348 |
+
height=300,
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
concept_input = gr.Textbox(
|
| 352 |
+
label="💡 Concept / Theme",
|
| 353 |
+
placeholder="e.g., photosynthesis process, product launch, data visualization...",
|
| 354 |
+
lines=1,
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
audience_input = gr.Dropdown(
|
| 358 |
+
choices=[
|
| 359 |
+
"General",
|
| 360 |
+
"Children (K-8)",
|
| 361 |
+
"High School Students",
|
| 362 |
+
"College Students",
|
| 363 |
+
"Professionals",
|
| 364 |
+
"Researchers",
|
| 365 |
+
"General Public",
|
| 366 |
+
"Social Media Audience",
|
| 367 |
+
],
|
| 368 |
+
label="👥 Target Audience",
|
| 369 |
+
value="General",
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
usecase_input = gr.Dropdown(
|
| 373 |
+
choices=[
|
| 374 |
+
("Social Media Post", "social_media"),
|
| 375 |
+
("Thumbnail", "thumbnail"),
|
| 376 |
+
("Educational", "educational"),
|
| 377 |
+
("Scientific Figure", "scientific_figure"),
|
| 378 |
+
("Infographic", "infographic"),
|
| 379 |
+
("Presentation Slide", "presentation_slide"),
|
| 380 |
+
("Marketing", "marketing"),
|
| 381 |
+
("General", "default"),
|
| 382 |
+
],
|
| 383 |
+
label="🎯 Use Case",
|
| 384 |
+
value="social_media",
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
score_btn = gr.Button(
|
| 388 |
+
"🔍 Score Image",
|
| 389 |
+
variant="primary",
|
| 390 |
+
size="lg",
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
with gr.Accordion("⚙️ Advanced Options", open=False):
|
| 394 |
+
detail_slider = gr.Slider(
|
| 395 |
+
1, 5, value=3, step=1,
|
| 396 |
+
label="Detail Level",
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
# Output column
|
| 400 |
+
with gr.Column(scale=3):
|
| 401 |
+
overall_score = gr.HTML(
|
| 402 |
+
label="Overall Score",
|
| 403 |
+
value="<div style='padding:40px; text-align:center; color:#999;'>Upload an image and click 'Score Image' to see results.</div>"
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
with gr.Row():
|
| 407 |
+
strengths_box = gr.HTML(
|
| 408 |
+
label="✅ Strengths",
|
| 409 |
+
value="",
|
| 410 |
+
)
|
| 411 |
+
weaknesses_box = gr.HTML(
|
| 412 |
+
label="⚠️ Weaknesses",
|
| 413 |
+
value="",
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
score_chart = gr.BarPlot(
|
| 417 |
+
x="Sub-Score",
|
| 418 |
+
y="Score",
|
| 419 |
+
color="Sub-Score",
|
| 420 |
+
title="Score Breakdown",
|
| 421 |
+
y_lim=[0, 100],
|
| 422 |
+
height=280,
|
| 423 |
+
value=pd.DataFrame({"Sub-Score": [], "Score": []}),
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
suggestions_box = gr.HTML(
|
| 427 |
+
label="💡 Improvement Suggestions",
|
| 428 |
+
value="",
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
projected_html = gr.HTML(
|
| 432 |
+
label="Projected Improvement",
|
| 433 |
+
value="",
|
| 434 |
+
)
|
| 435 |
+
|
| 436 |
+
score_btn.click(
|
| 437 |
+
fn=score_image,
|
| 438 |
+
inputs=[image_input, concept_input, audience_input, usecase_input, detail_slider],
|
| 439 |
+
outputs=[overall_score, score_chart, strengths_box, weaknesses_box, suggestions_box, projected_html],
|
| 440 |
+
concurrency_id="inference",
|
| 441 |
+
concurrency_limit=1,
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
# ── TAB 2: Compare Mode ──
|
| 445 |
+
with gr.Tab("🔄 Compare Mode"):
|
| 446 |
+
with gr.Row():
|
| 447 |
+
before_img = gr.Image(
|
| 448 |
+
type="pil",
|
| 449 |
+
label="📷 Original Image",
|
| 450 |
+
sources=["upload"],
|
| 451 |
+
height=250,
|
| 452 |
+
)
|
| 453 |
+
after_img = gr.Image(
|
| 454 |
+
type="pil",
|
| 455 |
+
label="📷 Revised Image",
|
| 456 |
+
sources=["upload"],
|
| 457 |
+
height=250,
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
with gr.Row():
|
| 461 |
+
compare_concept = gr.Textbox(
|
| 462 |
+
label="💡 Concept",
|
| 463 |
+
placeholder="Same concept as original...",
|
| 464 |
+
lines=1,
|
| 465 |
+
)
|
| 466 |
+
compare_audience = gr.Dropdown(
|
| 467 |
+
choices=[
|
| 468 |
+
"General", "Children (K-8)", "High School Students",
|
| 469 |
+
"College Students", "Professionals", "Researchers",
|
| 470 |
+
"General Public", "Social Media Audience",
|
| 471 |
+
],
|
| 472 |
+
label="👥 Audience",
|
| 473 |
+
value="General",
|
| 474 |
+
)
|
| 475 |
+
compare_usecase = gr.Dropdown(
|
| 476 |
+
choices=[
|
| 477 |
+
("Social Media Post", "social_media"),
|
| 478 |
+
("Thumbnail", "thumbnail"),
|
| 479 |
+
("Educational", "educational"),
|
| 480 |
+
("Scientific Figure", "scientific_figure"),
|
| 481 |
+
("Infographic", "infographic"),
|
| 482 |
+
("Presentation Slide", "presentation_slide"),
|
| 483 |
+
("Marketing", "marketing"),
|
| 484 |
+
("General", "default"),
|
| 485 |
+
],
|
| 486 |
+
label="🎯 Use Case",
|
| 487 |
+
value="social_media",
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
compare_btn = gr.Button(
|
| 491 |
+
"🔄 Compare",
|
| 492 |
+
variant="primary",
|
| 493 |
+
size="lg",
|
| 494 |
+
)
|
| 495 |
+
|
| 496 |
+
with gr.Row():
|
| 497 |
+
before_scores = gr.Label(
|
| 498 |
+
label="Original Scores",
|
| 499 |
+
value={},
|
| 500 |
+
)
|
| 501 |
+
after_scores = gr.Label(
|
| 502 |
+
label="Revised Scores",
|
| 503 |
+
value={},
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
comparison_html = gr.HTML(
|
| 507 |
+
label="Comparison Results",
|
| 508 |
+
value="<div style='padding:20px; text-align:center; color:#999;'>Upload both images to compare.</div>"
|
| 509 |
+
)
|
| 510 |
+
|
| 511 |
+
next_steps = gr.HTML(
|
| 512 |
+
label="📋 Next Recommended Edits",
|
| 513 |
+
value="",
|
| 514 |
+
)
|
| 515 |
+
|
| 516 |
+
compare_btn.click(
|
| 517 |
+
fn=compare_images,
|
| 518 |
+
inputs=[before_img, after_img, compare_concept, compare_audience, compare_usecase],
|
| 519 |
+
outputs=[before_scores, after_scores, comparison_html, next_steps],
|
| 520 |
+
concurrency_id="inference",
|
| 521 |
+
concurrency_limit=1,
|
| 522 |
+
)
|
| 523 |
+
|
| 524 |
+
# ── TAB 3: About ──
|
| 525 |
+
with gr.Tab("ℹ️ About"):
|
| 526 |
+
gr.Markdown(ABOUT_TEXT)
|
| 527 |
+
|
| 528 |
+
# Footer
|
| 529 |
+
gr.Markdown(
|
| 530 |
+
"""
|
| 531 |
+
<div style="margin-top:20px; padding:12px; background:#f8f9fa; border-radius:8px; font-size:12px; color:#666;">
|
| 532 |
+
⚠️ <b>Disclaimer:</b> Scores are AI-predicted proxies, not measurements of brain activity or guaranteed engagement.
|
| 533 |
+
Use as directional feedback, not absolute truth.
|
| 534 |
+
<a href="https://github.com/facebookresearch/algonauts-2025" target="_blank">TRIBE</a> is used only as inspiration for the neural richness proxy.
|
| 535 |
+
</div>
|
| 536 |
+
"""
|
| 537 |
+
)
|
| 538 |
+
|
| 539 |
+
return demo
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
# ───────────────────────────────────────────────────────────────
|
| 543 |
+
# Launch
|
| 544 |
+
# ───────────────────────────────────────────────────────────────
|
| 545 |
+
|
| 546 |
+
if __name__ == "__main__":
|
| 547 |
+
demo = build_app()
|
| 548 |
+
demo.queue(max_size=10, api_open=False).launch(
|
| 549 |
+
show_api=False,
|
| 550 |
+
server_name="0.0.0.0",
|
| 551 |
+
server_port=int(os.environ.get("PORT", 7860)),
|
| 552 |
+
)
|
compare/comparator.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Before/after image comparison module."""
|
| 2 |
+
|
| 3 |
+
from typing import Dict, Any, List, Tuple, Optional
|
| 4 |
+
from scoring.engine import ScoreResponse
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def compare_scores(original: ScoreResponse, revised: ScoreResponse) -> Dict[str, Any]:
|
| 8 |
+
"""
|
| 9 |
+
Compare two scored images and produce delta analysis.
|
| 10 |
+
|
| 11 |
+
Returns dict with:
|
| 12 |
+
original_overall, revised_overall, delta_overall,
|
| 13 |
+
improvements, regressions, next_recommendations, all_deltas
|
| 14 |
+
"""
|
| 15 |
+
orig_scores = original.sub_scores
|
| 16 |
+
rev_scores = revised.sub_scores
|
| 17 |
+
|
| 18 |
+
# Compute deltas
|
| 19 |
+
all_deltas = {}
|
| 20 |
+
for key in orig_scores:
|
| 21 |
+
if key in rev_scores:
|
| 22 |
+
all_deltas[key] = round(rev_scores[key] - orig_scores[key], 1)
|
| 23 |
+
|
| 24 |
+
# Identify improvements (>5) and regressions (<-5)
|
| 25 |
+
MEANINGFUL = 5.0
|
| 26 |
+
improvements = [(k, v) for k, v in all_deltas.items() if v > MEANINGFUL]
|
| 27 |
+
regressions = [(k, v) for k, v in all_deltas.items() if v < -MEANINGFUL]
|
| 28 |
+
|
| 29 |
+
# Sort by magnitude
|
| 30 |
+
improvements.sort(key=lambda x: -x[1])
|
| 31 |
+
regressions.sort(key=lambda x: x[1])
|
| 32 |
+
|
| 33 |
+
# Next recommendations: weakest sub-scores in revised version
|
| 34 |
+
# Exclude improvement_potential
|
| 35 |
+
filtered_rev = {k: v for k, v in rev_scores.items() if k != "improvement_potential"}
|
| 36 |
+
next_targets = sorted(filtered_rev.items(), key=lambda x: x[1])[:3]
|
| 37 |
+
|
| 38 |
+
# Build next recommendations
|
| 39 |
+
readable_names = {
|
| 40 |
+
"concept_match": "Concept Match",
|
| 41 |
+
"visual_focus": "Visual Focus",
|
| 42 |
+
"readability": "Readability",
|
| 43 |
+
"complexity_balance": "Complexity Balance",
|
| 44 |
+
"communication_clarity": "Communication Clarity",
|
| 45 |
+
"neural_richness": "Neural Richness",
|
| 46 |
+
"memorability_proxy": "Memorability",
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
next_recommendations = []
|
| 50 |
+
for name, score in next_targets:
|
| 51 |
+
readable = readable_names.get(name, name)
|
| 52 |
+
next_recommendations.append({
|
| 53 |
+
"sub_score": name,
|
| 54 |
+
"current_score": score,
|
| 55 |
+
"message": f"Further improve {readable.lower()} (currently {score:.0f}/100) — this is your weakest remaining dimension."
|
| 56 |
+
})
|
| 57 |
+
|
| 58 |
+
return {
|
| 59 |
+
"original_overall": round(original.overall_score, 1),
|
| 60 |
+
"revised_overall": round(revised.overall_score, 1),
|
| 61 |
+
"delta_overall": round(revised.overall_score - original.overall_score, 1),
|
| 62 |
+
"improvements": improvements[:5],
|
| 63 |
+
"regressions": regressions[:5],
|
| 64 |
+
"next_recommendations": next_recommendations,
|
| 65 |
+
"all_deltas": all_deltas,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def format_comparison_result(result: Dict[str, Any]) -> str:
|
| 70 |
+
"""Format comparison result as human-readable text."""
|
| 71 |
+
lines = []
|
| 72 |
+
|
| 73 |
+
orig = result["original_overall"]
|
| 74 |
+
rev = result["revised_overall"]
|
| 75 |
+
delta = result["delta_overall"]
|
| 76 |
+
|
| 77 |
+
if delta > 0:
|
| 78 |
+
lines.append(f"📈 Overall improved: {orig:.0f} → {rev:.0f} (+{delta:.0f} points)")
|
| 79 |
+
elif delta < 0:
|
| 80 |
+
lines.append(f"📉 Overall decreased: {orig:.0f} → {rev:.0f} ({delta:.0f} points)")
|
| 81 |
+
else:
|
| 82 |
+
lines.append(f"➡️ Overall unchanged: {orig:.0f} → {rev:.0f}")
|
| 83 |
+
|
| 84 |
+
if result["improvements"]:
|
| 85 |
+
lines.append("\n✅ Improvements:")
|
| 86 |
+
for name, val in result["improvements"]:
|
| 87 |
+
lines.append(f" • {name}: +{val:.0f} points")
|
| 88 |
+
|
| 89 |
+
if result["regressions"]:
|
| 90 |
+
lines.append("\n⚠️ Regressions:")
|
| 91 |
+
for name, val in result["regressions"]:
|
| 92 |
+
lines.append(f" • {name}: {val:.0f} points")
|
| 93 |
+
|
| 94 |
+
if result["next_recommendations"]:
|
| 95 |
+
lines.append("\n📋 Next recommended edits:")
|
| 96 |
+
for rec in result["next_recommendations"]:
|
| 97 |
+
lines.append(f" • {rec['message']}")
|
| 98 |
+
|
| 99 |
+
return "\n".join(lines)
|
configs/model_config.yaml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
models:
|
| 2 |
+
clip:
|
| 3 |
+
model_id: "openai/clip-vit-base-patch32"
|
| 4 |
+
dtype: "float16"
|
| 5 |
+
device: "auto"
|
| 6 |
+
|
| 7 |
+
thresholds:
|
| 8 |
+
min_image_size: 50
|
| 9 |
+
max_image_size: 4096
|
| 10 |
+
ocr_confidence_min: 0.3
|
| 11 |
+
target_readability_coverage: 0.4
|
| 12 |
+
optimal_edge_density: 0.10
|
| 13 |
+
optimal_whitespace: 0.12
|
| 14 |
+
|
| 15 |
+
scoring:
|
| 16 |
+
target_subscore: 75.0
|
| 17 |
+
max_plausible_gain_per_rule: 25.0
|
| 18 |
+
suggestion_limit: 5
|
| 19 |
+
confidence_base: 0.85
|
| 20 |
+
confidence_no_concept_penalty: 0.15
|
| 21 |
+
confidence_low_res_penalty: 0.10
|
| 22 |
+
confidence_ocr_uncertain_penalty: 0.10
|
| 23 |
+
|
| 24 |
+
comparison:
|
| 25 |
+
meaningful_threshold: 5.0
|
| 26 |
+
significant_threshold: 15.0
|
| 27 |
+
max_suggestions: 3
|
configs/scoring_weights.yaml
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
presets:
|
| 2 |
+
social_media:
|
| 3 |
+
concept_match: 0.10
|
| 4 |
+
visual_focus: 0.20
|
| 5 |
+
readability: 0.05
|
| 6 |
+
complexity_balance: 0.15
|
| 7 |
+
communication_clarity: 0.10
|
| 8 |
+
neural_richness: 0.20
|
| 9 |
+
memorability_proxy: 0.15
|
| 10 |
+
improvement_potential: 0.05
|
| 11 |
+
|
| 12 |
+
educational:
|
| 13 |
+
concept_match: 0.20
|
| 14 |
+
visual_focus: 0.10
|
| 15 |
+
readability: 0.20
|
| 16 |
+
complexity_balance: 0.10
|
| 17 |
+
communication_clarity: 0.20
|
| 18 |
+
neural_richness: 0.05
|
| 19 |
+
memorability_proxy: 0.05
|
| 20 |
+
improvement_potential: 0.10
|
| 21 |
+
|
| 22 |
+
thumbnail:
|
| 23 |
+
concept_match: 0.15
|
| 24 |
+
visual_focus: 0.25
|
| 25 |
+
readability: 0.05
|
| 26 |
+
complexity_balance: 0.15
|
| 27 |
+
communication_clarity: 0.10
|
| 28 |
+
neural_richness: 0.15
|
| 29 |
+
memorability_proxy: 0.10
|
| 30 |
+
improvement_potential: 0.05
|
| 31 |
+
|
| 32 |
+
scientific_figure:
|
| 33 |
+
concept_match: 0.20
|
| 34 |
+
visual_focus: 0.10
|
| 35 |
+
readability: 0.15
|
| 36 |
+
complexity_balance: 0.10
|
| 37 |
+
communication_clarity: 0.20
|
| 38 |
+
neural_richness: 0.05
|
| 39 |
+
memorability_proxy: 0.10
|
| 40 |
+
improvement_potential: 0.10
|
| 41 |
+
|
| 42 |
+
infographic:
|
| 43 |
+
concept_match: 0.15
|
| 44 |
+
visual_focus: 0.10
|
| 45 |
+
readability: 0.25
|
| 46 |
+
complexity_balance: 0.15
|
| 47 |
+
communication_clarity: 0.20
|
| 48 |
+
neural_richness: 0.05
|
| 49 |
+
memorability_proxy: 0.05
|
| 50 |
+
improvement_potential: 0.05
|
| 51 |
+
|
| 52 |
+
presentation_slide:
|
| 53 |
+
concept_match: 0.15
|
| 54 |
+
visual_focus: 0.10
|
| 55 |
+
readability: 0.25
|
| 56 |
+
complexity_balance: 0.15
|
| 57 |
+
communication_clarity: 0.20
|
| 58 |
+
neural_richness: 0.05
|
| 59 |
+
memorability_proxy: 0.05
|
| 60 |
+
improvement_potential: 0.05
|
| 61 |
+
|
| 62 |
+
marketing:
|
| 63 |
+
concept_match: 0.10
|
| 64 |
+
visual_focus: 0.20
|
| 65 |
+
readability: 0.10
|
| 66 |
+
complexity_balance: 0.10
|
| 67 |
+
communication_clarity: 0.15
|
| 68 |
+
neural_richness: 0.15
|
| 69 |
+
memorability_proxy: 0.15
|
| 70 |
+
improvement_potential: 0.05
|
| 71 |
+
|
| 72 |
+
default:
|
| 73 |
+
concept_match: 0.12
|
| 74 |
+
visual_focus: 0.15
|
| 75 |
+
readability: 0.12
|
| 76 |
+
complexity_balance: 0.12
|
| 77 |
+
communication_clarity: 0.15
|
| 78 |
+
neural_richness: 0.12
|
| 79 |
+
memorability_proxy: 0.12
|
| 80 |
+
improvement_potential: 0.10
|
| 81 |
+
|
| 82 |
+
normalization:
|
| 83 |
+
clip_cosine:
|
| 84 |
+
min_val: 0.10
|
| 85 |
+
max_val: 0.45
|
| 86 |
+
|
| 87 |
+
edge_density:
|
| 88 |
+
optimal: 0.10
|
| 89 |
+
sigma: 0.06
|
| 90 |
+
min_val: 0.0
|
| 91 |
+
max_val: 0.25
|
| 92 |
+
|
| 93 |
+
color_entropy:
|
| 94 |
+
optimal: 4.5
|
| 95 |
+
sigma: 1.0
|
| 96 |
+
min_val: 0.0
|
| 97 |
+
max_val: 5.55
|
| 98 |
+
|
| 99 |
+
contrast:
|
| 100 |
+
min_val: 0.0
|
| 101 |
+
max_val: 1.0
|
| 102 |
+
|
| 103 |
+
whitespace:
|
| 104 |
+
optimal: 0.12
|
| 105 |
+
sigma: 0.08
|
| 106 |
+
min_val: 0.0
|
| 107 |
+
max_val: 0.40
|
| 108 |
+
|
| 109 |
+
sharpness:
|
| 110 |
+
min_val: 0.0
|
| 111 |
+
max_val: 2.0
|
| 112 |
+
|
| 113 |
+
peak_saliency:
|
| 114 |
+
min_val: 0.0
|
| 115 |
+
max_val: 1.0
|
| 116 |
+
|
| 117 |
+
saliency_entropy:
|
| 118 |
+
min_val: 0.0
|
| 119 |
+
max_val: 1.0
|
| 120 |
+
|
| 121 |
+
ocr_confidence:
|
| 122 |
+
min_val: 0.0
|
| 123 |
+
max_val: 1.0
|
| 124 |
+
|
| 125 |
+
nima_aesthetic:
|
| 126 |
+
min_val: 0.0
|
| 127 |
+
max_val: 1.0
|
| 128 |
+
|
| 129 |
+
clip_norm:
|
| 130 |
+
min_val: 10.0
|
| 131 |
+
max_val: 35.0
|
configs/suggestion_rules.yaml
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
rules:
|
| 2 |
+
- sub_score: visual_focus
|
| 3 |
+
max_score: 50
|
| 4 |
+
trigger_feature: peak_saliency
|
| 5 |
+
trigger_condition: lt
|
| 6 |
+
trigger_value: 0.5
|
| 7 |
+
message: "Add a dominant focal element (larger main object, brighter color on subject, or isolate the subject from background)."
|
| 8 |
+
max_gain: 20
|
| 9 |
+
|
| 10 |
+
- sub_score: visual_focus
|
| 11 |
+
max_score: 50
|
| 12 |
+
trigger_feature: center_saliency
|
| 13 |
+
trigger_condition: lt
|
| 14 |
+
trigger_value: 0.3
|
| 15 |
+
message: "Move the main subject closer to the center or a rule-of-thirds intersection to establish a clear visual anchor."
|
| 16 |
+
max_gain: 15
|
| 17 |
+
|
| 18 |
+
- sub_score: visual_focus
|
| 19 |
+
max_score: 50
|
| 20 |
+
trigger_feature: top20_fraction
|
| 21 |
+
trigger_condition: gt
|
| 22 |
+
trigger_value: 0.4
|
| 23 |
+
message: "Too many competing focal areas — reduce background detail, blur non-essential zones, or remove decorative clutter."
|
| 24 |
+
max_gain: 18
|
| 25 |
+
|
| 26 |
+
- sub_score: readability
|
| 27 |
+
max_score: 50
|
| 28 |
+
trigger_feature: ocr_confidence
|
| 29 |
+
trigger_condition: lt
|
| 30 |
+
trigger_value: 0.6
|
| 31 |
+
message: "Increase text contrast: use darker text on lighter backgrounds (or vice versa). Ensure font size is at least 2% of image height."
|
| 32 |
+
max_gain: 20
|
| 33 |
+
|
| 34 |
+
- sub_score: readability
|
| 35 |
+
max_score: 50
|
| 36 |
+
trigger_feature: text_coverage
|
| 37 |
+
trigger_condition: gt
|
| 38 |
+
trigger_value: 0.4
|
| 39 |
+
message: "Reduce text density — move details to a caption or legend below the image. Use bullet points instead of paragraphs."
|
| 40 |
+
max_gain: 15
|
| 41 |
+
|
| 42 |
+
- sub_score: readability
|
| 43 |
+
max_score: 50
|
| 44 |
+
trigger_feature: word_count
|
| 45 |
+
trigger_condition: gt
|
| 46 |
+
trigger_value: 30
|
| 47 |
+
message: "Shorten labels to 2–3 words each; move long descriptions outside the image. Use abbreviations where appropriate."
|
| 48 |
+
max_gain: 12
|
| 49 |
+
|
| 50 |
+
- sub_score: complexity_balance
|
| 51 |
+
max_score: 50
|
| 52 |
+
trigger_feature: edge_density
|
| 53 |
+
trigger_condition: gt
|
| 54 |
+
trigger_value: 0.15
|
| 55 |
+
message: "Simplify: remove decorative borders, reduce line detail, and increase spacing between visual elements."
|
| 56 |
+
max_gain: 18
|
| 57 |
+
|
| 58 |
+
- sub_score: complexity_balance
|
| 59 |
+
max_score: 50
|
| 60 |
+
trigger_feature: edge_density
|
| 61 |
+
trigger_condition: lt
|
| 62 |
+
trigger_value: 0.03
|
| 63 |
+
message: "Add structural elements: borders, dividers, icons, or visual markers to guide the eye and provide visual structure."
|
| 64 |
+
max_gain: 15
|
| 65 |
+
|
| 66 |
+
- sub_score: complexity_balance
|
| 67 |
+
max_score: 50
|
| 68 |
+
trigger_feature: color_entropy
|
| 69 |
+
trigger_condition: gt
|
| 70 |
+
trigger_value: 5.0
|
| 71 |
+
message: "Reduce color palette to 3–5 main colors; use a consistent color scheme. Use shades of the same hue for related elements."
|
| 72 |
+
max_gain: 12
|
| 73 |
+
|
| 74 |
+
- sub_score: communication_clarity
|
| 75 |
+
max_score: 50
|
| 76 |
+
trigger_feature: whitespace_ratio
|
| 77 |
+
trigger_condition: lt
|
| 78 |
+
trigger_value: 0.05
|
| 79 |
+
message: "Increase padding and margins — add whitespace around key elements. White space is not wasted space."
|
| 80 |
+
max_gain: 15
|
| 81 |
+
|
| 82 |
+
- sub_score: communication_clarity
|
| 83 |
+
max_score: 50
|
| 84 |
+
trigger_feature: contrast
|
| 85 |
+
trigger_condition: lt
|
| 86 |
+
trigger_value: 0.3
|
| 87 |
+
message: "Increase overall contrast — the image appears flat/washed out. Darken darks and lighten lights."
|
| 88 |
+
max_gain: 18
|
| 89 |
+
|
| 90 |
+
- sub_score: communication_clarity
|
| 91 |
+
max_score: 50
|
| 92 |
+
trigger_feature: symmetry_lr
|
| 93 |
+
trigger_condition: lt
|
| 94 |
+
trigger_value: 0.7
|
| 95 |
+
message: "Balance the composition — distribute visual weight more evenly left-to-right to avoid a lopsided appearance."
|
| 96 |
+
max_gain: 10
|
| 97 |
+
|
| 98 |
+
- sub_score: concept_match
|
| 99 |
+
max_score: 50
|
| 100 |
+
trigger_feature: concept_cosine
|
| 101 |
+
trigger_condition: lt
|
| 102 |
+
trigger_value: 0.2
|
| 103 |
+
message: "The image doesn't clearly convey the intended concept — add concept-specific visual elements, icons, or labels that reinforce the topic."
|
| 104 |
+
max_gain: 25
|
| 105 |
+
|
| 106 |
+
- sub_score: neural_richness
|
| 107 |
+
max_score: 50
|
| 108 |
+
trigger_feature: clip_norm
|
| 109 |
+
trigger_condition: lt
|
| 110 |
+
trigger_value: 15
|
| 111 |
+
message: "Add visual variety: mix textures, depths, or scene complexity to enrich the visual signal. Avoid overly uniform or flat backgrounds."
|
| 112 |
+
max_gain: 12
|
| 113 |
+
|
| 114 |
+
- sub_score: memorability_proxy
|
| 115 |
+
max_score: 50
|
| 116 |
+
trigger_feature: nima_aesthetic_proxy
|
| 117 |
+
trigger_condition: lt
|
| 118 |
+
trigger_value: 0.4
|
| 119 |
+
message: "Improve visual appeal: better lighting, more saturated key colors, or a more dynamic and unexpected composition."
|
| 120 |
+
max_gain: 15
|
| 121 |
+
|
| 122 |
+
fallback_messages:
|
| 123 |
+
visual_focus: "Improve the focal point by making the main subject larger, more colorful, or more isolated."
|
| 124 |
+
readability: "Review all text: increase contrast and font size, shorten labels, and declutter the text areas."
|
| 125 |
+
complexity_balance: "Reduce or add visual elements to find a balance — neither empty nor overcrowded."
|
| 126 |
+
communication_clarity: "Improve the visual hierarchy: use spacing, alignment, and contrast to guide the viewer's eye."
|
| 127 |
+
concept_match: "Strengthen the connection to the topic — add icons, labels, or imagery that directly illustrate the concept."
|
| 128 |
+
neural_richness: "Increase visual depth and variety through texture, lighting, or compositional complexity."
|
| 129 |
+
memorability_proxy: "Add an unusual or striking element — a bold color, unexpected angle, or strong visual metaphor."
|
features/__init__.py
ADDED
|
File without changes
|
features/heuristics.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Heuristic image feature extraction using OpenCV/numpy/scipy."""
|
| 2 |
+
|
| 3 |
+
import cv2
|
| 4 |
+
import numpy as np
|
| 5 |
+
from PIL import Image
|
| 6 |
+
from scipy.stats import entropy as scipy_entropy
|
| 7 |
+
from typing import Dict
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def compute_heuristic_features(img: Image.Image) -> Dict[str, float]:
|
| 11 |
+
"""
|
| 12 |
+
Compute 9+ heuristic features from a PIL Image using OpenCV.
|
| 13 |
+
|
| 14 |
+
Returns dict with keys:
|
| 15 |
+
edge_density, contrast, whitespace_ratio, near_white_ratio,
|
| 16 |
+
color_entropy, saturation_entropy, symmetry_lr, symmetry_tb,
|
| 17 |
+
sharpness, center_weight
|
| 18 |
+
"""
|
| 19 |
+
img_rgb = img.convert("RGB")
|
| 20 |
+
arr = np.array(img_rgb)
|
| 21 |
+
gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
|
| 22 |
+
H, W = gray.shape
|
| 23 |
+
|
| 24 |
+
features = {}
|
| 25 |
+
|
| 26 |
+
# 1. Edge density via Canny
|
| 27 |
+
edges = cv2.Canny(gray, 100, 200)
|
| 28 |
+
features["edge_density"] = float(edges.sum()) / (255 * H * W + 1e-8)
|
| 29 |
+
|
| 30 |
+
# 2. Contrast (std of luminance / max possible)
|
| 31 |
+
features["contrast"] = float(gray.std()) / 127.5
|
| 32 |
+
|
| 33 |
+
# 3. Whitespace ratio (near-white pixels)
|
| 34 |
+
features["whitespace_ratio"] = float((gray > 240).sum()) / (H * W + 1e-8)
|
| 35 |
+
features["near_white_ratio"] = float((gray > 200).sum()) / (H * W + 1e-8)
|
| 36 |
+
|
| 37 |
+
# 4. Color entropy (average per-channel)
|
| 38 |
+
entropies = []
|
| 39 |
+
for c in range(3):
|
| 40 |
+
hist, _ = np.histogram(arr[:, :, c], bins=256, range=(0, 256))
|
| 41 |
+
entropies.append(float(scipy_entropy(hist + 1)))
|
| 42 |
+
features["color_entropy"] = float(np.mean(entropies))
|
| 43 |
+
|
| 44 |
+
# 5. Saturation entropy (from HSV)
|
| 45 |
+
hsv = cv2.cvtColor(arr, cv2.COLOR_RGB2HSV)
|
| 46 |
+
sat_hist, _ = np.histogram(hsv[:, :, 1].ravel(), bins=256, range=(0, 256))
|
| 47 |
+
features["saturation_entropy"] = float(scipy_entropy(sat_hist + 1))
|
| 48 |
+
|
| 49 |
+
# 6. Symmetry (left-right)
|
| 50 |
+
flipped_lr = np.fliplr(gray).astype(float)
|
| 51 |
+
gray_f = gray.astype(float)
|
| 52 |
+
denom_lr = np.sqrt((gray_f ** 2).sum() * (flipped_lr ** 2).sum()) + 1e-8
|
| 53 |
+
features["symmetry_lr"] = float((gray_f * flipped_lr).sum() / denom_lr)
|
| 54 |
+
|
| 55 |
+
# 7. Symmetry (top-bottom)
|
| 56 |
+
flipped_tb = np.flipud(gray).astype(float)
|
| 57 |
+
denom_tb = np.sqrt((gray_f ** 2).sum() * (flipped_tb ** 2).sum()) + 1e-8
|
| 58 |
+
features["symmetry_tb"] = float((gray_f * flipped_tb).sum() / denom_tb)
|
| 59 |
+
|
| 60 |
+
# 8. Sharpness (Laplacian variance, log-normalized)
|
| 61 |
+
lap_var = cv2.Laplacian(gray, cv2.CV_64F).var()
|
| 62 |
+
features["sharpness"] = float(np.log1p(lap_var) / 10.0)
|
| 63 |
+
|
| 64 |
+
# 9. Center weight (rule of thirds center vs overall)
|
| 65 |
+
h3, w3 = max(1, H // 3), max(1, W // 3)
|
| 66 |
+
if h3 * 2 <= H and w3 * 2 <= W:
|
| 67 |
+
thirds_zone = gray[h3:2*h3, w3:2*w3]
|
| 68 |
+
center_mean = float(thirds_zone.mean())
|
| 69 |
+
else:
|
| 70 |
+
center_mean = float(gray.mean())
|
| 71 |
+
features["center_weight"] = center_mean / (gray.mean() + 1e-8)
|
| 72 |
+
|
| 73 |
+
return features
|
features/neural_richness.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Neural richness proxy computation.
|
| 2 |
+
|
| 3 |
+
Uses composite signals derived from CLIP features, saliency, and aesthetic scores
|
| 4 |
+
as a proxy for predicted brain-response richness.
|
| 5 |
+
|
| 6 |
+
In a production V2 deployment, this module could be swapped to use
|
| 7 |
+
V-JEPA-2 features or a precomputed TRIBE scoring service.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
from typing import Dict
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def compute_neural_richness_proxy(
|
| 15 |
+
semantic_features: Dict[str, float],
|
| 16 |
+
saliency_features: Dict[str, float],
|
| 17 |
+
quality_features: Dict[str, float],
|
| 18 |
+
heuristic_features: Dict[str, float]
|
| 19 |
+
) -> float:
|
| 20 |
+
"""
|
| 21 |
+
Compute a neural richness proxy score from available features.
|
| 22 |
+
|
| 23 |
+
This is a weighted composite of signals known to correlate with
|
| 24 |
+
human visual engagement and brain response breadth:
|
| 25 |
+
|
| 26 |
+
- CLIP embedding complexity (rich visual representations)
|
| 27 |
+
- Saliency spatial spread (distributed attention engagement)
|
| 28 |
+
- Aesthetic signal (reward-circuit correlates)
|
| 29 |
+
- Color/feature diversity (visual cortex breadth)
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
semantic_features: from features/semantic.py
|
| 33 |
+
saliency_features: from features/saliency.py
|
| 34 |
+
quality_features: from features/quality.py
|
| 35 |
+
heuristic_features: from features/heuristics.py
|
| 36 |
+
|
| 37 |
+
Returns:
|
| 38 |
+
Float in [0, 100] representing predicted neural richness.
|
| 39 |
+
"""
|
| 40 |
+
# CLIP complexity: normalized embedding norm
|
| 41 |
+
clip_norm = semantic_features.get("clip_image_norm", 0.0)
|
| 42 |
+
# Typical range observed: 15–35 for CLIP ViT-B/32 features
|
| 43 |
+
clip_complexity = _sigmoid_normalize(clip_norm, center=25.0, scale=8.0)
|
| 44 |
+
|
| 45 |
+
# Saliency spread: entropy of saliency map (already normalized in saliency_features)
|
| 46 |
+
saliency_spread = saliency_features.get("saliency_entropy", 0.5)
|
| 47 |
+
|
| 48 |
+
# Aesthetic signal: NIMA proxy
|
| 49 |
+
aesthetic = quality_features.get("nima_aesthetic_proxy", 0.5)
|
| 50 |
+
|
| 51 |
+
# Feature diversity: color entropy (normalized by max possible ~5.55)
|
| 52 |
+
color_entropy = heuristic_features.get("color_entropy", 3.0)
|
| 53 |
+
feature_diversity = _sigmoid_normalize(color_entropy, center=4.5, scale=1.5)
|
| 54 |
+
|
| 55 |
+
# Edge density (complexity proxy)
|
| 56 |
+
edge_density = heuristic_features.get("edge_density", 0.08)
|
| 57 |
+
complexity = _sigmoid_normalize(edge_density, center=0.10, scale=0.06)
|
| 58 |
+
|
| 59 |
+
# Weighted combination
|
| 60 |
+
weights = {
|
| 61 |
+
"clip_complexity": 0.25,
|
| 62 |
+
"saliency_spread": 0.20,
|
| 63 |
+
"aesthetic": 0.20,
|
| 64 |
+
"feature_diversity": 0.15,
|
| 65 |
+
"complexity": 0.20,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
score = (
|
| 69 |
+
weights["clip_complexity"] * clip_complexity +
|
| 70 |
+
weights["saliency_spread"] * saliency_spread +
|
| 71 |
+
weights["aesthetic"] * aesthetic +
|
| 72 |
+
weights["feature_diversity"] * feature_diversity +
|
| 73 |
+
weights["complexity"] * complexity
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
return float(np.clip(score * 100, 0, 100))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _sigmoid_normalize(x: float, center: float, scale: float) -> float:
|
| 80 |
+
"""Map x to [0,1] using logistic sigmoid centered at `center` with width `scale`."""
|
| 81 |
+
return 1.0 / (1.0 + np.exp(-(x - center) / scale))
|
features/ocr.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OCR-based readability features. Fallback to heuristic when OCR unavailable."""
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
from PIL import Image
|
| 5 |
+
from typing import Dict
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _compute_text_heuristic(img: Image.Image) -> Dict[str, float]:
|
| 9 |
+
"""
|
| 10 |
+
Fallback heuristic: detect high-contrast regions as proxy for text presence.
|
| 11 |
+
Returns reasonable defaults when OCR is unavailable.
|
| 12 |
+
"""
|
| 13 |
+
gray = np.array(img.convert("L")).astype(np.float32)
|
| 14 |
+
# Edge density is proxy for text
|
| 15 |
+
edges = np.abs(np.diff(gray, axis=1, append=gray[:, -1:])) + np.abs(np.diff(gray, axis=0, append=gray[-1:, :]))
|
| 16 |
+
text_proxy = float(edges.mean()) / (gray.max() + 1e-8)
|
| 17 |
+
|
| 18 |
+
return {
|
| 19 |
+
"text_coverage": 0.0,
|
| 20 |
+
"avg_ocr_confidence": 0.0,
|
| 21 |
+
"word_count": 0,
|
| 22 |
+
"text_density": 0.0,
|
| 23 |
+
"avg_text_height_ratio": 0.0,
|
| 24 |
+
"has_text": False,
|
| 25 |
+
"text_proxy": text_proxy,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def compute_ocr_features(img: Image.Image, ocr_results: list = None) -> Dict[str, float]:
|
| 30 |
+
"""
|
| 31 |
+
Compute OCR-based readability features.
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
img: PIL Image
|
| 35 |
+
ocr_results: List of (bbox, text, confidence) tuples from EasyOCR.
|
| 36 |
+
If None, uses heuristic fallback.
|
| 37 |
+
|
| 38 |
+
Returns dict with keys:
|
| 39 |
+
text_coverage, avg_ocr_confidence, word_count, text_density,
|
| 40 |
+
avg_text_height_ratio, has_text, text_proxy
|
| 41 |
+
"""
|
| 42 |
+
if ocr_results is None:
|
| 43 |
+
return _compute_text_heuristic(img)
|
| 44 |
+
|
| 45 |
+
total_area = img.width * img.height
|
| 46 |
+
|
| 47 |
+
if not ocr_results:
|
| 48 |
+
return {
|
| 49 |
+
"text_coverage": 0.0,
|
| 50 |
+
"avg_ocr_confidence": 0.0,
|
| 51 |
+
"word_count": 0,
|
| 52 |
+
"text_density": 0.0,
|
| 53 |
+
"avg_text_height_ratio": 0.0,
|
| 54 |
+
"has_text": False,
|
| 55 |
+
"text_proxy": 0.0,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
text_area = 0.0
|
| 59 |
+
total_conf = 0.0
|
| 60 |
+
total_height = 0.0
|
| 61 |
+
|
| 62 |
+
for result in ocr_results:
|
| 63 |
+
# result[0] is bbox: [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
|
| 64 |
+
# result[1] is text string
|
| 65 |
+
# result[2] is confidence
|
| 66 |
+
bbox = result[0]
|
| 67 |
+
conf = result[2]
|
| 68 |
+
|
| 69 |
+
# Approximate area from bbox
|
| 70 |
+
xs = [p[0] for p in bbox]
|
| 71 |
+
ys = [p[1] for p in bbox]
|
| 72 |
+
area = (max(xs) - min(xs)) * (max(ys) - min(ys))
|
| 73 |
+
text_area += area
|
| 74 |
+
total_conf += conf
|
| 75 |
+
total_height += max(ys) - min(ys)
|
| 76 |
+
|
| 77 |
+
num_detections = len(ocr_results)
|
| 78 |
+
avg_height = total_height / num_detections
|
| 79 |
+
|
| 80 |
+
return {
|
| 81 |
+
"text_coverage": float(text_area / (total_area + 1e-8)),
|
| 82 |
+
"avg_ocr_confidence": float(total_conf / num_detections),
|
| 83 |
+
"word_count": num_detections,
|
| 84 |
+
"text_density": float(num_detections / (total_area / 10000 + 1e-8)),
|
| 85 |
+
"avg_text_height_ratio": float(avg_height / (img.height + 1e-8)),
|
| 86 |
+
"has_text": True,
|
| 87 |
+
"text_proxy": float(total_conf / num_detections),
|
| 88 |
+
}
|
features/quality.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image quality and aesthetic features using lightweight proxies.
|
| 2 |
+
Since pyiqa may not be available in all environments, we use PIL/NumPy heuristics
|
| 3 |
+
that correlate with known quality dimensions."""
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
from PIL import Image, ImageStat
|
| 7 |
+
from typing import Dict
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def compute_quality_features(img: Image.Image) -> Dict[str, float]:
|
| 11 |
+
"""
|
| 12 |
+
Compute image quality and aesthetic proxy features.
|
| 13 |
+
|
| 14 |
+
These are lightweight heuristics. In production, swap with:
|
| 15 |
+
- pyiqa.create_metric('topiq_nr')
|
| 16 |
+
- pyiqa.create_metric('nima')
|
| 17 |
+
- pyiqa.create_metric('clipiqa+')
|
| 18 |
+
|
| 19 |
+
Returns dict with keys:
|
| 20 |
+
topiq_nr_proxy, nima_aesthetic_proxy, clipiqa_proxy,
|
| 21 |
+
laion_aesthetic_proxy, sharpness, colorfulness
|
| 22 |
+
"""
|
| 23 |
+
arr = np.array(img.convert("RGB")).astype(np.float32)
|
| 24 |
+
gray = arr.mean(axis=2)
|
| 25 |
+
H, W = gray.shape
|
| 26 |
+
|
| 27 |
+
features = {}
|
| 28 |
+
|
| 29 |
+
# --- TOP-IQ NR proxy: combined sharpness + contrast + noise ---
|
| 30 |
+
# Sharpness: Laplacian variance
|
| 31 |
+
lap = cv2.Laplacian(gray.astype(np.uint8), cv2.CV_64F).var() if 'cv2' in globals() else 0.0
|
| 32 |
+
if lap == 0.0:
|
| 33 |
+
import cv2
|
| 34 |
+
lap = cv2.Laplacian(gray.astype(np.uint8), cv2.CV_64F).var()
|
| 35 |
+
|
| 36 |
+
# Contrast: Michelson-ish measure
|
| 37 |
+
contrast_proxy = (gray.max() - gray.min()) / (gray.max() + gray.min() + 1e-8)
|
| 38 |
+
# Noise: estimate via local variance
|
| 39 |
+
local_var = np.var(gray[::4, ::4])
|
| 40 |
+
noise_proxy = 1.0 - np.exp(-local_var / 1000.0) # higher var = less noise bias
|
| 41 |
+
|
| 42 |
+
# Composite quality proxy: sharpness * contrast * (1 - noise_penalty)
|
| 43 |
+
sharp_norm = np.log1p(lap) / 10.0
|
| 44 |
+
features["topiq_nr_proxy"] = float(np.clip(sharp_norm * contrast_proxy * noise_proxy, 0, 1))
|
| 45 |
+
|
| 46 |
+
# --- NIMA aesthetic proxy: composition + color + exposure ---
|
| 47 |
+
# Colorfulness (Hasler & Süsstrunk approximation)
|
| 48 |
+
rg = np.abs(arr[:, :, 0] - arr[:, :, 1])
|
| 49 |
+
yb = np.abs(0.5 * (arr[:, :, 0] + arr[:, :, 1]) - arr[:, :, 2])
|
| 50 |
+
colorfulness = float(np.sqrt(rg.std()**2 + yb.std()**2) + 0.3 * np.sqrt(rg.mean()**2 + yb.mean()**2))
|
| 51 |
+
color_score = np.clip(colorfulness / 100.0, 0, 1)
|
| 52 |
+
|
| 53 |
+
# Exposure balance: histogram centering
|
| 54 |
+
hist, _ = np.histogram(gray.ravel(), bins=256, range=(0, 256))
|
| 55 |
+
hist_norm = hist / (hist.sum() + 1e-8)
|
| 56 |
+
mean_idx = np.sum(np.arange(256) * hist_norm)
|
| 57 |
+
exposure_score = 1.0 - abs(mean_idx - 128.0) / 128.0
|
| 58 |
+
|
| 59 |
+
# Composition: rule of thirds center
|
| 60 |
+
h3, w3 = max(1, H // 3), max(1, W // 3)
|
| 61 |
+
if h3 * 2 <= H and w3 * 2 <= W:
|
| 62 |
+
center_brightness = gray[h3:2*h3, w3:2*w3].mean() / 255.0
|
| 63 |
+
else:
|
| 64 |
+
center_brightness = gray.mean() / 255.0
|
| 65 |
+
|
| 66 |
+
# Weighted aesthetic proxy
|
| 67 |
+
features["nima_aesthetic_proxy"] = float(np.clip(
|
| 68 |
+
0.3 * sharp_norm + 0.3 * color_score + 0.2 * exposure_score + 0.2 * center_brightness,
|
| 69 |
+
0, 1
|
| 70 |
+
))
|
| 71 |
+
|
| 72 |
+
# --- CLIP-IQA+ proxy: image-text quality correlation ---
|
| 73 |
+
# Proxy: how "natural" / well-composed the image appears
|
| 74 |
+
# Use edge + color + exposure composite
|
| 75 |
+
features["clipiqa_proxy"] = features["nima_aesthetic_proxy"] # reuse for now
|
| 76 |
+
|
| 77 |
+
# --- LAION aesthetic proxy ---
|
| 78 |
+
features["laion_aesthetic_proxy"] = features["nima_aesthetic_proxy"]
|
| 79 |
+
|
| 80 |
+
# Individual quality signals
|
| 81 |
+
features["sharpness"] = sharp_norm
|
| 82 |
+
features["colorfulness"] = color_score
|
| 83 |
+
features["exposure_balance"] = exposure_score
|
| 84 |
+
|
| 85 |
+
return features
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _try_pyiqa_features(img_path: str) -> Dict[str, float]:
|
| 89 |
+
"""
|
| 90 |
+
Attempt to use pyiqa models if available.
|
| 91 |
+
Falls back silently.
|
| 92 |
+
|
| 93 |
+
Returns empty dict if pyiqa is not available.
|
| 94 |
+
"""
|
| 95 |
+
try:
|
| 96 |
+
import pyiqa
|
| 97 |
+
results = {}
|
| 98 |
+
for metric_name in ['brisque', 'niqe', 'clipiqa']:
|
| 99 |
+
try:
|
| 100 |
+
metric = pyiqa.create_metric(metric_name, device='cpu')
|
| 101 |
+
score = metric(img_path).item()
|
| 102 |
+
results[f"pyiqa_{metric_name}"] = score
|
| 103 |
+
except Exception:
|
| 104 |
+
pass
|
| 105 |
+
return results
|
| 106 |
+
except ImportError:
|
| 107 |
+
return {}
|
features/saliency.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Saliency map computation using pretrained MSI-Net (fallback to heuristic)."""
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
from PIL import Image
|
| 5 |
+
from scipy.stats import entropy as scipy_entropy
|
| 6 |
+
from typing import Dict, Optional
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _compute_heuristic_saliency(img: Image.Image) -> np.ndarray:
|
| 10 |
+
"""
|
| 11 |
+
Fallback saliency using luminance contrast and color variance.
|
| 12 |
+
Returns a pseudo-saliency map in [0,1].
|
| 13 |
+
"""
|
| 14 |
+
arr = np.array(img.convert("RGB")).astype(np.float32)
|
| 15 |
+
gray = arr.mean(axis=2)
|
| 16 |
+
# Compute local contrast via gradient magnitude
|
| 17 |
+
dx = np.abs(np.diff(gray, axis=1, append=gray[:, -1:]))
|
| 18 |
+
dy = np.abs(np.diff(gray, axis=0, append=gray[-1:, :]))
|
| 19 |
+
grad = np.sqrt(dx**2 + dy**2)
|
| 20 |
+
# Normalize
|
| 21 |
+
sal = grad / (grad.max() + 1e-8)
|
| 22 |
+
return sal.astype(np.float32)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def compute_saliency_features(img: Image.Image, saliency_map: Optional[np.ndarray] = None) -> Dict[str, float]:
|
| 26 |
+
"""
|
| 27 |
+
Compute saliency-based features from an image.
|
| 28 |
+
|
| 29 |
+
If MSI-Net fails or is not loaded, falls back to heuristic.
|
| 30 |
+
"""
|
| 31 |
+
if saliency_map is None:
|
| 32 |
+
saliency_map = _compute_heuristic_saliency(img)
|
| 33 |
+
|
| 34 |
+
sal_flat = saliency_map.ravel()
|
| 35 |
+
sal_probs = sal_flat / (sal_flat.sum() + 1e-8)
|
| 36 |
+
|
| 37 |
+
features = {}
|
| 38 |
+
|
| 39 |
+
# 1. Peak saliency
|
| 40 |
+
features["peak_saliency"] = float(saliency_map.max())
|
| 41 |
+
|
| 42 |
+
# 2. Mean saliency
|
| 43 |
+
features["mean_saliency"] = float(saliency_map.mean())
|
| 44 |
+
|
| 45 |
+
# 3. Saliency entropy (spatial spread)
|
| 46 |
+
max_entropy = np.log(len(sal_flat) + 1e-8)
|
| 47 |
+
features["saliency_entropy"] = float(scipy_entropy(sal_probs + 1e-10)) / max_entropy
|
| 48 |
+
|
| 49 |
+
# 4. Top-20% fraction
|
| 50 |
+
threshold = 0.8 * features["peak_saliency"]
|
| 51 |
+
features["top20_fraction"] = float((saliency_map > threshold).sum()) / (saliency_map.size + 1e-8)
|
| 52 |
+
|
| 53 |
+
# 5. Center saliency (central third)
|
| 54 |
+
H, W = saliency_map.shape
|
| 55 |
+
h3, w3 = max(1, H // 3), max(1, W // 3)
|
| 56 |
+
center_zone = saliency_map[h3:2*h3, w3:2*w3]
|
| 57 |
+
features["center_saliency"] = float(center_zone.mean())
|
| 58 |
+
|
| 59 |
+
# 6. Saliency uniformity (lower = more varied = more interesting)
|
| 60 |
+
features["saliency_uniformity"] = float(saliency_map.std() / (features["mean_saliency"] + 1e-8))
|
| 61 |
+
|
| 62 |
+
return features
|
features/semantic.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Semantic / concept matching features using CLIP or SigLIP."""
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
from PIL import Image
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
from typing import Dict, Optional, Tuple
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SemanticFeatureExtractor:
|
| 11 |
+
"""Extracts concept-match features from image + user text using CLIP."""
|
| 12 |
+
|
| 13 |
+
def __init__(self, model, processor, device="cpu"):
|
| 14 |
+
self.model = model
|
| 15 |
+
self.processor = processor
|
| 16 |
+
self.device = device
|
| 17 |
+
|
| 18 |
+
@classmethod
|
| 19 |
+
def from_clip(cls, model_id="openai/clip-vit-base-patch32", device="cpu"):
|
| 20 |
+
"""Load a standard CLIP model from transformers."""
|
| 21 |
+
try:
|
| 22 |
+
from transformers import CLIPModel, CLIPProcessor
|
| 23 |
+
model = CLIPModel.from_pretrained(model_id)
|
| 24 |
+
processor = CLIPProcessor.from_pretrained(model_id)
|
| 25 |
+
model = model.to(device)
|
| 26 |
+
if device == "cpu":
|
| 27 |
+
model = model.float() # avoid half precision issues on CPU
|
| 28 |
+
return cls(model, processor, device)
|
| 29 |
+
except Exception as e:
|
| 30 |
+
print(f"[SemanticFeatureExtractor] CLIP load failed: {e}")
|
| 31 |
+
return cls(None, None, device)
|
| 32 |
+
|
| 33 |
+
def _encode_image(self, img: Image.Image) -> Optional[torch.Tensor]:
|
| 34 |
+
"""Encode image with CLIP via vision_model + visual_projection."""
|
| 35 |
+
if self.model is None or self.processor is None:
|
| 36 |
+
return None
|
| 37 |
+
try:
|
| 38 |
+
inputs = self.processor(images=img, return_tensors="pt").to(self.device)
|
| 39 |
+
with torch.no_grad():
|
| 40 |
+
vision_outputs = self.model.vision_model(pixel_values=inputs["pixel_values"])
|
| 41 |
+
image_embeds = vision_outputs.pooler_output
|
| 42 |
+
image_features = self.model.visual_projection(image_embeds)
|
| 43 |
+
return F.normalize(image_features, dim=-1)
|
| 44 |
+
except Exception as e:
|
| 45 |
+
print(f"[SemanticFeatureExtractor] Image encode failed: {e}")
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
def _encode_text(self, texts: list) -> Optional[torch.Tensor]:
|
| 49 |
+
"""Encode text prompts with CLIP via text_model + text_projection."""
|
| 50 |
+
if self.model is None or self.processor is None:
|
| 51 |
+
return None
|
| 52 |
+
try:
|
| 53 |
+
inputs = self.processor(text=texts, return_tensors="pt", padding=True).to(self.device)
|
| 54 |
+
with torch.no_grad():
|
| 55 |
+
text_outputs = self.model.text_model(**inputs)
|
| 56 |
+
text_embeds = text_outputs.pooler_output
|
| 57 |
+
text_features = self.model.text_projection(text_embeds)
|
| 58 |
+
return F.normalize(text_features, dim=-1)
|
| 59 |
+
except Exception as e:
|
| 60 |
+
print(f"[SemanticFeatureExtractor] Text encode failed: {e}")
|
| 61 |
+
return None
|
| 62 |
+
|
| 63 |
+
def compute(self, img: Image.Image, concept: str, audience: str, use_case: str) -> Dict[str, float]:
|
| 64 |
+
"""
|
| 65 |
+
Compute concept-match features.
|
| 66 |
+
|
| 67 |
+
Returns dict with keys:
|
| 68 |
+
concept_cosine, audience_cosine, usecase_cosine, composite_cosine,
|
| 69 |
+
clip_image_norm, clip_image_std
|
| 70 |
+
"""
|
| 71 |
+
img_features = self._encode_image(img)
|
| 72 |
+
|
| 73 |
+
if img_features is None:
|
| 74 |
+
# Fallback: heuristic based on image size and color variety
|
| 75 |
+
arr = np.array(img.convert("RGB"))
|
| 76 |
+
return {
|
| 77 |
+
"concept_cosine": 0.0,
|
| 78 |
+
"audience_cosine": 0.0,
|
| 79 |
+
"usecase_cosine": 0.0,
|
| 80 |
+
"composite_cosine": 0.0,
|
| 81 |
+
"clip_image_norm": 0.0,
|
| 82 |
+
"clip_image_std": 0.0,
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
# Build prompts
|
| 86 |
+
prompts = [
|
| 87 |
+
concept or "an image",
|
| 88 |
+
f"{concept or 'an image'} for {audience}" if audience else concept or "an image",
|
| 89 |
+
f"{use_case}: {concept or 'an image'}" if use_case else concept or "an image",
|
| 90 |
+
]
|
| 91 |
+
text_features = self._encode_text(prompts)
|
| 92 |
+
|
| 93 |
+
if text_features is None:
|
| 94 |
+
return {
|
| 95 |
+
"concept_cosine": 0.0,
|
| 96 |
+
"audience_cosine": 0.0,
|
| 97 |
+
"usecase_cosine": 0.0,
|
| 98 |
+
"composite_cosine": 0.0,
|
| 99 |
+
"clip_image_norm": float(img_features.norm(dim=-1).item()),
|
| 100 |
+
"clip_image_std": float(img_features.std(dim=-1).item()),
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
# Cosine similarities (already normalized)
|
| 104 |
+
sims = (img_features @ text_features.T).squeeze(0)
|
| 105 |
+
|
| 106 |
+
return {
|
| 107 |
+
"concept_cosine": float(sims[0].item()),
|
| 108 |
+
"audience_cosine": float(sims[1].item()),
|
| 109 |
+
"usecase_cosine": float(sims[2].item()),
|
| 110 |
+
"composite_cosine": float(sims.mean().item()),
|
| 111 |
+
"clip_image_norm": float(img_features.norm(dim=-1).item()),
|
| 112 |
+
"clip_image_std": float(img_features.std(dim=-1).item()),
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def compute_semantic_features(img: Image.Image, concept: str, audience: str, use_case: str,
|
| 117 |
+
extractor: Optional[SemanticFeatureExtractor] = None) -> Dict[str, float]:
|
| 118 |
+
"""
|
| 119 |
+
Compute semantic/concept-match features.
|
| 120 |
+
If extractor not provided, creates one (slow on first call).
|
| 121 |
+
"""
|
| 122 |
+
if extractor is None:
|
| 123 |
+
extractor = SemanticFeatureExtractor.from_clip(device="cpu")
|
| 124 |
+
return extractor.compute(img, concept, audience, use_case)
|
models/loader.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model loading and caching utilities."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from typing import Optional, Any
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class ModelLoader:
|
| 8 |
+
"""Singleton-like model loader that lazily loads and caches models."""
|
| 9 |
+
|
| 10 |
+
def __init__(self, config: dict = None):
|
| 11 |
+
self.config = config or {}
|
| 12 |
+
self._cache = {}
|
| 13 |
+
self._device = self._get_device()
|
| 14 |
+
|
| 15 |
+
def _get_device(self) -> str:
|
| 16 |
+
import torch
|
| 17 |
+
if torch.cuda.is_available():
|
| 18 |
+
return "cuda"
|
| 19 |
+
# Check for MPS (Apple Silicon)
|
| 20 |
+
if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
| 21 |
+
return "mps"
|
| 22 |
+
return "cpu"
|
| 23 |
+
|
| 24 |
+
def get_clip_model(self):
|
| 25 |
+
"""Load CLIP model and processor."""
|
| 26 |
+
if "clip" in self._cache:
|
| 27 |
+
return self._cache["clip"]
|
| 28 |
+
|
| 29 |
+
model_id = self.config.get("models", {}).get("clip", {}).get("model_id", "openai/clip-vit-base-patch32")
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
import torch
|
| 33 |
+
from transformers import CLIPModel, CLIPProcessor
|
| 34 |
+
|
| 35 |
+
print(f"[ModelLoader] Loading CLIP: {model_id}...")
|
| 36 |
+
model = CLIPModel.from_pretrained(model_id)
|
| 37 |
+
processor = CLIPProcessor.from_pretrained(model_id)
|
| 38 |
+
model = model.to(self._device)
|
| 39 |
+
if self._device == "cpu":
|
| 40 |
+
model = model.float() # Use full precision on CPU
|
| 41 |
+
|
| 42 |
+
self._cache["clip"] = (model, processor)
|
| 43 |
+
print(f"[ModelLoader] CLIP loaded on {self._device}")
|
| 44 |
+
return self._cache["clip"]
|
| 45 |
+
except Exception as e:
|
| 46 |
+
print(f"[ModelLoader] CLIP load FAILED: {e}")
|
| 47 |
+
self._cache["clip"] = None
|
| 48 |
+
return None
|
| 49 |
+
|
| 50 |
+
def get_device(self) -> str:
|
| 51 |
+
return self._device
|
| 52 |
+
|
| 53 |
+
def warmup(self):
|
| 54 |
+
"""Preload critical models."""
|
| 55 |
+
self.get_clip_model()
|
| 56 |
+
|
| 57 |
+
def clear(self):
|
| 58 |
+
"""Clear model cache to free memory."""
|
| 59 |
+
import torch
|
| 60 |
+
import gc
|
| 61 |
+
for key in list(self._cache.keys()):
|
| 62 |
+
del self._cache[key]
|
| 63 |
+
gc.collect()
|
| 64 |
+
if torch.cuda.is_available():
|
| 65 |
+
torch.cuda.empty_cache()
|
| 66 |
+
self._cache.clear()
|
recommendations/engine.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Recommendation engine — turns score weaknesses into actionable suggestions."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import yaml
|
| 5 |
+
from typing import Dict, Any, List, Optional
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class Suggestion:
|
| 11 |
+
priority: int
|
| 12 |
+
sub_score_target: str
|
| 13 |
+
message: str
|
| 14 |
+
projected_gain: float
|
| 15 |
+
trigger_feature: str = ""
|
| 16 |
+
trigger_value: float = 0.0
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class RecommendationEngine:
|
| 20 |
+
"""Generates ranked improvement suggestions from sub-scores and raw features."""
|
| 21 |
+
|
| 22 |
+
def __init__(self, rules_path: Optional[str] = None):
|
| 23 |
+
self.rules = self._load_rules(rules_path)
|
| 24 |
+
self.target_score = 75.0
|
| 25 |
+
self.max_suggestions = 5
|
| 26 |
+
|
| 27 |
+
def _load_rules(self, rules_path: Optional[str]) -> List[Dict[str, Any]]:
|
| 28 |
+
"""Load suggestion rules from YAML."""
|
| 29 |
+
if rules_path and os.path.exists(rules_path):
|
| 30 |
+
with open(rules_path, 'r') as f:
|
| 31 |
+
data = yaml.safe_load(f) or {}
|
| 32 |
+
return data.get("rules", [])
|
| 33 |
+
|
| 34 |
+
# Try default paths
|
| 35 |
+
for path in ['configs/suggestion_rules.yaml', 'viral-images/configs/suggestion_rules.yaml']:
|
| 36 |
+
if os.path.exists(path):
|
| 37 |
+
with open(path, 'r') as f:
|
| 38 |
+
data = yaml.safe_load(f) or {}
|
| 39 |
+
return data.get("rules", [])
|
| 40 |
+
return []
|
| 41 |
+
|
| 42 |
+
def generate(self, sub_scores: Dict[str, float],
|
| 43 |
+
raw_features: Dict[str, Any],
|
| 44 |
+
use_case: str = "default") -> List[Suggestion]:
|
| 45 |
+
"""
|
| 46 |
+
Generate ranked improvement suggestions.
|
| 47 |
+
|
| 48 |
+
Args:
|
| 49 |
+
sub_scores: 8 sub-scores [0-100]
|
| 50 |
+
raw_features: Raw extracted features
|
| 51 |
+
use_case: Current use case preset
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
List of Suggestion objects, ranked by priority.
|
| 55 |
+
"""
|
| 56 |
+
suggestions = []
|
| 57 |
+
|
| 58 |
+
# Sort sub-scores ascending to find weakest areas
|
| 59 |
+
weakest = sorted(sub_scores.items(), key=lambda x: x[1])
|
| 60 |
+
|
| 61 |
+
# Try to match each weak sub-score with a specific rule
|
| 62 |
+
for sub_name, sub_value in weakest:
|
| 63 |
+
# Skip improvement_potential (meta-score)
|
| 64 |
+
if sub_name == "improvement_potential":
|
| 65 |
+
continue
|
| 66 |
+
|
| 67 |
+
# Find matching rules for this sub-score
|
| 68 |
+
matching = self._find_matching_rules(sub_name, sub_value, raw_features)
|
| 69 |
+
|
| 70 |
+
# Take best matching rule
|
| 71 |
+
for rule in matching[:2]: # max 2 per sub-score
|
| 72 |
+
gain = min(rule.get("max_gain", 15.0), self.target_score - sub_value)
|
| 73 |
+
gain = max(0, gain)
|
| 74 |
+
|
| 75 |
+
sugg = Suggestion(
|
| 76 |
+
priority=0, # assigned later
|
| 77 |
+
sub_score_target=sub_name,
|
| 78 |
+
message=rule.get("message", ""),
|
| 79 |
+
projected_gain=gain,
|
| 80 |
+
trigger_feature=rule.get("trigger_feature", ""),
|
| 81 |
+
trigger_value=rule.get("trigger_value", 0.0)
|
| 82 |
+
)
|
| 83 |
+
suggestions.append(sugg)
|
| 84 |
+
|
| 85 |
+
# Rank by: (gap to target) × (max_gain weight) = most impactful first
|
| 86 |
+
for sugg in suggestions:
|
| 87 |
+
current = sub_scores.get(sugg.sub_score_target, 50)
|
| 88 |
+
gap = max(0, self.target_score - current)
|
| 89 |
+
# Priority score = gap × projected_gain
|
| 90 |
+
sugg.priority = gap * sugg.projected_gain
|
| 91 |
+
|
| 92 |
+
# Sort descending by priority, then take top N
|
| 93 |
+
suggestions.sort(key=lambda x: -x.priority)
|
| 94 |
+
suggestions = suggestions[:self.max_suggestions]
|
| 95 |
+
|
| 96 |
+
# Renumber priorities 1..N
|
| 97 |
+
for i, sugg in enumerate(suggestions, 1):
|
| 98 |
+
sugg.priority = i
|
| 99 |
+
|
| 100 |
+
return suggestions
|
| 101 |
+
|
| 102 |
+
def _find_matching_rules(self, sub_score_name: str, sub_score_value: float,
|
| 103 |
+
raw_features: Dict[str, Any]) -> List[Dict]:
|
| 104 |
+
"""Find rules that match this sub-score's weakness."""
|
| 105 |
+
matches = []
|
| 106 |
+
|
| 107 |
+
for rule in self.rules:
|
| 108 |
+
# Check sub-score match
|
| 109 |
+
if rule.get("sub_score") != sub_score_name:
|
| 110 |
+
continue
|
| 111 |
+
|
| 112 |
+
# Check max_score threshold
|
| 113 |
+
if sub_score_value > rule.get("max_score", 100):
|
| 114 |
+
continue
|
| 115 |
+
|
| 116 |
+
# Check trigger feature condition
|
| 117 |
+
trigger_feature = rule.get("trigger_feature", "")
|
| 118 |
+
trigger_value = rule.get("trigger_value", 0.0)
|
| 119 |
+
trigger_cond = rule.get("trigger_condition", "lt")
|
| 120 |
+
|
| 121 |
+
# Look up trigger feature value in raw features
|
| 122 |
+
feat_key = None
|
| 123 |
+
# Try heuristic_, saliency_, ocr_, quality_, semantic_ prefixes
|
| 124 |
+
for prefix in ["heuristic_", "saliency_", "ocr_", "quality_", "semantic_"]:
|
| 125 |
+
candidate = f"{prefix}{trigger_feature}"
|
| 126 |
+
if candidate in raw_features:
|
| 127 |
+
feat_key = candidate
|
| 128 |
+
break
|
| 129 |
+
|
| 130 |
+
if feat_key is None:
|
| 131 |
+
feat_key = trigger_feature # try raw key
|
| 132 |
+
|
| 133 |
+
feat_val = raw_features.get(feat_key)
|
| 134 |
+
if feat_val is None:
|
| 135 |
+
continue
|
| 136 |
+
|
| 137 |
+
# Evaluate condition
|
| 138 |
+
matched = False
|
| 139 |
+
if trigger_cond == "lt" and feat_val < trigger_value:
|
| 140 |
+
matched = True
|
| 141 |
+
elif trigger_cond == "gt" and feat_val > trigger_value:
|
| 142 |
+
matched = True
|
| 143 |
+
elif trigger_cond == "eq" and abs(feat_val - trigger_value) < 0.01:
|
| 144 |
+
matched = True
|
| 145 |
+
|
| 146 |
+
if matched:
|
| 147 |
+
matches.append(rule)
|
| 148 |
+
|
| 149 |
+
return matches
|
| 150 |
+
|
| 151 |
+
def estimate_improvement(self, sub_scores: Dict[str, float],
|
| 152 |
+
suggestions: List[Suggestion],
|
| 153 |
+
use_case: str) -> float:
|
| 154 |
+
"""
|
| 155 |
+
Estimate overall score improvement if all suggestions are followed.
|
| 156 |
+
|
| 157 |
+
Returns projected overall score.
|
| 158 |
+
"""
|
| 159 |
+
if not suggestions:
|
| 160 |
+
return sub_scores.get("overall_score", 50.0)
|
| 161 |
+
|
| 162 |
+
# Apply projected gains to each targeted sub-score
|
| 163 |
+
projected = dict(sub_scores)
|
| 164 |
+
|
| 165 |
+
# Aggregate gains per sub-score (take max, not sum — overlapping suggestions)
|
| 166 |
+
gains_by_sub = {}
|
| 167 |
+
for sugg in suggestions:
|
| 168 |
+
gains_by_sub[sugg.sub_score_target] = max(
|
| 169 |
+
gains_by_sub.get(sugg.sub_score_target, 0),
|
| 170 |
+
sugg.projected_gain
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
for sub_name, gain in gains_by_sub.items():
|
| 174 |
+
current = projected.get(sub_name, 0)
|
| 175 |
+
projected[sub_name] = min(100, current + gain)
|
| 176 |
+
|
| 177 |
+
# Re-aggregate (same weights as scoring engine)
|
| 178 |
+
# For simplicity, approximate: mean of top 3 weakest improvements
|
| 179 |
+
improved_subs = [projected[s] for s in gains_by_sub.keys()]
|
| 180 |
+
if not improved_subs:
|
| 181 |
+
return sub_scores.get("overall_score", 50.0)
|
| 182 |
+
|
| 183 |
+
# Rough estimate: apply gains proportionally to their weights
|
| 184 |
+
# This is approximate — in production, call ScoringEngine._aggregate()
|
| 185 |
+
current_overall = sum(sub_scores.values()) / len(sub_scores)
|
| 186 |
+
projected_overall = sum(projected.values()) / len(projected)
|
| 187 |
+
|
| 188 |
+
return round(min(100, projected_overall), 1)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def compute_improvement_potential(sub_scores: Dict[str, float]) -> float:
|
| 192 |
+
"""Compute improvement potential as meta-score."""
|
| 193 |
+
target = 75.0
|
| 194 |
+
gaps = sorted([max(0, target - s) for s in sub_scores.values() if s != "improvement_potential"], reverse=True)
|
| 195 |
+
top3 = gaps[:3]
|
| 196 |
+
return round(sum(top3) / len(top3) if top3 else 0.0, 1)
|
scoring/engine.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Main scoring engine — orchestrates feature extraction, sub-score computation, and aggregation."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
import yaml
|
| 6 |
+
import numpy as np
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Dict, Any, Optional
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
from .normalizers import ScoreNormalizer
|
| 13 |
+
from features.heuristics import compute_heuristic_features
|
| 14 |
+
from features.saliency import compute_saliency_features
|
| 15 |
+
from features.ocr import compute_ocr_features
|
| 16 |
+
from features.quality import compute_quality_features
|
| 17 |
+
from features.semantic import compute_semantic_features, SemanticFeatureExtractor
|
| 18 |
+
from features.neural_richness import compute_neural_richness_proxy
|
| 19 |
+
from utils.preprocessing import preprocess_image, validate_image
|
| 20 |
+
from models.loader import ModelLoader
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class ScoreResponse:
|
| 25 |
+
overall_score: float = 0.0
|
| 26 |
+
sub_scores: Dict[str, float] = field(default_factory=dict)
|
| 27 |
+
confidence: float = 0.85
|
| 28 |
+
strengths: list = field(default_factory=list)
|
| 29 |
+
weaknesses: list = field(default_factory=list)
|
| 30 |
+
suggestions: list = field(default_factory=list)
|
| 31 |
+
projected_improvement: float = 0.0
|
| 32 |
+
raw_features: Dict[str, Any] = field(default_factory=dict)
|
| 33 |
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class ScoringEngine:
|
| 37 |
+
"""Orchestrates the full image scoring pipeline."""
|
| 38 |
+
|
| 39 |
+
def __init__(self, model_loader: Optional[ModelLoader] = None,
|
| 40 |
+
config_path: Optional[str] = None):
|
| 41 |
+
self.model_loader = model_loader or ModelLoader()
|
| 42 |
+
self.config = self._load_config(config_path)
|
| 43 |
+
self.normalizer = ScoreNormalizer(self.config)
|
| 44 |
+
self.extractor = None # Lazy init
|
| 45 |
+
self.warmup_complete = False
|
| 46 |
+
|
| 47 |
+
def _load_config(self, config_path: Optional[str]) -> dict:
|
| 48 |
+
"""Load scoring config from YAML."""
|
| 49 |
+
if config_path and os.path.exists(config_path):
|
| 50 |
+
with open(config_path, 'r') as f:
|
| 51 |
+
return yaml.safe_load(f) or {}
|
| 52 |
+
# Try default paths
|
| 53 |
+
for path in ['configs/scoring_weights.yaml', 'viral-images/configs/scoring_weights.yaml']:
|
| 54 |
+
if os.path.exists(path):
|
| 55 |
+
with open(path, 'r') as f:
|
| 56 |
+
return yaml.safe_load(f) or {}
|
| 57 |
+
return {}
|
| 58 |
+
|
| 59 |
+
def warmup(self):
|
| 60 |
+
"""Pre-load models for faster first inference."""
|
| 61 |
+
if self.warmup_complete:
|
| 62 |
+
return
|
| 63 |
+
self.model_loader.warmup()
|
| 64 |
+
self.warmup_complete = True
|
| 65 |
+
|
| 66 |
+
# Initialize semantic extractor with CLIP
|
| 67 |
+
clip_model = self.model_loader.get_clip_model()
|
| 68 |
+
if clip_model is not None:
|
| 69 |
+
model, processor = clip_model
|
| 70 |
+
self.extractor = SemanticFeatureExtractor(model, processor, self.model_loader.get_device())
|
| 71 |
+
|
| 72 |
+
def score(self, image_input, concept: str = "", audience: str = "General",
|
| 73 |
+
use_case: str = "social_media") -> ScoreResponse:
|
| 74 |
+
"""
|
| 75 |
+
Score an image across 8 dimensions.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
image_input: PIL Image, numpy array, or file path
|
| 79 |
+
concept: User-declared concept/theme
|
| 80 |
+
audience: Target audience
|
| 81 |
+
use_case: Use case preset key
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
ScoreResponse with all scores, explanations, and suggestions.
|
| 85 |
+
"""
|
| 86 |
+
t0 = time.time()
|
| 87 |
+
response = ScoreResponse()
|
| 88 |
+
|
| 89 |
+
# --- 1. Preprocess ---
|
| 90 |
+
try:
|
| 91 |
+
img = preprocess_image(image_input)
|
| 92 |
+
except Exception as e:
|
| 93 |
+
response.metadata["error"] = f"Preprocessing failed: {e}"
|
| 94 |
+
return response
|
| 95 |
+
|
| 96 |
+
val_error = validate_image(img)
|
| 97 |
+
if val_error:
|
| 98 |
+
response.metadata["error"] = val_error
|
| 99 |
+
return response
|
| 100 |
+
|
| 101 |
+
# --- 2. Initialize extractor if needed ---
|
| 102 |
+
if self.extractor is None:
|
| 103 |
+
clip_model = self.model_loader.get_clip_model()
|
| 104 |
+
if clip_model is not None:
|
| 105 |
+
model, processor = clip_model
|
| 106 |
+
self.extractor = SemanticFeatureExtractor(model, processor, self.model_loader.get_device())
|
| 107 |
+
|
| 108 |
+
# --- 3. Extract features ---
|
| 109 |
+
raw_features = {}
|
| 110 |
+
|
| 111 |
+
# Heuristic features (always available)
|
| 112 |
+
heuristic_feats = compute_heuristic_features(img)
|
| 113 |
+
raw_features.update({f"heuristic_{k}": v for k, v in heuristic_feats.items()})
|
| 114 |
+
|
| 115 |
+
# Saliency features (with heuristic fallback)
|
| 116 |
+
saliency_feats = compute_saliency_features(img)
|
| 117 |
+
raw_features.update({f"saliency_{k}": v for k, v in saliency_feats.items()})
|
| 118 |
+
|
| 119 |
+
# OCR features (with heuristic fallback)
|
| 120 |
+
ocr_feats = compute_ocr_features(img)
|
| 121 |
+
raw_features.update({f"ocr_{k}": v for k, v in ocr_feats.items()})
|
| 122 |
+
|
| 123 |
+
# Quality features
|
| 124 |
+
quality_feats = compute_quality_features(img)
|
| 125 |
+
raw_features.update({f"quality_{k}": v for k, v in quality_feats.items()})
|
| 126 |
+
|
| 127 |
+
# Semantic features (CLIP-based)
|
| 128 |
+
semantic_feats = compute_semantic_features(
|
| 129 |
+
img, concept, audience, use_case, self.extractor
|
| 130 |
+
)
|
| 131 |
+
raw_features.update({f"semantic_{k}": v for k, v in semantic_feats.items()})
|
| 132 |
+
|
| 133 |
+
# --- 4. Compute sub-scores ---
|
| 134 |
+
sub_scores = {}
|
| 135 |
+
|
| 136 |
+
# Concept Match
|
| 137 |
+
sub_scores["concept_match"] = self.normalizer.normalize_concept_match(
|
| 138 |
+
semantic_feats.get("composite_cosine", 0.0)
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# Visual Focus
|
| 142 |
+
sub_scores["visual_focus"] = self.normalizer.normalize_visual_focus(
|
| 143 |
+
saliency_feats.get("peak_saliency", 0.3),
|
| 144 |
+
saliency_feats.get("center_saliency", 0.2),
|
| 145 |
+
saliency_feats.get("top20_fraction", 0.2)
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
# Readability
|
| 149 |
+
sub_scores["readability"] = self.normalizer.normalize_readability(
|
| 150 |
+
ocr_feats.get("avg_ocr_confidence", 0.0),
|
| 151 |
+
ocr_feats.get("text_coverage", 0.0),
|
| 152 |
+
ocr_feats.get("word_count", 0),
|
| 153 |
+
ocr_feats.get("has_text", False)
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
# Complexity Balance
|
| 157 |
+
sub_scores["complexity_balance"] = self.normalizer.normalize_complexity_balance(
|
| 158 |
+
heuristic_feats.get("edge_density", 0.08),
|
| 159 |
+
heuristic_feats.get("color_entropy", 3.0)
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
# Communication Clarity
|
| 163 |
+
sub_scores["communication_clarity"] = self.normalizer.normalize_communication_clarity(
|
| 164 |
+
heuristic_feats.get("whitespace_ratio", 0.05),
|
| 165 |
+
heuristic_feats.get("contrast", 0.4),
|
| 166 |
+
heuristic_feats.get("symmetry_lr", 0.5),
|
| 167 |
+
heuristic_feats.get("sharpness", 0.3)
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
# Neural Richness (proxy)
|
| 171 |
+
neural_proxy = compute_neural_richness_proxy(
|
| 172 |
+
semantic_feats, saliency_feats, quality_feats, heuristic_feats
|
| 173 |
+
)
|
| 174 |
+
sub_scores["neural_richness"] = self.normalizer.normalize_neural_richness(neural_proxy)
|
| 175 |
+
|
| 176 |
+
# Memorability Proxy
|
| 177 |
+
sub_scores["memorability_proxy"] = self.normalizer.normalize_memorability_proxy(
|
| 178 |
+
quality_feats.get("nima_aesthetic_proxy", 0.5),
|
| 179 |
+
quality_feats.get("colorfulness", 0.5),
|
| 180 |
+
quality_feats.get("sharpness", 0.3)
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
# Improvement Potential
|
| 184 |
+
sub_scores["improvement_potential"] = self.normalizer.normalize_improvement_potential(
|
| 185 |
+
sub_scores
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
# --- 5. Compute confidence ---
|
| 189 |
+
confidence = self._compute_confidence(
|
| 190 |
+
concept, img.size[0], img.size[1],
|
| 191 |
+
ocr_feats.get("has_text", False),
|
| 192 |
+
self.extractor is not None
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
# --- 6. Aggregate ---
|
| 196 |
+
overall = self._aggregate(sub_scores, use_case)
|
| 197 |
+
|
| 198 |
+
# --- 7. Strengths and weaknesses ---
|
| 199 |
+
strengths, weaknesses = self._identify_strengths_weaknesses(sub_scores)
|
| 200 |
+
|
| 201 |
+
# --- 8. Build response ---
|
| 202 |
+
response.overall_score = round(overall, 1)
|
| 203 |
+
response.sub_scores = {k: round(v, 1) for k, v in sub_scores.items()}
|
| 204 |
+
response.confidence = round(confidence, 2)
|
| 205 |
+
response.strengths = strengths
|
| 206 |
+
response.weaknesses = weaknesses
|
| 207 |
+
response.raw_features = {k: round(v, 4) if isinstance(v, float) else v
|
| 208 |
+
for k, v in raw_features.items()}
|
| 209 |
+
response.metadata = {
|
| 210 |
+
"processing_time_ms": round((time.time() - t0) * 1000, 1),
|
| 211 |
+
"models_used": ["clip-vit-base-patch32", "heuristic-fallback",
|
| 212 |
+
"saliency-heuristic", "ocr-heuristic"],
|
| 213 |
+
"neural_richness_mode": "proxy",
|
| 214 |
+
"api_version": "1.0.0",
|
| 215 |
+
"image_size": f"{img.size[0]}x{img.size[1]}",
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
return response
|
| 219 |
+
|
| 220 |
+
def _compute_confidence(self, concept: str, width: int, height: int,
|
| 221 |
+
has_text: bool, has_clip: bool) -> float:
|
| 222 |
+
"""Compute confidence in the scoring."""
|
| 223 |
+
base = self.config.get("scoring", {}).get("confidence_base", 0.85)
|
| 224 |
+
|
| 225 |
+
if not concept or len(concept.strip()) < 3:
|
| 226 |
+
base -= self.config.get("scoring", {}).get("confidence_no_concept_penalty", 0.15)
|
| 227 |
+
|
| 228 |
+
if width < 200 or height < 200:
|
| 229 |
+
base -= self.config.get("scoring", {}).get("confidence_low_res_penalty", 0.10)
|
| 230 |
+
|
| 231 |
+
if not has_text and concept and "text" in concept.lower():
|
| 232 |
+
base -= self.config.get("scoring", {}).get("confidence_ocr_uncertain_penalty", 0.10)
|
| 233 |
+
|
| 234 |
+
if not has_clip:
|
| 235 |
+
base -= 0.25 # major signal missing
|
| 236 |
+
|
| 237 |
+
return max(0.4, base)
|
| 238 |
+
|
| 239 |
+
def _aggregate(self, sub_scores: Dict[str, float], use_case: str) -> float:
|
| 240 |
+
"""Weighted aggregation of sub-scores."""
|
| 241 |
+
presets = self.config.get("presets", {})
|
| 242 |
+
weights = presets.get(use_case, presets.get("default", {}))
|
| 243 |
+
|
| 244 |
+
if not weights:
|
| 245 |
+
# Uniform weights fallback
|
| 246 |
+
weights = {k: 1.0 / len(sub_scores) for k in sub_scores}
|
| 247 |
+
|
| 248 |
+
total = sum(weights.get(k, 0.0) * v for k, v in sub_scores.items())
|
| 249 |
+
weight_sum = sum(weights.get(k, 0.0) for k in sub_scores)
|
| 250 |
+
|
| 251 |
+
if weight_sum > 0:
|
| 252 |
+
return total / weight_sum
|
| 253 |
+
return np.mean(list(sub_scores.values())) if sub_scores else 0.0
|
| 254 |
+
|
| 255 |
+
def _identify_strengths_weaknesses(self, sub_scores: Dict[str, float],
|
| 256 |
+
threshold_high: float = 70.0,
|
| 257 |
+
threshold_low: float = 50.0):
|
| 258 |
+
"""Identify top strengths and weaknesses from sub-scores."""
|
| 259 |
+
readable_names = {
|
| 260 |
+
"concept_match": "Concept Match",
|
| 261 |
+
"visual_focus": "Visual Focus",
|
| 262 |
+
"readability": "Readability",
|
| 263 |
+
"complexity_balance": "Complexity Balance",
|
| 264 |
+
"communication_clarity": "Communication Clarity",
|
| 265 |
+
"neural_richness": "Predicted Neural Richness",
|
| 266 |
+
"memorability_proxy": "Memorability",
|
| 267 |
+
"improvement_potential": "Improvement Potential",
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
strengths = []
|
| 271 |
+
weaknesses = []
|
| 272 |
+
|
| 273 |
+
sorted_scores = sorted(sub_scores.items(), key=lambda x: -x[1])
|
| 274 |
+
|
| 275 |
+
for name, score in sorted_scores:
|
| 276 |
+
readable = readable_names.get(name, name)
|
| 277 |
+
if score >= threshold_high:
|
| 278 |
+
strengths.append(f"Strong {readable.lower()}: scored {score:.0f}/100")
|
| 279 |
+
elif score <= threshold_low:
|
| 280 |
+
weaknesses.append(f"Weak {readable.lower()}: scored only {score:.0f}/100")
|
| 281 |
+
|
| 282 |
+
return strengths[:5], weaknesses[:5]
|
scoring/normalizers.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sub-score normalization utilities."""
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
from typing import Dict, Any, Optional
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _clamp(x: float, lo: float = 0, hi: float = 100) -> float:
|
| 8 |
+
return float(np.clip(x, lo, hi))
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _rescale(x: float, min_val: float, max_val: float) -> float:
|
| 12 |
+
"""Rescale x from [min_val, max_val] to [0, 100]."""
|
| 13 |
+
if max_val == min_val:
|
| 14 |
+
return 50.0
|
| 15 |
+
return _clamp(100 * (x - min_val) / (max_val - min_val))
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _gaussian_score(x: float, optimal: float, sigma: float) -> float:
|
| 19 |
+
"""Score peaks at `optimal`, decreases with Gaussian falloff."""
|
| 20 |
+
return 100 * np.exp(-((x - optimal) ** 2) / (2 * sigma ** 2))
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _sigmoid_normalize(x: float, center: float = 0.5, scale: float = 0.1) -> float:
|
| 24 |
+
"""Map x to [0,1] with logistic sigmoid."""
|
| 25 |
+
return 1.0 / (1.0 + np.exp(-(x - center) / scale))
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ScoreNormalizer:
|
| 29 |
+
"""Normalizes raw feature values to 0–100 sub-scores."""
|
| 30 |
+
|
| 31 |
+
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
| 32 |
+
self.config = config or {}
|
| 33 |
+
|
| 34 |
+
def normalize_concept_match(self, raw_cosine: float) -> float:
|
| 35 |
+
"""CLIP cosine similarity → [0, 100]."""
|
| 36 |
+
norm_cfg = self.config.get("normalization", {}).get("clip_cosine", {})
|
| 37 |
+
min_val = norm_cfg.get("min_val", 0.10)
|
| 38 |
+
max_val = norm_cfg.get("max_val", 0.45)
|
| 39 |
+
return _rescale(raw_cosine, min_val, max_val)
|
| 40 |
+
|
| 41 |
+
def normalize_visual_focus(self, peak_saliency: float, center_saliency: float,
|
| 42 |
+
top20_fraction: float) -> float:
|
| 43 |
+
"""Saliency metrics → visual focus [0, 100]."""
|
| 44 |
+
# Peak should be strong
|
| 45 |
+
peak_score = peak_saliency * 100
|
| 46 |
+
# Center saliency should be above average
|
| 47 |
+
center_score = min(center_saliency / (peak_saliency + 0.01) * 100, 100)
|
| 48 |
+
# Top20 fraction: ideal is 0.15-0.25 (not too diffuse, not too concentrated)
|
| 49 |
+
spread_score = 100 * np.exp(-((top20_fraction - 0.20) ** 2) / (2 * 0.08 ** 2))
|
| 50 |
+
return _clamp(0.4 * peak_score + 0.3 * center_score + 0.3 * spread_score)
|
| 51 |
+
|
| 52 |
+
def normalize_readability(self, ocr_confidence: float, text_coverage: float,
|
| 53 |
+
word_count: int, has_text: bool) -> float:
|
| 54 |
+
"""OCR metrics → readability [0, 100]."""
|
| 55 |
+
if not has_text:
|
| 56 |
+
return 70.0 # neutral: no text is not a flaw
|
| 57 |
+
|
| 58 |
+
conf_score = ocr_confidence * 100
|
| 59 |
+
# Coverage penalty: prefer 5-20%
|
| 60 |
+
coverage_score = 100 * np.exp(-((text_coverage - 0.12) ** 2) / (2 * 0.08 ** 2))
|
| 61 |
+
# Word count: moderate is good
|
| 62 |
+
wc_score = 100 * np.exp(-((word_count - 8) ** 2) / (2 * 15 ** 2))
|
| 63 |
+
|
| 64 |
+
return _clamp(0.5 * conf_score + 0.3 * coverage_score + 0.2 * wc_score)
|
| 65 |
+
|
| 66 |
+
def normalize_complexity_balance(self, edge_density: float,
|
| 67 |
+
color_entropy: float) -> float:
|
| 68 |
+
"""Edge density + color entropy → complexity balance [0, 100]."""
|
| 69 |
+
norm_cfg = self.config.get("normalization", {})
|
| 70 |
+
|
| 71 |
+
edge_cfg = norm_cfg.get("edge_density", {})
|
| 72 |
+
edge_score = _gaussian_score(
|
| 73 |
+
edge_density,
|
| 74 |
+
edge_cfg.get("optimal", 0.10),
|
| 75 |
+
edge_cfg.get("sigma", 0.06)
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
color_cfg = norm_cfg.get("color_entropy", {})
|
| 79 |
+
color_score = _gaussian_score(
|
| 80 |
+
color_entropy,
|
| 81 |
+
color_cfg.get("optimal", 4.5),
|
| 82 |
+
color_cfg.get("sigma", 1.0)
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
return _clamp(0.6 * edge_score + 0.4 * color_score)
|
| 86 |
+
|
| 87 |
+
def normalize_communication_clarity(self, whitespace: float, contrast: float,
|
| 88 |
+
symmetry_lr: float, sharpness: float) -> float:
|
| 89 |
+
"""Whitespace, contrast, symmetry, sharpness → clarity [0, 100]."""
|
| 90 |
+
norm_cfg = self.config.get("normalization", {})
|
| 91 |
+
ws_cfg = norm_cfg.get("whitespace", {})
|
| 92 |
+
ws_score = _gaussian_score(
|
| 93 |
+
whitespace,
|
| 94 |
+
ws_cfg.get("optimal", 0.12),
|
| 95 |
+
ws_cfg.get("sigma", 0.08)
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
contrast_score = contrast * 100
|
| 99 |
+
sym_score = symmetry_lr * 100 # already in [0,1]
|
| 100 |
+
sharp_score = min(sharpness / 0.5 * 100, 100) # sharpness normalized
|
| 101 |
+
|
| 102 |
+
return _clamp(0.3 * ws_score + 0.3 * contrast_score + 0.2 * sym_score + 0.2 * sharp_score)
|
| 103 |
+
|
| 104 |
+
def normalize_neural_richness(self, proxy_score: float) -> float:
|
| 105 |
+
"""Neural richness proxy [0,100] is already normalized."""
|
| 106 |
+
return _clamp(proxy_score)
|
| 107 |
+
|
| 108 |
+
def normalize_memorability_proxy(self, aesthetic_proxy: float,
|
| 109 |
+
colorfulness: float,
|
| 110 |
+
sharpness: float) -> float:
|
| 111 |
+
"""Aesthetic + colorfulness + sharpness → memorability [0, 100]."""
|
| 112 |
+
aesthetic_score = aesthetic_proxy * 100
|
| 113 |
+
color_score = colorfulness * 100
|
| 114 |
+
sharp_score = min(sharpness / 0.5 * 100, 100)
|
| 115 |
+
return _clamp(0.5 * aesthetic_score + 0.3 * color_score + 0.2 * sharp_score)
|
| 116 |
+
|
| 117 |
+
def normalize_improvement_potential(self, sub_scores: Dict[str, float],
|
| 118 |
+
target: float = 75.0) -> float:
|
| 119 |
+
"""Inverse of how far weakest scores are from target."""
|
| 120 |
+
gaps = [max(0, target - s) for s in sub_scores.values()]
|
| 121 |
+
# Top 3 gaps weighted
|
| 122 |
+
gaps_sorted = sorted(gaps, reverse=True)
|
| 123 |
+
top3 = gaps_sorted[:3]
|
| 124 |
+
avg_gap = np.mean(top3) if top3 else 0
|
| 125 |
+
return _clamp(avg_gap) # already 0-100
|
utils/__init__.py
ADDED
|
File without changes
|
utils/formatting.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Output formatting utilities for Gradio."""
|
| 2 |
+
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from typing import Dict, List, Any
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def format_overall_score(score: float) -> str:
|
| 8 |
+
"""Format overall score as HTML progress bar."""
|
| 9 |
+
score_int = int(round(score))
|
| 10 |
+
color = "#2ecc71" if score_int >= 70 else "#f39c12" if score_int >= 50 else "#e74c3c"
|
| 11 |
+
return f"""
|
| 12 |
+
<div style="padding:16px; background:#f8f9fa; border-radius:8px; text-align:center;">
|
| 13 |
+
<div style="font-size:14px; color:#666; margin-bottom:8px;">Overall Score</div>
|
| 14 |
+
<div style="font-size:48px; font-weight:bold; color:{color};">{score_int}<span style="font-size:20px; color:#999;">/100</span></div>
|
| 15 |
+
<div style="margin-top:8px; background:#ddd; border-radius:4px; height:20px; width:100%;">
|
| 16 |
+
<div style="background:{color}; width:{score_int}%; height:20px; border-radius:4px; transition:width 0.5s;"></div>
|
| 17 |
+
</div>
|
| 18 |
+
</div>
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def format_sub_scores(sub_scores: Dict[str, float]) -> pd.DataFrame:
|
| 23 |
+
"""Format sub-scores as a DataFrame for gr.BarPlot."""
|
| 24 |
+
df = pd.DataFrame({
|
| 25 |
+
"Sub-Score": list(sub_scores.keys()),
|
| 26 |
+
"Score": list(sub_scores.values())
|
| 27 |
+
})
|
| 28 |
+
return df
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def format_strengths(strengths: List[str]) -> str:
|
| 32 |
+
"""Format strengths as HTML list."""
|
| 33 |
+
if not strengths:
|
| 34 |
+
return "No notable strengths detected."
|
| 35 |
+
items = "\n".join(f"<li>✅ {s}</li>" for s in strengths)
|
| 36 |
+
return f"<ul style='margin:0; padding-left:20px;'>{items}</ul>"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def format_weaknesses(weaknesses: List[str]) -> str:
|
| 40 |
+
"""Format weaknesses as HTML list."""
|
| 41 |
+
if not weaknesses:
|
| 42 |
+
return "No notable weaknesses detected."
|
| 43 |
+
items = "\n".join(f"<li>⚠️ {w}</li>" for w in weaknesses)
|
| 44 |
+
return f"<ul style='margin:0; padding-left:20px;'>{items}</ul>"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def format_suggestions(suggestions: List[Dict[str, Any]]) -> str:
|
| 48 |
+
"""Format suggestions as numbered HTML list."""
|
| 49 |
+
if not suggestions:
|
| 50 |
+
return "No specific suggestions — your image scores well across all dimensions."
|
| 51 |
+
items = []
|
| 52 |
+
for i, s in enumerate(suggestions[:5], 1):
|
| 53 |
+
priority_emoji = {1: "🔴", 2: "🟡", 3: "🟡"}.get(i, "🟢")
|
| 54 |
+
items.append(
|
| 55 |
+
f"<div style='margin-bottom:12px; padding:10px; background:#f0f4ff; border-radius:6px; border-left:4px solid #4a86e8;'>">
|
| 56 |
+
f"<b>{priority_emoji} Priority {i}</b> — {s['sub_score_target']}<br/>"
|
| 57 |
+
f"<div style='margin-top:6px; color:#333;'>{s['message']}</div>"
|
| 58 |
+
f"<div style='margin-top:4px; font-size:12px; color:#888;'>📈 Projected gain: +{s['projected_gain']:.0f} points</div>"
|
| 59 |
+
f"</div>"
|
| 60 |
+
)
|
| 61 |
+
return "\n".join(items)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def format_projected_improvement(current: float, projected: float) -> str:
|
| 65 |
+
"""Format projected improvement as HTML."""
|
| 66 |
+
delta = projected - current
|
| 67 |
+
if delta <= 0:
|
| 68 |
+
return "<div style='padding:12px; background:#e8f5e9; border-radius:6px;'>🎉 This image is already well-optimized!</div>"
|
| 69 |
+
return f"""
|
| 70 |
+
<div style="padding:12px; background:#fff3e0; border-radius:6px;">
|
| 71 |
+
<b>💡 Projected Improvement</b><br/>
|
| 72 |
+
Current: <b>{current:.0f}</b>/100 → After suggestions: <b>{projected:.0f}</b>/100
|
| 73 |
+
<span style="color:#e65100;"> (+{delta:.0f} points)</span>
|
| 74 |
+
</div>
|
| 75 |
+
"""
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def format_comparison(original: Dict[str, Any], revised: Dict[str, Any],
|
| 79 |
+
improvements: List, regressions: List,
|
| 80 |
+
next_steps: List[Dict[str, Any]]) -> str:
|
| 81 |
+
"""Format comparison results as HTML."""
|
| 82 |
+
orig_score = original.get("overall_score", 0)
|
| 83 |
+
rev_score = revised.get("overall_score", 0)
|
| 84 |
+
delta = rev_score - orig_score
|
| 85 |
+
|
| 86 |
+
result = f"""
|
| 87 |
+
<div style="padding:16px; background:#f8f9fa; border-radius:8px;">
|
| 88 |
+
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
| 89 |
+
<div style="text-align:center;">
|
| 90 |
+
<div style="font-size:12px; color:#666;">Original</div>
|
| 91 |
+
<div style="font-size:32px; font-weight:bold; color:#e74c3c;">{orig_score:.0f}</div>
|
| 92 |
+
</div>
|
| 93 |
+
<div style="font-size:24px;">→</div>
|
| 94 |
+
<div style="text-align:center;">
|
| 95 |
+
<div style="font-size:12px; color:#666;">Revised</div>
|
| 96 |
+
<div style="font-size:32px; font-weight:bold; color:#2ecc71;">{rev_score:.0f}</div>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
if delta > 0:
|
| 102 |
+
result += f"<div style='text-align:center; color:#27ae60; font-weight:bold; margin-bottom:12px;'>📈 Overall improved by +{delta:.0f} points</div>"
|
| 103 |
+
elif delta < 0:
|
| 104 |
+
result += f"<div style='text-align:center; color:#e74c3c; font-weight:bold; margin-bottom:12px;'>📉 Overall decreased by {delta:.0f} points</div>"
|
| 105 |
+
else:
|
| 106 |
+
result += "<div style='text-align:center; color:#666; margin-bottom:12px;'>Overall score unchanged</div>"
|
| 107 |
+
|
| 108 |
+
if improvements:
|
| 109 |
+
result += "<div style='font-weight:bold; color:#27ae60; margin-top:12px;'>✅ Improvements:</div><ul>"
|
| 110 |
+
for name, val in improvements[:5]:
|
| 111 |
+
result += f"<li>{name}: +{val:.0f} points</li>"
|
| 112 |
+
result += "</ul>"
|
| 113 |
+
|
| 114 |
+
if regressions:
|
| 115 |
+
result += "<div style='font-weight:bold; color:#e74c3c; margin-top:12px;'>⚠️ Regressions:</div><ul>"
|
| 116 |
+
for name, val in regressions[:5]:
|
| 117 |
+
result += f"<li>{name}: {val:.0f} points</li>"
|
| 118 |
+
result += "</ul>"
|
| 119 |
+
|
| 120 |
+
if next_steps:
|
| 121 |
+
result += "<div style='font-weight:bold; margin-top:12px;'>📋 Next Recommended Edits:</div>"
|
| 122 |
+
for step in next_steps[:3]:
|
| 123 |
+
result += f"<div style='margin:4px 0; padding:8px; background:#e3f2fd; border-radius:4px;'>{step['message']}</div>"
|
| 124 |
+
|
| 125 |
+
result += "</div>"
|
| 126 |
+
return result
|
utils/preprocessing.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image preprocessing utilities."""
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
from PIL import Image
|
| 5 |
+
import hashlib
|
| 6 |
+
from typing import Tuple, Optional
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def preprocess_image(image_input, max_size: int = 1024) -> Image.Image:
|
| 10 |
+
"""
|
| 11 |
+
Preprocess any image input into a standardized PIL RGB image.
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
image_input: PIL Image, numpy array, or file path
|
| 15 |
+
max_size: maximum dimension for resizing
|
| 16 |
+
|
| 17 |
+
Returns:
|
| 18 |
+
PIL Image in RGB mode, resized to max_size if needed
|
| 19 |
+
"""
|
| 20 |
+
if isinstance(image_input, str):
|
| 21 |
+
img = Image.open(image_input).convert("RGB")
|
| 22 |
+
elif isinstance(image_input, np.ndarray):
|
| 23 |
+
if image_input.ndim == 2:
|
| 24 |
+
img = Image.fromarray(image_input).convert("RGB")
|
| 25 |
+
elif image_input.ndim == 3:
|
| 26 |
+
img = Image.fromarray(image_input).convert("RGB")
|
| 27 |
+
else:
|
| 28 |
+
raise ValueError(f"Unexpected array shape: {image_input.shape}")
|
| 29 |
+
elif isinstance(image_input, Image.Image):
|
| 30 |
+
img = image_input.convert("RGB")
|
| 31 |
+
else:
|
| 32 |
+
raise TypeError(f"Unsupported image type: {type(image_input)}")
|
| 33 |
+
|
| 34 |
+
# Resize if too large
|
| 35 |
+
w, h = img.size
|
| 36 |
+
if max(w, h) > max_size:
|
| 37 |
+
scale = max_size / max(w, h)
|
| 38 |
+
new_w, new_h = int(w * scale), int(h * scale)
|
| 39 |
+
img = img.resize((new_w, new_h), Image.LANCZOS)
|
| 40 |
+
|
| 41 |
+
return img
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def compute_image_hash(img: Image.Image) -> str:
|
| 45 |
+
"""Compute SHA256 hash of image pixels for caching."""
|
| 46 |
+
return hashlib.sha256(np.array(img).tobytes()).hexdigest()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def get_image_size(img: Image.Image) -> Tuple[int, int]:
|
| 50 |
+
"""Return (width, height) of image."""
|
| 51 |
+
return img.size
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def validate_image(img: Image.Image, min_size: int = 50) -> Optional[str]:
|
| 55 |
+
"""
|
| 56 |
+
Validate image meets minimum requirements.
|
| 57 |
+
|
| 58 |
+
Returns error message if invalid, None if valid.
|
| 59 |
+
"""
|
| 60 |
+
w, h = img.size
|
| 61 |
+
if w < min_size or h < min_size:
|
| 62 |
+
return f"Image too small: {w}x{h}. Minimum {min_size}x{min_size} required."
|
| 63 |
+
return None
|