The pure Shannon entropy score penalized well-designed ads with multiple intentional visual elements (e.g. hero product + text + logo scored ~8/100). New composite score (0-100) weights four components: - Peak Dominance (30%): strength of #1 hotspot vs rest - Hierarchy Clarity (25%): monotonic intensity ordering - Gaze Coherence (25%): smooth spatial gaze path - Entropy Concentration (20%): sqrt-softened entropy The raw entropy score is preserved as entropy_score for users who want it, visible in the ScoreCard hover tooltip and PDF report. Also adds auto-create DB tables on startup for fresh Docker deploys. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
449 lines
16 KiB
Python
449 lines
16 KiB
Python
import io
|
|
import os
|
|
from datetime import datetime
|
|
|
|
from PIL import Image as PILImage
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.lib.styles import ParagraphStyle
|
|
from reportlab.lib.units import inch, mm
|
|
from reportlab.pdfbase import pdfmetrics
|
|
from reportlab.pdfbase.ttfonts import TTFont
|
|
from reportlab.platypus import (
|
|
Image,
|
|
PageBreak,
|
|
Paragraph,
|
|
SimpleDocTemplate,
|
|
Spacer,
|
|
Table,
|
|
TableStyle,
|
|
)
|
|
|
|
# ─── Colors ──────────────────────────────────────────
|
|
NAVY = colors.HexColor("#1a1a2e")
|
|
GOLD = colors.HexColor("#ffc407")
|
|
GOLD_LIGHT = colors.HexColor("#fff8e0")
|
|
LIGHT_GRAY = colors.HexColor("#f5f5f5")
|
|
MEDIUM_GRAY = colors.HexColor("#666666")
|
|
DARK_TEXT = colors.HexColor("#333333")
|
|
WHITE = colors.white
|
|
GREEN = colors.HexColor("#16a34a")
|
|
BLUE = colors.HexColor("#2563eb")
|
|
AMBER = colors.HexColor("#d97706")
|
|
PURPLE = colors.HexColor("#7c3aed")
|
|
|
|
# ─── Register Montserrat fonts ──────────────────────
|
|
FONT_DIR = os.path.join(os.path.dirname(__file__), "fonts")
|
|
|
|
pdfmetrics.registerFont(TTFont("Montserrat", os.path.join(FONT_DIR, "Montserrat-Regular.ttf")))
|
|
pdfmetrics.registerFont(TTFont("Montserrat-Bold", os.path.join(FONT_DIR, "Montserrat-Bold.ttf")))
|
|
pdfmetrics.registerFont(TTFont("Montserrat-SemiBold", os.path.join(FONT_DIR, "Montserrat-SemiBold.ttf")))
|
|
|
|
pdfmetrics.registerFontFamily(
|
|
"Montserrat",
|
|
normal="Montserrat",
|
|
bold="Montserrat-Bold",
|
|
)
|
|
|
|
|
|
def _bytes_to_image(data: bytes) -> io.BytesIO:
|
|
buf = io.BytesIO(data)
|
|
buf.seek(0)
|
|
return buf
|
|
|
|
|
|
def _make_styles():
|
|
title = ParagraphStyle(
|
|
"Title",
|
|
fontName="Montserrat-Bold",
|
|
fontSize=32,
|
|
textColor=NAVY,
|
|
spaceAfter=6,
|
|
leading=38,
|
|
)
|
|
subtitle = ParagraphStyle(
|
|
"Subtitle",
|
|
fontName="Montserrat",
|
|
fontSize=14,
|
|
textColor=GOLD,
|
|
spaceAfter=20,
|
|
leading=18,
|
|
)
|
|
heading = ParagraphStyle(
|
|
"Heading",
|
|
fontName="Montserrat-Bold",
|
|
fontSize=18,
|
|
textColor=NAVY,
|
|
spaceBefore=12,
|
|
spaceAfter=8,
|
|
leading=22,
|
|
)
|
|
subheading = ParagraphStyle(
|
|
"Subheading",
|
|
fontName="Montserrat-SemiBold",
|
|
fontSize=13,
|
|
textColor=NAVY,
|
|
spaceBefore=8,
|
|
spaceAfter=4,
|
|
leading=16,
|
|
)
|
|
body = ParagraphStyle(
|
|
"Body",
|
|
fontName="Montserrat",
|
|
fontSize=10,
|
|
textColor=DARK_TEXT,
|
|
spaceAfter=6,
|
|
leading=14,
|
|
)
|
|
body_small = ParagraphStyle(
|
|
"BodySmall",
|
|
fontName="Montserrat",
|
|
fontSize=9,
|
|
textColor=MEDIUM_GRAY,
|
|
spaceAfter=4,
|
|
leading=12,
|
|
)
|
|
meta_label = ParagraphStyle(
|
|
"MetaLabel",
|
|
fontName="Montserrat-SemiBold",
|
|
fontSize=10,
|
|
textColor=MEDIUM_GRAY,
|
|
spaceAfter=2,
|
|
)
|
|
meta_value = ParagraphStyle(
|
|
"MetaValue",
|
|
fontName="Montserrat-Bold",
|
|
fontSize=14,
|
|
textColor=NAVY,
|
|
spaceAfter=8,
|
|
)
|
|
footer = ParagraphStyle(
|
|
"Footer",
|
|
fontName="Montserrat",
|
|
fontSize=8,
|
|
textColor=MEDIUM_GRAY,
|
|
alignment=1, # center
|
|
)
|
|
return {
|
|
"title": title,
|
|
"subtitle": subtitle,
|
|
"heading": heading,
|
|
"subheading": subheading,
|
|
"body": body,
|
|
"body_small": body_small,
|
|
"meta_label": meta_label,
|
|
"meta_value": meta_value,
|
|
"footer": footer,
|
|
}
|
|
|
|
|
|
def _insight_type_label(t: str) -> tuple[str, colors.Color]:
|
|
return {
|
|
"success": ("STRENGTH", GREEN),
|
|
"warning": ("ATTENTION", AMBER),
|
|
"info": ("INSIGHT", BLUE),
|
|
}.get(t, ("INSIGHT", BLUE))
|
|
|
|
|
|
def _build_insight_table(insights: list[dict], styles: dict, is_ai: bool = False) -> list:
|
|
"""Build styled insight rows as ReportLab elements."""
|
|
elements = []
|
|
for insight in insights:
|
|
label_text, label_color = _insight_type_label(insight["type"])
|
|
accent = PURPLE if is_ai else label_color
|
|
|
|
# Build a mini table for each insight card
|
|
badge = f'<font color="#{accent.hexval()[2:]}" size="7"><b>{label_text}</b></font>'
|
|
title = f'<font name="Montserrat-SemiBold" size="10" color="#1a1a2e">{insight["title"]}</font>'
|
|
desc = f'<font name="Montserrat" size="9" color="#555555">{insight["description"]}</font>'
|
|
|
|
content = Paragraph(f"{badge}<br/>{title}<br/>{desc}", styles["body"])
|
|
|
|
t = Table(
|
|
[[content]],
|
|
colWidths=[170 * mm],
|
|
)
|
|
t.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, -1), GOLD_LIGHT if is_ai else LIGHT_GRAY),
|
|
("LEFTPADDING", (0, 0), (-1, -1), 10),
|
|
("RIGHTPADDING", (0, 0), (-1, -1), 10),
|
|
("TOPPADDING", (0, 0), (-1, -1), 8),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
|
("ROUNDEDCORNERS", [4, 4, 4, 4]),
|
|
("LINEBEFOREDECORCOLOR", (0, 0), (0, -1), accent),
|
|
# Left accent border
|
|
("LINEBEFORE", (0, 0), (0, -1), 3, accent),
|
|
]))
|
|
elements.append(t)
|
|
elements.append(Spacer(1, 4))
|
|
|
|
return elements
|
|
|
|
|
|
def generate_report(
|
|
analysis,
|
|
original_image: bytes,
|
|
heatmap_image: bytes,
|
|
gaze_image: bytes,
|
|
aois: list,
|
|
rule_insights: list[dict] | None = None,
|
|
ai_insights: list[dict] | None = None,
|
|
ai_cost_usd: float | None = None,
|
|
) -> bytes:
|
|
"""Generate a professional PDF report for an analysis."""
|
|
buffer = io.BytesIO()
|
|
doc = SimpleDocTemplate(
|
|
buffer,
|
|
pagesize=A4,
|
|
topMargin=20 * mm,
|
|
bottomMargin=20 * mm,
|
|
leftMargin=15 * mm,
|
|
rightMargin=15 * mm,
|
|
)
|
|
|
|
styles = _make_styles()
|
|
elements = []
|
|
|
|
# ─── Cover Page ──────────────────────────────────────────
|
|
elements.append(Spacer(1, 50))
|
|
elements.append(Paragraph("OliVAS", styles["title"]))
|
|
elements.append(Paragraph("Visual Attention Analysis Report", styles["subtitle"]))
|
|
elements.append(Spacer(1, 8))
|
|
|
|
# Gold divider line
|
|
divider = Table([[""]], colWidths=[60 * mm], rowHeights=[2])
|
|
divider.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, -1), GOLD),
|
|
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
|
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
|
("TOPPADDING", (0, 0), (-1, -1), 0),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
|
|
]))
|
|
elements.append(divider)
|
|
elements.append(Spacer(1, 20))
|
|
|
|
# Metadata grid
|
|
meta_data = [
|
|
["ANALYSIS", "MODEL", "DATE", "IMAGE SIZE"],
|
|
[
|
|
analysis.name,
|
|
analysis.model_used.replace("_", " ").title(),
|
|
datetime.now().strftime("%B %d, %Y"),
|
|
f"{analysis.image_width} x {analysis.image_height}",
|
|
],
|
|
]
|
|
meta_table = Table(meta_data, colWidths=[45 * mm] * 4)
|
|
meta_table.setStyle(TableStyle([
|
|
("FONTNAME", (0, 0), (-1, 0), "Montserrat-SemiBold"),
|
|
("FONTSIZE", (0, 0), (-1, 0), 8),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), MEDIUM_GRAY),
|
|
("FONTNAME", (0, 1), (-1, 1), "Montserrat-Bold"),
|
|
("FONTSIZE", (0, 1), (-1, 1), 11),
|
|
("TEXTCOLOR", (0, 1), (-1, 1), NAVY),
|
|
("TOPPADDING", (0, 0), (-1, -1), 4),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
]))
|
|
elements.append(meta_table)
|
|
elements.append(Spacer(1, 10))
|
|
|
|
# Score badge
|
|
if analysis.overall_score is not None:
|
|
score = analysis.overall_score
|
|
score_color = GREEN if score >= 55 else (AMBER if score >= 35 else colors.HexColor("#ef4444"))
|
|
score_text = (
|
|
f'<font name="Montserrat-SemiBold" size="9" color="#666666">DESIGN EFFECTIVENESS</font><br/>'
|
|
f'<font name="Montserrat-Bold" size="28" color="#{score_color.hexval()[2:]}">{score:.0f}</font>'
|
|
f'<font name="Montserrat" size="12" color="#999999"> / 100</font>'
|
|
)
|
|
entropy = getattr(analysis, "entropy_score", None)
|
|
if entropy is not None:
|
|
score_text += (
|
|
f'<br/><font name="Montserrat" size="8" color="#999999">'
|
|
f'Entropy concentration: {entropy:.0f}/100</font>'
|
|
)
|
|
elements.append(Paragraph(score_text, styles["body"]))
|
|
elements.append(Spacer(1, 10))
|
|
|
|
# Thumbnail on cover
|
|
max_width = 160 * mm
|
|
img = PILImage.open(io.BytesIO(original_image))
|
|
aspect = img.width / img.height
|
|
img_width = min(max_width, 130 * mm)
|
|
img_height = img_width / aspect
|
|
if img_height > 90 * mm:
|
|
img_height = 90 * mm
|
|
img_width = img_height * aspect
|
|
elements.append(Image(_bytes_to_image(original_image), width=img_width, height=img_height))
|
|
|
|
elements.append(PageBreak())
|
|
|
|
# ─── Heatmap Page ────────────────────────────────────────
|
|
elements.append(Paragraph("Attention Heatmap", styles["heading"]))
|
|
elements.append(
|
|
Paragraph(
|
|
"Areas highlighted in warm colors (red/yellow) indicate high predicted "
|
|
"attention during the first 3-5 seconds of viewing. Cool colors (blue) "
|
|
"indicate lower attention probability.",
|
|
styles["body"],
|
|
)
|
|
)
|
|
elements.append(Spacer(1, 8))
|
|
elements.append(Image(_bytes_to_image(heatmap_image), width=img_width, height=img_height))
|
|
|
|
elements.append(PageBreak())
|
|
|
|
# ─── Gaze Sequence Page ──────────────────────────────────
|
|
elements.append(Paragraph("Predicted Gaze Sequence", styles["heading"]))
|
|
elements.append(
|
|
Paragraph(
|
|
"Numbered points show the predicted order in which viewers will "
|
|
"fixate on different areas of the design. Point #1 is where the eye "
|
|
"lands first.",
|
|
styles["body"],
|
|
)
|
|
)
|
|
elements.append(Spacer(1, 8))
|
|
elements.append(Image(_bytes_to_image(gaze_image), width=img_width, height=img_height))
|
|
|
|
if analysis.gaze_sequence:
|
|
elements.append(Spacer(1, 10))
|
|
elements.append(Paragraph("Fixation Details", styles["subheading"]))
|
|
table_data = [["#", "Position", "From Left", "From Top", "Probability"]]
|
|
for point in analysis.gaze_sequence:
|
|
table_data.append([
|
|
str(point["rank"]),
|
|
f"({point['x']}, {point['y']})",
|
|
f"{point['x_pct']:.1f}%",
|
|
f"{point['y_pct']:.1f}%",
|
|
f"{point['probability']:.1%}",
|
|
])
|
|
t = Table(table_data, colWidths=[25, 80, 60, 60, 70])
|
|
t.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, 0), NAVY),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
|
|
("FONTNAME", (0, 0), (-1, 0), "Montserrat-SemiBold"),
|
|
("FONTNAME", (0, 1), (-1, -1), "Montserrat"),
|
|
("FONTSIZE", (0, 0), (-1, -1), 9),
|
|
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#dddddd")),
|
|
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, LIGHT_GRAY]),
|
|
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
|
("TOPPADDING", (0, 0), (-1, -1), 5),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
|
]))
|
|
elements.append(t)
|
|
|
|
# ─── Hotspots Page ───────────────────────────────────────
|
|
if analysis.hotspots:
|
|
elements.append(PageBreak())
|
|
elements.append(Paragraph("Attention Hotspots", styles["heading"]))
|
|
elements.append(
|
|
Paragraph(
|
|
"The top regions ranked by predicted attention intensity. Higher rank "
|
|
"means more predicted visual attention.",
|
|
styles["body"],
|
|
)
|
|
)
|
|
elements.append(Spacer(1, 8))
|
|
|
|
hs_data = [["Rank", "Position (x, y)", "Intensity"]]
|
|
for hs in analysis.hotspots:
|
|
cx = hs.get("center_x", hs["x"])
|
|
cy = hs.get("center_y", hs["y"])
|
|
hs_data.append([
|
|
f"#{hs['rank']}",
|
|
f"({cx}, {cy})",
|
|
f"{hs['intensity']:.2%}",
|
|
])
|
|
t = Table(hs_data, colWidths=[50, 120, 80])
|
|
t.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, 0), NAVY),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
|
|
("FONTNAME", (0, 0), (-1, 0), "Montserrat-SemiBold"),
|
|
("FONTNAME", (0, 1), (-1, -1), "Montserrat"),
|
|
("FONTSIZE", (0, 0), (-1, -1), 9),
|
|
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#dddddd")),
|
|
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, LIGHT_GRAY]),
|
|
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
|
("TOPPADDING", (0, 0), (-1, -1), 5),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
|
]))
|
|
elements.append(t)
|
|
|
|
# ─── Insights Page ───────────────────────────────────────
|
|
has_rule = rule_insights and len(rule_insights) > 0
|
|
has_ai = ai_insights and len(ai_insights) > 0
|
|
|
|
if has_rule or has_ai:
|
|
elements.append(PageBreak())
|
|
elements.append(Paragraph("Analysis Insights", styles["heading"]))
|
|
|
|
if has_rule:
|
|
elements.append(Paragraph("Rule-Based Insights", styles["subheading"]))
|
|
elements.append(
|
|
Paragraph(
|
|
"Automatically generated observations based on the attention metrics.",
|
|
styles["body_small"],
|
|
)
|
|
)
|
|
elements.append(Spacer(1, 6))
|
|
elements.extend(_build_insight_table(rule_insights, styles, is_ai=False))
|
|
elements.append(Spacer(1, 12))
|
|
|
|
if has_ai:
|
|
elements.append(Paragraph("AI Design Analysis", styles["subheading"]))
|
|
ai_note = "Generated by Claude Sonnet 4.6 — context-aware design recommendations."
|
|
if ai_cost_usd is not None:
|
|
ai_note += f" (Cost: ${ai_cost_usd:.4f})"
|
|
elements.append(Paragraph(ai_note, styles["body_small"]))
|
|
elements.append(Spacer(1, 6))
|
|
elements.extend(_build_insight_table(ai_insights, styles, is_ai=True))
|
|
|
|
# ─── AOI Page (if any) ───────────────────────────────────
|
|
if aois:
|
|
elements.append(PageBreak())
|
|
elements.append(Paragraph("Areas of Interest", styles["heading"]))
|
|
elements.append(
|
|
Paragraph(
|
|
"User-defined regions analyzed for predicted attention capture. "
|
|
"Density > 1.0 means the region captures more attention than its "
|
|
"size would suggest.",
|
|
styles["body"],
|
|
)
|
|
)
|
|
elements.append(Spacer(1, 8))
|
|
|
|
aoi_data = [["Region", "Attention %", "Area %", "Density"]]
|
|
for aoi in aois:
|
|
aoi_data.append([
|
|
aoi.label,
|
|
f"{aoi.attention_pct:.1f}%",
|
|
f"{aoi.area_pct:.1f}%",
|
|
f"{aoi.attention_density:.2f}x",
|
|
])
|
|
t = Table(aoi_data, colWidths=[100, 70, 70, 70])
|
|
t.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, 0), NAVY),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
|
|
("FONTNAME", (0, 0), (-1, 0), "Montserrat-SemiBold"),
|
|
("FONTNAME", (0, 1), (-1, -1), "Montserrat"),
|
|
("FONTSIZE", (0, 0), (-1, -1), 9),
|
|
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#dddddd")),
|
|
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, LIGHT_GRAY]),
|
|
("ALIGN", (1, 0), (-1, -1), "CENTER"),
|
|
("TOPPADDING", (0, 0), (-1, -1), 5),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
|
]))
|
|
elements.append(t)
|
|
|
|
# ─── Footer ──────────────────────────────────────────────
|
|
elements.append(Spacer(1, 30))
|
|
elements.append(
|
|
Paragraph(
|
|
"Generated by OliVAS — Open-Source Visual Attention Software by OLIVER",
|
|
styles["footer"],
|
|
)
|
|
)
|
|
|
|
doc.build(elements)
|
|
return buffer.getvalue()
|