Spaces:
Running
Running
Delete app.py
Browse files
app.py
DELETED
|
@@ -1,815 +0,0 @@
|
|
| 1 |
-
import cv2
|
| 2 |
-
import numpy as np
|
| 3 |
-
import gradio as gr
|
| 4 |
-
from PIL import Image
|
| 5 |
-
import tensorflow as tf
|
| 6 |
-
import keras
|
| 7 |
-
from huggingface_hub import snapshot_download
|
| 8 |
-
import pytesseract
|
| 9 |
-
import io
|
| 10 |
-
import math
|
| 11 |
-
import os
|
| 12 |
-
import re
|
| 13 |
-
import tempfile
|
| 14 |
-
from collections import defaultdict
|
| 15 |
-
import matplotlib
|
| 16 |
-
matplotlib.use("Agg")
|
| 17 |
-
import matplotlib.pyplot as plt
|
| 18 |
-
from reportlab.lib.pagesizes import letter
|
| 19 |
-
from reportlab.lib.units import inch
|
| 20 |
-
from reportlab.lib.styles import getSampleStyleSheet
|
| 21 |
-
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image as RLImage
|
| 22 |
-
|
| 23 |
-
import requests
|
| 24 |
-
|
| 25 |
-
_msi_model = None
|
| 26 |
-
LOGO_PATH = "logo.png"
|
| 27 |
-
COUNTAPI_NAMESPACE = "haanilango-design-analyzer"
|
| 28 |
-
COUNTAPI_KEY = "images-analyzed"
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def format_count_badge(count):
|
| 32 |
-
return (
|
| 33 |
-
f'<div style="background-color:#1e2761; color:white; text-align:center; '
|
| 34 |
-
f'padding:20px; border-radius:8px; margin-bottom:10px;">'
|
| 35 |
-
f'<div style="font-size:28px; letter-spacing:4px;">★★★★★</div>'
|
| 36 |
-
f'<div style="font-size:20px; font-weight:bold; margin-top:8px;">{count:,} images were tested</div>'
|
| 37 |
-
f'</div>'
|
| 38 |
-
)
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def get_current_count():
|
| 42 |
-
try:
|
| 43 |
-
resp = requests.get(f"https://api.countapi.xyz/get/{COUNTAPI_NAMESPACE}/{COUNTAPI_KEY}", timeout=5)
|
| 44 |
-
if resp.status_code == 200:
|
| 45 |
-
return resp.json().get("value", 0)
|
| 46 |
-
except Exception:
|
| 47 |
-
pass
|
| 48 |
-
return 0
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def increment_count():
|
| 52 |
-
try:
|
| 53 |
-
resp = requests.get(f"https://api.countapi.xyz/hit/{COUNTAPI_NAMESPACE}/{COUNTAPI_KEY}", timeout=5)
|
| 54 |
-
if resp.status_code == 200:
|
| 55 |
-
return resp.json().get("value", None)
|
| 56 |
-
except Exception:
|
| 57 |
-
pass
|
| 58 |
-
return None
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
def add_watermark(pil_img, logo_path=LOGO_PATH, scale=0.20, opacity=0.95):
|
| 62 |
-
if not os.path.exists(logo_path):
|
| 63 |
-
return pil_img
|
| 64 |
-
try:
|
| 65 |
-
base = pil_img.convert("RGBA")
|
| 66 |
-
logo = Image.open(logo_path).convert("RGBA")
|
| 67 |
-
bbox = logo.getbbox()
|
| 68 |
-
if bbox:
|
| 69 |
-
logo = logo.crop(bbox)
|
| 70 |
-
img_w, img_h = base.size
|
| 71 |
-
logo_w = max(50, int(img_w * scale))
|
| 72 |
-
logo_ratio = logo.height / logo.width
|
| 73 |
-
logo_h = int(logo_w * logo_ratio)
|
| 74 |
-
logo_resized = logo.resize((logo_w, logo_h), Image.LANCZOS)
|
| 75 |
-
|
| 76 |
-
r, g, b, a = logo_resized.split()
|
| 77 |
-
a = a.point(lambda p: int(p * opacity))
|
| 78 |
-
logo_resized = Image.merge("RGBA", (r, g, b, a))
|
| 79 |
-
|
| 80 |
-
margin = max(10, int(img_w * 0.015))
|
| 81 |
-
pos = (img_w - logo_w - margin, img_h - logo_h - margin)
|
| 82 |
-
|
| 83 |
-
base.paste(logo_resized, pos, logo_resized)
|
| 84 |
-
return base.convert("RGB")
|
| 85 |
-
except Exception:
|
| 86 |
-
return pil_img
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def load_msi_net():
|
| 90 |
-
global _msi_model
|
| 91 |
-
if _msi_model is None:
|
| 92 |
-
hf_dir = snapshot_download(repo_id="alexanderkroner/MSI-Net")
|
| 93 |
-
_msi_model = keras.layers.TFSMLayer(hf_dir, call_endpoint="serving_default")
|
| 94 |
-
return _msi_model
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
def get_target_shape(original_shape):
|
| 98 |
-
ar = original_shape[0] / original_shape[1]
|
| 99 |
-
square_mode = abs(ar - 1.0)
|
| 100 |
-
landscape_mode = abs(ar - 240 / 320)
|
| 101 |
-
portrait_mode = abs(ar - 320 / 240)
|
| 102 |
-
best = min(square_mode, landscape_mode, portrait_mode)
|
| 103 |
-
if best == square_mode:
|
| 104 |
-
return (320, 320)
|
| 105 |
-
elif best == landscape_mode:
|
| 106 |
-
return (240, 320)
|
| 107 |
-
else:
|
| 108 |
-
return (320, 240)
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
def preprocess_input(input_image, target_shape):
|
| 112 |
-
t = tf.expand_dims(input_image, axis=0)
|
| 113 |
-
t = tf.image.resize(t, target_shape, preserve_aspect_ratio=True)
|
| 114 |
-
vp = target_shape[0] - t.shape[1]
|
| 115 |
-
hp = target_shape[1] - t.shape[2]
|
| 116 |
-
v1, v2 = vp // 2, vp - vp // 2
|
| 117 |
-
h1, h2 = hp // 2, hp - hp // 2
|
| 118 |
-
t = tf.pad(t, [[0, 0], [v1, v2], [h1, h2], [0, 0]])
|
| 119 |
-
return t, [v1, v2], [h1, h2]
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
def postprocess_output(output_tensor, vp, hp, original_shape):
|
| 123 |
-
output_tensor = output_tensor[:, vp[0]:output_tensor.shape[1] - vp[1], hp[0]:output_tensor.shape[2] - hp[1], :]
|
| 124 |
-
output_tensor = tf.image.resize(output_tensor, original_shape)
|
| 125 |
-
return output_tensor.numpy().squeeze()
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
def compute_text_boost_map(pil_img):
|
| 129 |
-
img_arr = np.array(pil_img.convert("RGB"))
|
| 130 |
-
h, w = img_arr.shape[:2]
|
| 131 |
-
data = pytesseract.image_to_data(img_arr, config="--psm 11", output_type=pytesseract.Output.DICT)
|
| 132 |
-
text_map = np.zeros((h, w), dtype=np.float32)
|
| 133 |
-
for i in range(len(data['text'])):
|
| 134 |
-
word = data['text'][i].strip()
|
| 135 |
-
tw_, th_ = data['width'][i], data['height'][i]
|
| 136 |
-
if not word or th_ < 8 or tw_ < 5:
|
| 137 |
-
continue
|
| 138 |
-
x, y = data['left'][i], data['top'][i]
|
| 139 |
-
weight = min(1.0, (th_ / h) * 6)
|
| 140 |
-
pad = int(th_ * 0.15)
|
| 141 |
-
y0, y1 = max(0, y - pad), min(h, y + th_ + pad)
|
| 142 |
-
x0, x1 = max(0, x - pad), min(w, x + tw_ + pad)
|
| 143 |
-
text_map[y0:y1, x0:x1] = np.maximum(text_map[y0:y1, x0:x1], weight)
|
| 144 |
-
return text_map
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
def compute_saliency_map(pil_image):
|
| 148 |
-
model = load_msi_net()
|
| 149 |
-
input_image = np.array(pil_image.convert("RGB"), dtype=np.float32)
|
| 150 |
-
original_shape = input_image.shape[:2]
|
| 151 |
-
target_shape = get_target_shape(original_shape)
|
| 152 |
-
input_tensor, v_pad, h_pad = preprocess_input(input_image, target_shape)
|
| 153 |
-
raw_output = model(input_tensor)
|
| 154 |
-
output_tensor = list(raw_output.values())[0] if isinstance(raw_output, dict) else raw_output
|
| 155 |
-
msi_saliency = postprocess_output(output_tensor, v_pad, h_pad, original_shape)
|
| 156 |
-
msi_saliency = msi_saliency.astype(np.float32)
|
| 157 |
-
msi_saliency -= msi_saliency.min()
|
| 158 |
-
if msi_saliency.max() > 0:
|
| 159 |
-
msi_saliency /= msi_saliency.max()
|
| 160 |
-
|
| 161 |
-
text_boost = compute_text_boost_map(pil_image)
|
| 162 |
-
combined = 0.7 * msi_saliency + 0.3 * text_boost
|
| 163 |
-
return combined
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
def render_heatmap_overlay(img_bgr, saliency_map, alpha=0.45):
|
| 167 |
-
heat_u8 = (saliency_map * 255).astype(np.uint8)
|
| 168 |
-
heat_color = cv2.applyColorMap(heat_u8, cv2.COLORMAP_JET)
|
| 169 |
-
return cv2.addWeighted(heat_color, alpha, img_bgr, 1 - alpha, 0)
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
def compute_attention_scores(saliency_map):
|
| 173 |
-
flat = np.sort(saliency_map.flatten())
|
| 174 |
-
n = len(flat)
|
| 175 |
-
cum = np.cumsum(flat)
|
| 176 |
-
gini = (n + 1 - 2 * np.sum(cum) / (cum[-1] + 1e-8)) / n
|
| 177 |
-
focus_score = float(gini) * 100
|
| 178 |
-
spread_score = float((saliency_map >= 0.5).mean()) * 100
|
| 179 |
-
return {"focus_score": round(focus_score, 1), "spread_score": round(spread_score, 1)}
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
def compute_readability_score(read):
|
| 183 |
-
if read['words_checked'] == 0:
|
| 184 |
-
return None
|
| 185 |
-
penalty = 12 * math.sqrt(read['contrast_issues']) + 8 * math.sqrt(read['small_text_issues'])
|
| 186 |
-
return round(max(0, 100 - penalty), 1)
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
def coverage_grade_score(spread_score):
|
| 190 |
-
if spread_score < 40:
|
| 191 |
-
return 100.0
|
| 192 |
-
return max(0.0, 100.0 - (spread_score - 40) * 2)
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
def compute_overall_grade(focus_score, spread_score, readability_score):
|
| 196 |
-
coverage_score = coverage_grade_score(spread_score)
|
| 197 |
-
if readability_score is None:
|
| 198 |
-
total = round((0.55 / 0.75) * focus_score + (0.20 / 0.75) * coverage_score)
|
| 199 |
-
else:
|
| 200 |
-
total = round(0.55 * focus_score + 0.20 * coverage_score + 0.25 * readability_score)
|
| 201 |
-
total = max(0, min(100, total))
|
| 202 |
-
if total >= 75:
|
| 203 |
-
grade = "Strong"
|
| 204 |
-
elif total >= 50:
|
| 205 |
-
grade = "Good Start"
|
| 206 |
-
else:
|
| 207 |
-
grade = "Room to Grow"
|
| 208 |
-
return total, grade
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
def explain_grade(focus_score, spread_score, readability_score, read):
|
| 212 |
-
coverage_score = coverage_grade_score(spread_score)
|
| 213 |
-
weighted_coverage_deficit = 0.20 * max(0, 100 - coverage_score)
|
| 214 |
-
if readability_score is None:
|
| 215 |
-
if weighted_coverage_deficit > 0.55 * max(0, 100 - focus_score):
|
| 216 |
-
return "based on attention alone (this design's text couldn't be reliably read, so readability wasn't scored) - mainly because attention is spread too widely without settling anywhere"
|
| 217 |
-
elif focus_score < 50:
|
| 218 |
-
return "based on attention alone (this design's text couldn't be reliably read, so readability wasn't scored) - mainly because attention is spread out without one clear focal point"
|
| 219 |
-
elif focus_score > 90:
|
| 220 |
-
return "based on attention alone (this design's text couldn't be reliably read, so readability wasn't scored) - attention is very tightly focused on one spot"
|
| 221 |
-
else:
|
| 222 |
-
return "based on attention alone - this design's text couldn't be reliably read, so readability wasn't included"
|
| 223 |
-
total_issues = read['contrast_issues'] + read['small_text_issues']
|
| 224 |
-
weighted_focus_deficit = 0.55 * max(0, 100 - focus_score)
|
| 225 |
-
weighted_read_deficit = 0.25 * max(0, 100 - readability_score)
|
| 226 |
-
|
| 227 |
-
if weighted_coverage_deficit > weighted_focus_deficit and weighted_coverage_deficit > weighted_read_deficit:
|
| 228 |
-
return "mainly because attention is spread too widely across the design without settling anywhere"
|
| 229 |
-
elif weighted_read_deficit >= weighted_focus_deficit and total_issues > 0:
|
| 230 |
-
return "mainly due to text readability - some text is harder to read than ideal"
|
| 231 |
-
elif focus_score < 50:
|
| 232 |
-
return "mainly because attention is spread out, without one clear focal point"
|
| 233 |
-
elif focus_score > 90:
|
| 234 |
-
return "attention is very tightly focused on one spot - worth checking other key elements (logo, CTA) aren't being missed"
|
| 235 |
-
else:
|
| 236 |
-
return "a mix of small readability and focus factors - see details below"
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
def compute_hierarchy(text_sizes):
|
| 240 |
-
if len(text_sizes) < 2:
|
| 241 |
-
return None
|
| 242 |
-
largest = max(text_sizes)
|
| 243 |
-
smallest = min(text_sizes)
|
| 244 |
-
ratio = largest / max(smallest, 0.1)
|
| 245 |
-
score = round(min(100, ratio * 20), 1)
|
| 246 |
-
return score, ratio
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
def hierarchy_description(ratio):
|
| 250 |
-
if ratio < 1.5:
|
| 251 |
-
return "text sizes are very similar - there's no single element that clearly leads the eye"
|
| 252 |
-
elif ratio < 2.5:
|
| 253 |
-
return "there's some size variation, but the hierarchy could be stronger"
|
| 254 |
-
else:
|
| 255 |
-
return "clear size hierarchy - one element leads, the rest support it"
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
def compute_balance(saliency_map):
|
| 259 |
-
h, w = saliency_map.shape
|
| 260 |
-
left = saliency_map[:, :w // 2].mean()
|
| 261 |
-
right = saliency_map[:, w // 2:].mean()
|
| 262 |
-
top = saliency_map[:h // 2, :].mean()
|
| 263 |
-
bottom = saliency_map[h // 2:, :].mean()
|
| 264 |
-
horiz_diff = abs(left - right) / max(left + right, 1e-6)
|
| 265 |
-
vert_diff = abs(top - bottom) / max(top + bottom, 1e-6)
|
| 266 |
-
imbalance = (horiz_diff + vert_diff) / 2
|
| 267 |
-
score = round(max(0, 100 - imbalance * 200), 1)
|
| 268 |
-
return score
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
def balance_description(score):
|
| 272 |
-
if score >= 75:
|
| 273 |
-
return "well balanced - visual weight is evenly distributed"
|
| 274 |
-
elif score >= 50:
|
| 275 |
-
return "somewhat uneven - one side carries noticeably more visual weight"
|
| 276 |
-
else:
|
| 277 |
-
return "heavily lopsided - attention is pulled hard toward one side or corner"
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
def detect_price_info(all_words):
|
| 281 |
-
joined = " ".join(all_words)
|
| 282 |
-
price_pattern = r'[\$£€¥]\s?\d[\d,]*(\.\d{1,2})?|\b\d[\d,]*(\.\d{1,2})?\s?(SGD|USD|GBP|EUR|dollars?|cents?)\b'
|
| 283 |
-
return bool(re.search(price_pattern, joined, re.IGNORECASE))
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
def detect_location_info(all_words):
|
| 287 |
-
joined = " ".join(all_words)
|
| 288 |
-
location_pattern = (
|
| 289 |
-
r'\b(venue|address|location|street|st\.|road|rd\.|avenue|ave\.|'
|
| 290 |
-
r'blvd|drive|dr\.|www\.|\.com|\.sg|\.net)\b'
|
| 291 |
-
)
|
| 292 |
-
postal_pattern = r'\bS?\d{6}\b'
|
| 293 |
-
return bool(re.search(location_pattern, joined, re.IGNORECASE)) or bool(re.search(postal_pattern, joined))
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
def coverage_description(spread_score):
|
| 297 |
-
if spread_score < 15:
|
| 298 |
-
return "concentrated on one clear focal point - that's usually a good sign, not a problem"
|
| 299 |
-
elif spread_score < 40:
|
| 300 |
-
return "landing mostly in a couple of areas, which is fairly typical"
|
| 301 |
-
else:
|
| 302 |
-
return "spread fairly widely across the design"
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
def create_score_chart(focus_score, spread_score, readability_score, hierarchy_score=None, balance_score=None):
|
| 306 |
-
labels = ["Clear Focal\nPoint", "Attention\nCoverage"]
|
| 307 |
-
values = [focus_score, spread_score]
|
| 308 |
-
if readability_score is not None:
|
| 309 |
-
labels.append("Readability")
|
| 310 |
-
values.append(readability_score)
|
| 311 |
-
if hierarchy_score is not None:
|
| 312 |
-
labels.append("Visual\nHierarchy")
|
| 313 |
-
values.append(hierarchy_score)
|
| 314 |
-
if balance_score is not None:
|
| 315 |
-
labels.append("Balance")
|
| 316 |
-
values.append(balance_score)
|
| 317 |
-
colors = []
|
| 318 |
-
for v in values:
|
| 319 |
-
if v >= 75:
|
| 320 |
-
colors.append("#4CAF50")
|
| 321 |
-
elif v >= 50:
|
| 322 |
-
colors.append("#FFC107")
|
| 323 |
-
else:
|
| 324 |
-
colors.append("#F44336")
|
| 325 |
-
fig, ax = plt.subplots(figsize=(5, 3), dpi=100)
|
| 326 |
-
bars = ax.barh(labels, values, color=colors)
|
| 327 |
-
ax.set_xlim(0, 100)
|
| 328 |
-
ax.set_xlabel("Score out of 100")
|
| 329 |
-
ax.invert_yaxis()
|
| 330 |
-
for bar, v in zip(bars, values):
|
| 331 |
-
ax.text(min(v + 2, 92), bar.get_y() + bar.get_height() / 2, f"{v:.0f}", va="center", fontsize=10)
|
| 332 |
-
ax.set_title("Scores at a Glance")
|
| 333 |
-
fig.tight_layout()
|
| 334 |
-
buf = io.BytesIO()
|
| 335 |
-
fig.savefig(buf, format="png")
|
| 336 |
-
plt.close(fig)
|
| 337 |
-
buf.seek(0)
|
| 338 |
-
return Image.open(buf).convert("RGB")
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
def generate_recommendations(saliency_map, scores, read, img_h, img_w):
|
| 342 |
-
tips = []
|
| 343 |
-
bottom_band = saliency_map[int(img_h * 0.85):, :]
|
| 344 |
-
bottom_avg = bottom_band.mean()
|
| 345 |
-
if bottom_avg < 0.25:
|
| 346 |
-
tips.append("Your bottom section (often where CTAs or contact info sit) is getting low attention. Consider bolder color contrast or larger text there.")
|
| 347 |
-
|
| 348 |
-
if scores['focus_score'] < 40:
|
| 349 |
-
tips.append("Attention is scattered with no clear focal point. Consider making one element (headline, product, or offer) visually dominant.")
|
| 350 |
-
elif scores['focus_score'] > 90:
|
| 351 |
-
tips.append("Attention is very narrowly focused on one spot - double check other important elements (logo, CTA) are not being ignored.")
|
| 352 |
-
|
| 353 |
-
if read['contrast_issues'] > 0:
|
| 354 |
-
if read['contrast_issues'] > 5:
|
| 355 |
-
tips.append(f"A quick win: {read['contrast_issues']} text elements could read more clearly. The good news - the pattern (see white boxes marked \"Low contrast\" on the image) suggests one color choice affects most of them, so a single tweak will likely fix most at once.")
|
| 356 |
-
else:
|
| 357 |
-
tips.append(f"{read['contrast_issues']} text element(s) could be easier to read - see the white boxes marked \"Low contrast\" on the image above, then darken the text or lighten its background.")
|
| 358 |
-
if read['small_text_issues'] > 0:
|
| 359 |
-
if read['small_text_issues'] > 5:
|
| 360 |
-
tips.append(f"Another quick win: {read['small_text_issues']} text elements are a bit small. Likely one font-size setting affects most of them, so this is usually a fast fix.")
|
| 361 |
-
else:
|
| 362 |
-
tips.append(f"{read['small_text_issues']} text element(s) are a bit small - see the white boxes marked \"Small text\" on the image above.")
|
| 363 |
-
|
| 364 |
-
if not tips:
|
| 365 |
-
tips.append("No major issues detected - this design is in solid shape.")
|
| 366 |
-
|
| 367 |
-
return tips
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
def identify_hotspots(saliency_map, img_h, img_w, top_n=4):
|
| 371 |
-
threshold = np.percentile(saliency_map, 85)
|
| 372 |
-
binary = (saliency_map >= threshold).astype(np.uint8)
|
| 373 |
-
kernel = np.ones((25, 25), np.uint8)
|
| 374 |
-
binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
|
| 375 |
-
num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(binary, connectivity=8)
|
| 376 |
-
zones = []
|
| 377 |
-
for i in range(1, num_labels):
|
| 378 |
-
area = stats[i, cv2.CC_STAT_AREA]
|
| 379 |
-
if area < (img_h * img_w * 0.005):
|
| 380 |
-
continue
|
| 381 |
-
cx, cy = centroids[i]
|
| 382 |
-
h_pos = "top" if cy < img_h * 0.33 else ("bottom" if cy > img_h * 0.66 else "middle")
|
| 383 |
-
w_pos = "left" if cx < img_w * 0.33 else ("right" if cx > img_w * 0.66 else "center")
|
| 384 |
-
avg_intensity = float(saliency_map[labels == i].mean())
|
| 385 |
-
zones.append({"position": f"{h_pos}-{w_pos}", "area_pct": round(float(area / (img_h * img_w)) * 100, 1),
|
| 386 |
-
"intensity": round(avg_intensity * 100, 1), "cx": int(cx), "cy": int(cy)})
|
| 387 |
-
zones.sort(key=lambda z: -z["intensity"])
|
| 388 |
-
top_zones = zones[:top_n]
|
| 389 |
-
for idx, z in enumerate(top_zones):
|
| 390 |
-
z["intensity_rank"] = idx + 1
|
| 391 |
-
top_zones.sort(key=lambda z: (z["cy"], z["cx"]))
|
| 392 |
-
return top_zones
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
def relative_luminance(rgb):
|
| 396 |
-
def chan(c):
|
| 397 |
-
c = c / 255.0
|
| 398 |
-
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
| 399 |
-
r, g, b = rgb
|
| 400 |
-
return 0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b)
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
def contrast_ratio(rgb1, rgb2):
|
| 404 |
-
l1 = relative_luminance(rgb1)
|
| 405 |
-
l2 = relative_luminance(rgb2)
|
| 406 |
-
lighter, darker = max(l1, l2), min(l1, l2)
|
| 407 |
-
return (lighter + 0.05) / (darker + 0.05)
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
def required_contrast_ratio(size_pct):
|
| 411 |
-
return 3.0 if size_pct >= 3.0 else 4.5
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
def contrast_severity_label(ratio, required_ratio):
|
| 415 |
-
deficit = (required_ratio - ratio) / required_ratio
|
| 416 |
-
if deficit > 0.6:
|
| 417 |
-
return "could be much clearer"
|
| 418 |
-
elif deficit > 0.35:
|
| 419 |
-
return "could be clearer"
|
| 420 |
-
elif deficit > 0.15:
|
| 421 |
-
return "slightly less clear than ideal"
|
| 422 |
-
else:
|
| 423 |
-
return "close to the recommended level"
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
def sample_text_and_bg_color(img_arr, x, y, w, h):
|
| 427 |
-
pad = max(2, int(h * 0.3))
|
| 428 |
-
y0, y1 = max(0, y - pad), min(img_arr.shape[0], y + h + pad)
|
| 429 |
-
x0, x1 = max(0, x - pad), min(img_arr.shape[1], x + w + pad)
|
| 430 |
-
region = img_arr[y0:y1, x0:x1].reshape(-1, 3).astype(np.float32)
|
| 431 |
-
if len(region) < 10:
|
| 432 |
-
return None, None
|
| 433 |
-
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0)
|
| 434 |
-
try:
|
| 435 |
-
_, _, centers = cv2.kmeans(region, 2, None, criteria, 3, cv2.KMEANS_PP_CENTERS)
|
| 436 |
-
except cv2.error:
|
| 437 |
-
return None, None
|
| 438 |
-
color1 = tuple(centers[0].astype(int))
|
| 439 |
-
color2 = tuple(centers[1].astype(int))
|
| 440 |
-
return color1, color2
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
def analyze_readability(pil_img):
|
| 444 |
-
img_arr = np.array(pil_img.convert("RGB"))
|
| 445 |
-
img_h = img_arr.shape[0]
|
| 446 |
-
|
| 447 |
-
gray = cv2.cvtColor(img_arr, cv2.COLOR_RGB2GRAY)
|
| 448 |
-
|
| 449 |
-
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
| 450 |
-
gray_enhanced = clahe.apply(gray)
|
| 451 |
-
|
| 452 |
-
gray_denoised = cv2.medianBlur(gray_enhanced, 3)
|
| 453 |
-
|
| 454 |
-
bw = cv2.adaptiveThreshold(gray_denoised, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 10)
|
| 455 |
-
|
| 456 |
-
cleanup_kernel = np.ones((2, 2), np.uint8)
|
| 457 |
-
bw = cv2.morphologyEx(bw, cv2.MORPH_OPEN, cleanup_kernel)
|
| 458 |
-
|
| 459 |
-
ocr_data = pytesseract.image_to_data(bw, output_type=pytesseract.Output.DICT)
|
| 460 |
-
all_words_loose = [t.strip() for t in ocr_data['text'] if t.strip()]
|
| 461 |
-
|
| 462 |
-
words_checked = 0
|
| 463 |
-
contrast_issues = 0
|
| 464 |
-
small_text_issues = 0
|
| 465 |
-
issue_lines = []
|
| 466 |
-
issue_boxes = []
|
| 467 |
-
text_sizes = []
|
| 468 |
-
for i in range(len(ocr_data['text'])):
|
| 469 |
-
word = ocr_data['text'][i].strip()
|
| 470 |
-
conf = int(ocr_data['conf'][i])
|
| 471 |
-
if not word or conf < 60 or len(word) < 2:
|
| 472 |
-
continue
|
| 473 |
-
x, y, w, h = ocr_data['left'][i], ocr_data['top'][i], ocr_data['width'][i], ocr_data['height'][i]
|
| 474 |
-
if w < 3 or h < 3:
|
| 475 |
-
continue
|
| 476 |
-
words_checked += 1
|
| 477 |
-
size_pct = (h / img_h) * 100
|
| 478 |
-
text_sizes.append(size_pct)
|
| 479 |
-
text_color, bg_color = sample_text_and_bg_color(img_arr, x, y, w, h)
|
| 480 |
-
if text_color is None:
|
| 481 |
-
continue
|
| 482 |
-
ratio = contrast_ratio(text_color, bg_color)
|
| 483 |
-
required_ratio = required_contrast_ratio(size_pct)
|
| 484 |
-
is_contrast_issue = ratio < required_ratio
|
| 485 |
-
is_small_issue = size_pct < 1.2
|
| 486 |
-
|
| 487 |
-
if is_contrast_issue:
|
| 488 |
-
contrast_issues += 1
|
| 489 |
-
if len(issue_lines) < 5:
|
| 490 |
-
severity = contrast_severity_label(ratio, required_ratio)
|
| 491 |
-
issue_lines.append(f"- \"{word}\" {severity} against its background")
|
| 492 |
-
if is_small_issue:
|
| 493 |
-
small_text_issues += 1
|
| 494 |
-
if len(issue_lines) < 5:
|
| 495 |
-
issue_lines.append(f"- \"{word}\" is a bit small to read comfortably")
|
| 496 |
-
|
| 497 |
-
if is_contrast_issue or is_small_issue:
|
| 498 |
-
combined_label = " + ".join(
|
| 499 |
-
l for l, flag in [("Low contrast", is_contrast_issue), ("Small text", is_small_issue)] if flag
|
| 500 |
-
)
|
| 501 |
-
issue_boxes.append({"box": (x, y, w, h), "label": combined_label})
|
| 502 |
-
return {"words_checked": words_checked, "contrast_issues": contrast_issues,
|
| 503 |
-
"small_text_issues": small_text_issues, "issue_lines": issue_lines, "issue_boxes": issue_boxes,
|
| 504 |
-
"text_sizes": text_sizes, "all_words": all_words_loose}
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
def merge_issue_boxes(issue_boxes, gap=15):
|
| 508 |
-
by_label = defaultdict(list)
|
| 509 |
-
for item in issue_boxes:
|
| 510 |
-
by_label[item["label"]].append(item["box"])
|
| 511 |
-
merged = []
|
| 512 |
-
for label, boxes in by_label.items():
|
| 513 |
-
boxes = sorted(boxes, key=lambda b: (b[1], b[0]))
|
| 514 |
-
used = [False] * len(boxes)
|
| 515 |
-
for i in range(len(boxes)):
|
| 516 |
-
if used[i]:
|
| 517 |
-
continue
|
| 518 |
-
x, y, w, h = boxes[i]
|
| 519 |
-
mx0, my0, mx1, my1 = x, y, x + w, y + h
|
| 520 |
-
used[i] = True
|
| 521 |
-
changed = True
|
| 522 |
-
while changed:
|
| 523 |
-
changed = False
|
| 524 |
-
for j in range(len(boxes)):
|
| 525 |
-
if used[j]:
|
| 526 |
-
continue
|
| 527 |
-
bx, by, bw, bh = boxes[j]
|
| 528 |
-
bx0, by0, bx1, by1 = bx, by, bx + bw, by + bh
|
| 529 |
-
vertical_overlap = min(my1, by1) - max(my0, by0)
|
| 530 |
-
if vertical_overlap > 0 and bx0 - mx1 <= gap and bx1 >= mx0 - gap:
|
| 531 |
-
mx0, my0 = min(mx0, bx0), min(my0, by0)
|
| 532 |
-
mx1, my1 = max(mx1, bx1), max(my1, by1)
|
| 533 |
-
used[j] = True
|
| 534 |
-
changed = True
|
| 535 |
-
merged.append({"box": (mx0, my0, mx1 - mx0, my1 - my0), "label": label})
|
| 536 |
-
return merged
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
def draw_issue_markers(overlay_bgr, issue_boxes, img_h, img_w):
|
| 540 |
-
img = overlay_bgr.copy()
|
| 541 |
-
scale = max(1.0, min(img_h, img_w) / 800.0)
|
| 542 |
-
merged_issues = merge_issue_boxes(issue_boxes)
|
| 543 |
-
placed_label_rects = []
|
| 544 |
-
pad = int(4 * scale)
|
| 545 |
-
box_border = max(2, int(3 * scale))
|
| 546 |
-
thin_border = max(1, int(scale))
|
| 547 |
-
font_scale = 0.5 * scale
|
| 548 |
-
text_thickness = max(1, int(round(scale)))
|
| 549 |
-
for issue in merged_issues:
|
| 550 |
-
x, y, w, h = issue["box"]
|
| 551 |
-
cv2.rectangle(img, (x - pad, y - pad), (x + w + pad, y + h + pad), (255, 255, 255), box_border)
|
| 552 |
-
cv2.rectangle(img, (x - pad, y - pad), (x + w + pad, y + h + pad), (0, 0, 0), thin_border)
|
| 553 |
-
label = issue["label"]
|
| 554 |
-
(tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, text_thickness)
|
| 555 |
-
label_x0 = x - pad
|
| 556 |
-
label_x1 = label_x0 + tw + int(8 * scale)
|
| 557 |
-
label_y1 = max(th + int(6 * scale), y - pad)
|
| 558 |
-
label_y0 = label_y1 - th - int(6 * scale)
|
| 559 |
-
|
| 560 |
-
collision = True
|
| 561 |
-
attempts = 0
|
| 562 |
-
while collision and attempts < 10:
|
| 563 |
-
collision = False
|
| 564 |
-
for (px0, py0, px1, py1) in placed_label_rects:
|
| 565 |
-
if not (label_x1 < px0 or label_x0 > px1 or label_y1 < py0 or label_y0 > py1):
|
| 566 |
-
collision = True
|
| 567 |
-
label_y1 -= (th + int(10 * scale))
|
| 568 |
-
label_y0 = label_y1 - th - int(6 * scale)
|
| 569 |
-
break
|
| 570 |
-
attempts += 1
|
| 571 |
-
|
| 572 |
-
placed_label_rects.append((label_x0, label_y0, label_x1, label_y1))
|
| 573 |
-
cv2.rectangle(img, (label_x0, label_y0), (label_x1, label_y1), (255, 255, 255), -1)
|
| 574 |
-
cv2.rectangle(img, (label_x0, label_y0), (label_x1, label_y1), (0, 0, 0), thin_border)
|
| 575 |
-
cv2.putText(img, label, (label_x0 + int(4 * scale), label_y1 - int(4 * scale)),
|
| 576 |
-
cv2.FONT_HERSHEY_SIMPLEX, font_scale, (0, 0, 0), text_thickness, cv2.LINE_AA)
|
| 577 |
-
return img
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
def markdown_to_paragraphs(md_text, styles):
|
| 581 |
-
story = []
|
| 582 |
-
for raw_line in md_text.split("\n"):
|
| 583 |
-
line = raw_line.strip()
|
| 584 |
-
if not line:
|
| 585 |
-
story.append(Spacer(1, 6))
|
| 586 |
-
continue
|
| 587 |
-
if line.startswith("## "):
|
| 588 |
-
story.append(Paragraph(line[3:], styles['Heading1']))
|
| 589 |
-
elif line.startswith("### "):
|
| 590 |
-
story.append(Paragraph(line[4:], styles['Heading2']))
|
| 591 |
-
elif line.startswith("---"):
|
| 592 |
-
story.append(Spacer(1, 10))
|
| 593 |
-
elif line.startswith("- ") or line.startswith("* "):
|
| 594 |
-
text = line[2:]
|
| 595 |
-
text = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', text)
|
| 596 |
-
text = re.sub(r'\*(.*?)\*', r'<i>\1</i>', text)
|
| 597 |
-
story.append(Paragraph("• " + text, styles['Normal']))
|
| 598 |
-
elif re.match(r'^\d+\.\s', line):
|
| 599 |
-
num, text = line.split('.', 1)
|
| 600 |
-
text = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', text.strip())
|
| 601 |
-
story.append(Paragraph(f"{num}. {text}", styles['Normal']))
|
| 602 |
-
else:
|
| 603 |
-
text = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', line)
|
| 604 |
-
text = re.sub(r'\*(.*?)\*', r'<i>\1</i>', text)
|
| 605 |
-
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<link href="\2"><u>\1</u></link>', text)
|
| 606 |
-
story.append(Paragraph(text, styles['Normal']))
|
| 607 |
-
return story
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
def stamp_pdf_watermark(canvas_obj, doc):
|
| 611 |
-
if not os.path.exists(LOGO_PATH):
|
| 612 |
-
return
|
| 613 |
-
try:
|
| 614 |
-
from reportlab.lib.utils import ImageReader
|
| 615 |
-
logo = Image.open(LOGO_PATH).convert("RGBA")
|
| 616 |
-
bbox = logo.getbbox()
|
| 617 |
-
if bbox:
|
| 618 |
-
logo = logo.crop(bbox)
|
| 619 |
-
logo_h = 0.8 * inch
|
| 620 |
-
logo_w = logo_h * (logo.width / logo.height)
|
| 621 |
-
margin = 15
|
| 622 |
-
canvas_obj.saveState()
|
| 623 |
-
canvas_obj.setFillAlpha(0.95)
|
| 624 |
-
canvas_obj.drawImage(ImageReader(logo), doc.pagesize[0] - logo_w - margin, margin,
|
| 625 |
-
width=logo_w, height=logo_h, mask='auto', preserveAspectRatio=True)
|
| 626 |
-
canvas_obj.restoreState()
|
| 627 |
-
except Exception:
|
| 628 |
-
pass
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
def export_pdf(heatmap_img, chart_img, summary_md):
|
| 632 |
-
if heatmap_img is None or not summary_md:
|
| 633 |
-
return None
|
| 634 |
-
if isinstance(heatmap_img, np.ndarray):
|
| 635 |
-
heatmap_img = Image.fromarray(heatmap_img)
|
| 636 |
-
if chart_img is not None and isinstance(chart_img, np.ndarray):
|
| 637 |
-
chart_img = Image.fromarray(chart_img)
|
| 638 |
-
styles = getSampleStyleSheet()
|
| 639 |
-
tmp_dir = tempfile.gettempdir()
|
| 640 |
-
pdf_path = os.path.join(tmp_dir, "design_analysis_report.pdf")
|
| 641 |
-
doc = SimpleDocTemplate(pdf_path, pagesize=letter, topMargin=0.6 * inch, bottomMargin=0.6 * inch,
|
| 642 |
-
leftMargin=0.7 * inch, rightMargin=0.7 * inch)
|
| 643 |
-
story = [Paragraph("Design Analyzer Report", styles['Title']), Spacer(1, 12)]
|
| 644 |
-
|
| 645 |
-
max_width = 6.1 * inch
|
| 646 |
-
heatmap_path = os.path.join(tmp_dir, "heatmap_export_temp.png")
|
| 647 |
-
heatmap_img.save(heatmap_path)
|
| 648 |
-
ratio = heatmap_img.height / heatmap_img.width
|
| 649 |
-
story.append(RLImage(heatmap_path, width=max_width, height=max_width * ratio))
|
| 650 |
-
story.append(Spacer(1, 14))
|
| 651 |
-
|
| 652 |
-
if chart_img is not None:
|
| 653 |
-
chart_path = os.path.join(tmp_dir, "chart_export_temp.png")
|
| 654 |
-
chart_img.save(chart_path)
|
| 655 |
-
chart_ratio = chart_img.height / chart_img.width
|
| 656 |
-
story.append(RLImage(chart_path, width=max_width, height=max_width * chart_ratio))
|
| 657 |
-
story.append(Spacer(1, 14))
|
| 658 |
-
|
| 659 |
-
story.extend(markdown_to_paragraphs(summary_md, styles))
|
| 660 |
-
doc.build(story, onFirstPage=stamp_pdf_watermark, onLaterPages=stamp_pdf_watermark)
|
| 661 |
-
return pdf_path
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
def draw_scan_path(overlay_bgr, zones, img_h, img_w):
|
| 665 |
-
img = overlay_bgr.copy()
|
| 666 |
-
scale = max(1.0, min(img_h, img_w) / 800.0)
|
| 667 |
-
outer_thickness = int(8 * scale)
|
| 668 |
-
inner_thickness = int(4 * scale)
|
| 669 |
-
ordered = sorted(zones, key=lambda z: z["intensity_rank"])
|
| 670 |
-
for i in range(len(ordered) - 1):
|
| 671 |
-
pt1 = (ordered[i]["cx"], ordered[i]["cy"])
|
| 672 |
-
pt2 = (ordered[i + 1]["cx"], ordered[i + 1]["cy"])
|
| 673 |
-
cv2.arrowedLine(img, pt1, pt2, (0, 0, 0), outer_thickness, tipLength=0.08, line_type=cv2.LINE_AA)
|
| 674 |
-
cv2.arrowedLine(img, pt1, pt2, (0, 255, 255), inner_thickness, tipLength=0.08, line_type=cv2.LINE_AA)
|
| 675 |
-
return img
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
def draw_zone_labels(overlay_bgr, zones, img_h, img_w):
|
| 679 |
-
img = overlay_bgr.copy()
|
| 680 |
-
scale = max(1.0, min(img_h, img_w) / 800.0)
|
| 681 |
-
radius = int(22 * scale)
|
| 682 |
-
font_scale = 0.9 * scale
|
| 683 |
-
thickness = max(2, int(3 * scale))
|
| 684 |
-
for i, z in enumerate(zones, 1):
|
| 685 |
-
cx, cy = z["cx"], z["cy"]
|
| 686 |
-
cv2.circle(img, (cx, cy), radius, (255, 255, 255), -1)
|
| 687 |
-
cv2.circle(img, (cx, cy), radius, (0, 0, 0), thickness)
|
| 688 |
-
text = str(i)
|
| 689 |
-
(tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness)
|
| 690 |
-
cv2.putText(img, text, (cx - tw // 2, cy + th // 2), cv2.FONT_HERSHEY_SIMPLEX, font_scale, (0, 0, 0), thickness, cv2.LINE_AA)
|
| 691 |
-
return img
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
def flatten_transparency(pil_image):
|
| 695 |
-
if pil_image.mode in ("RGBA", "LA") or (pil_image.mode == "P" and "transparency" in pil_image.info):
|
| 696 |
-
rgba = pil_image.convert("RGBA")
|
| 697 |
-
background = Image.new("RGB", rgba.size, (255, 255, 255))
|
| 698 |
-
background.paste(rgba, mask=rgba.split()[-1])
|
| 699 |
-
return background
|
| 700 |
-
return pil_image.convert("RGB")
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
def analyze(pil_image, overlay_strength):
|
| 704 |
-
if pil_image is None:
|
| 705 |
-
return None, None, "Upload an image first.", gr.update()
|
| 706 |
-
pil_rgb = flatten_transparency(pil_image)
|
| 707 |
-
img_bgr = cv2.cvtColor(np.array(pil_rgb), cv2.COLOR_RGB2BGR)
|
| 708 |
-
h, w = img_bgr.shape[:2]
|
| 709 |
-
|
| 710 |
-
saliency_map = compute_saliency_map(pil_rgb)
|
| 711 |
-
overlay_bgr = render_heatmap_overlay(img_bgr, saliency_map, alpha=overlay_strength)
|
| 712 |
-
|
| 713 |
-
scores = compute_attention_scores(saliency_map)
|
| 714 |
-
zones = identify_hotspots(saliency_map, h, w)
|
| 715 |
-
read = analyze_readability(pil_rgb)
|
| 716 |
-
readability_score = compute_readability_score(read)
|
| 717 |
-
total_score, grade = compute_overall_grade(scores['focus_score'], scores['spread_score'], readability_score)
|
| 718 |
-
grade_reason = explain_grade(scores['focus_score'], scores['spread_score'], readability_score, read)
|
| 719 |
-
tips = generate_recommendations(saliency_map, scores, read, h, w)
|
| 720 |
-
hierarchy_result = compute_hierarchy(read.get('text_sizes', []))
|
| 721 |
-
balance_score = compute_balance(saliency_map)
|
| 722 |
-
hierarchy_score = hierarchy_result[0] if hierarchy_result else None
|
| 723 |
-
chart_image = create_score_chart(scores['focus_score'], scores['spread_score'], readability_score,
|
| 724 |
-
hierarchy_score, balance_score)
|
| 725 |
-
|
| 726 |
-
overlay_bgr = draw_scan_path(overlay_bgr, zones, h, w)
|
| 727 |
-
overlay_bgr = draw_zone_labels(overlay_bgr, zones, h, w)
|
| 728 |
-
overlay_bgr = draw_issue_markers(overlay_bgr, read.get("issue_boxes", []), h, w)
|
| 729 |
-
overlay_rgb = cv2.cvtColor(overlay_bgr, cv2.COLOR_BGR2RGB)
|
| 730 |
-
|
| 731 |
-
summary = f"## Grade: {grade} — {total_score}/100\n"
|
| 732 |
-
summary += f"*Why: {grade_reason}*\n\n"
|
| 733 |
-
|
| 734 |
-
summary += "**Where Attention Goes First:** *(numbers match the image above)*\n"
|
| 735 |
-
if zones:
|
| 736 |
-
for i, z in enumerate(zones, 1):
|
| 737 |
-
summary += f"{i}. {z['position'].replace('-', ' ').title()} ({z['intensity']}/100 intensity, {z['area_pct']}% of design)\n"
|
| 738 |
-
else:
|
| 739 |
-
summary += "- Attention is fairly even across the design, no strong single hotspot.\n"
|
| 740 |
-
|
| 741 |
-
summary += (
|
| 742 |
-
f"\n**Scores:**\n"
|
| 743 |
-
f"- Clear Focal Point: {scores['focus_score']}/100 (how strongly attention lands on one main spot, vs scattered everywhere)\n"
|
| 744 |
-
f"- Attention Coverage: {scores['spread_score']}/100 - {coverage_description(scores['spread_score'])}\n"
|
| 745 |
-
f"- Readability: "
|
| 746 |
-
)
|
| 747 |
-
if read['words_checked'] == 0:
|
| 748 |
-
summary += "couldn't be reliably read on this design (common with heavily stylized or hand-lettered fonts) - not included in the score above\n"
|
| 749 |
-
elif read['contrast_issues'] == 0 and read['small_text_issues'] == 0:
|
| 750 |
-
summary += "clean, no changes needed\n"
|
| 751 |
-
else:
|
| 752 |
-
total_issues = read['contrast_issues'] + read['small_text_issues']
|
| 753 |
-
summary += f"{total_issues} easy improvement(s) spotted - see below\n"
|
| 754 |
-
|
| 755 |
-
if read['issue_lines']:
|
| 756 |
-
summary += "\n**A few examples:**\n" + "\n".join(read['issue_lines']) + "\n"
|
| 757 |
-
summary += (
|
| 758 |
-
"\n*Note: this is automated and can occasionally flag text that's actually fine, "
|
| 759 |
-
"especially on busy or gradient backgrounds - if something flagged looks perfectly "
|
| 760 |
-
"readable to your eye, trust your eye.*\n"
|
| 761 |
-
)
|
| 762 |
-
|
| 763 |
-
summary += "\n**How to Improve:** *(common and usually quick to fix)*\n"
|
| 764 |
-
for tip in tips:
|
| 765 |
-
summary += f"- {tip}\n"
|
| 766 |
-
|
| 767 |
-
summary += "\n**Layout & Composition:** *(based on classic layout principles - hierarchy and balance)*\n"
|
| 768 |
-
if hierarchy_result:
|
| 769 |
-
summary += f"- Visual Hierarchy: {hierarchy_description(hierarchy_result[1])}\n"
|
| 770 |
-
else:
|
| 771 |
-
summary += "- Visual Hierarchy: not enough distinct text elements to judge\n"
|
| 772 |
-
summary += f"- Balance: {balance_description(balance_score)}\n"
|
| 773 |
-
|
| 774 |
-
has_price = detect_price_info(read.get('all_words', []))
|
| 775 |
-
has_location = detect_location_info(read.get('all_words', []))
|
| 776 |
-
summary += (
|
| 777 |
-
"\n**Content Checklist:** *(Mike Stevens' classic Who/What/Where/Why/How Much - "
|
| 778 |
-
"not every design needs all five)*\n"
|
| 779 |
-
f"- How Much (price): {'found' if has_price else 'not detected - if this design should show a price, double check it'}\n"
|
| 780 |
-
f"- Where (location/contact): {'found' if has_location else 'not detected - if this design should point somewhere, double check it'}\n"
|
| 781 |
-
"- Who / What / Why: these need a human read, not automation - ask yourself: is it "
|
| 782 |
-
"clear who's offering this, exactly what they're offering, and why someone should care?\n"
|
| 783 |
-
)
|
| 784 |
-
|
| 785 |
-
summary += (
|
| 786 |
-
"\n---\n"
|
| 787 |
-
"**Methodology:** Attention prediction combines MSI-Net, a peer-reviewed saliency model "
|
| 788 |
-
"(Kroner et al., *Neural Networks*, 2020, "
|
| 789 |
-
"[DOI: 10.1016/j.neunet.2020.05.004](https://doi.org/10.1016/j.neunet.2020.05.004)), "
|
| 790 |
-
"with a size-proportional boost for legible text - since eye-tracking research confirms "
|
| 791 |
-
"large headline text reliably draws attention, which pure bottom-up saliency models can "
|
| 792 |
-
"underweight relative to small high-contrast graphics. This is a data-informed prediction, "
|
| 793 |
-
"not a substitute for live user testing."
|
| 794 |
-
)
|
| 795 |
-
|
| 796 |
-
summary += (
|
| 797 |
-
"\n\n---\n"
|
| 798 |
-
"**This tool flags what's off. A designer knows what's right.** "
|
| 799 |
-
"Haan is a brand identity and visual designer with 12+ years turning "
|
| 800 |
-
"insights like these into designs that actually convert, stay true to "
|
| 801 |
-
"your brand, and look intentional (not just \"fixed\").\n\n"
|
| 802 |
-
"[Visit haanilango.com](https://www.haanilango.com) · Tel: +65 9027 2070"
|
| 803 |
-
)
|
| 804 |
-
|
| 805 |
-
result_image = add_watermark(Image.fromarray(overlay_rgb))
|
| 806 |
-
chart_image = add_watermark(chart_image, scale=0.26)
|
| 807 |
-
|
| 808 |
-
new_count = increment_count()
|
| 809 |
-
badge_html = format_count_badge(new_count) if new_count is not None else gr.update()
|
| 810 |
-
|
| 811 |
-
return result_image, chart_image, summary, badge_html
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
with gr.Blocks(title="Design Analyzer") as demo:
|
| 815 |
-
count_display = gr.HTML(format_count_badge(0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|