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