Babajaan commited on
Commit
6ceaa94
·
verified ·
1 Parent(s): fe9b0e3

Full Viral Images v1.0 implementation - all modules and configs

Browse files
app.py CHANGED
@@ -1 +1,552 @@
1
- <PLACEHOLDER>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &rarr; 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;">&rarr;</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