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>
201 lines
7.3 KiB
Python
201 lines
7.3 KiB
Python
"""Rule-based insights engine for visual attention analysis.
|
|
|
|
Generates actionable text insights from analysis metrics —
|
|
no AI needed, just conditional logic on the computed data.
|
|
"""
|
|
|
|
|
|
def generate_insights(analysis) -> list[dict]:
|
|
"""Generate insights from a completed analysis.
|
|
|
|
Args:
|
|
analysis: Analysis ORM object with overall_score, hotspots, gaze_sequence,
|
|
image_width, image_height.
|
|
|
|
Returns:
|
|
List of dicts with keys: type (info|success|warning), title, description.
|
|
"""
|
|
insights = []
|
|
|
|
score = analysis.overall_score
|
|
hotspots = analysis.hotspots or []
|
|
gaze_seq = analysis.gaze_sequence or []
|
|
w = analysis.image_width
|
|
h = analysis.image_height
|
|
|
|
if score is None or not hotspots:
|
|
return insights
|
|
|
|
# 1. Design Effectiveness
|
|
if score >= 55:
|
|
insights.append({
|
|
"type": "success",
|
|
"title": "Strong design effectiveness",
|
|
"description": (
|
|
f"Design effectiveness is high (score {score:.0f}/100). "
|
|
"Your design has a clear visual hierarchy with a dominant focal point "
|
|
"and coherent gaze flow."
|
|
),
|
|
})
|
|
elif score >= 35:
|
|
insights.append({
|
|
"type": "info",
|
|
"title": "Moderate design effectiveness",
|
|
"description": (
|
|
f"Design effectiveness is moderate (score {score:.0f}/100). "
|
|
"Viewers will notice several areas. Consider whether your primary message "
|
|
"is prominent enough to stand out from competing elements."
|
|
),
|
|
})
|
|
else:
|
|
insights.append({
|
|
"type": "warning",
|
|
"title": "Low design effectiveness",
|
|
"description": (
|
|
f"Design effectiveness is low (score {score:.0f}/100). "
|
|
"No single element dominates and attention may scatter without clear hierarchy. "
|
|
"Consider increasing contrast, size, or whitespace around your hero element."
|
|
),
|
|
})
|
|
|
|
# 2. Dominant Focal Point
|
|
if len(hotspots) >= 2:
|
|
top = hotspots[0]["intensity"]
|
|
second = hotspots[1]["intensity"]
|
|
if top > 0 and second > 0:
|
|
ratio = top / second
|
|
if ratio >= 3:
|
|
insights.append({
|
|
"type": "success",
|
|
"title": "Clear dominant element",
|
|
"description": (
|
|
f"The top hotspot is {ratio:.1f}x stronger than the second — "
|
|
"your design has one unmistakable focal point. This is ideal for "
|
|
"ads with a single hero product or CTA."
|
|
),
|
|
})
|
|
elif ratio >= 1.5:
|
|
insights.append({
|
|
"type": "info",
|
|
"title": "Moderate focal dominance",
|
|
"description": (
|
|
f"The top hotspot is {ratio:.1f}x stronger than the second. "
|
|
"There's a primary focus but competing elements may split attention."
|
|
),
|
|
})
|
|
else:
|
|
insights.append({
|
|
"type": "warning",
|
|
"title": "Competing focal points",
|
|
"description": (
|
|
"The top two hotspots have similar intensity — viewers' eyes will "
|
|
"bounce between them. If one element is your priority, consider "
|
|
"making it larger, brighter, or more isolated."
|
|
),
|
|
})
|
|
|
|
# 3. Gaze Entry Point
|
|
if gaze_seq:
|
|
first = gaze_seq[0]
|
|
x_pct = first.get("x_pct", first["x"] / w * 100 if w else 50)
|
|
y_pct = first.get("y_pct", first["y"] / h * 100 if h else 50)
|
|
|
|
if y_pct < 33:
|
|
v_zone = "top"
|
|
elif y_pct < 66:
|
|
v_zone = "middle"
|
|
else:
|
|
v_zone = "bottom"
|
|
|
|
if x_pct < 33:
|
|
h_zone = "left"
|
|
elif x_pct < 66:
|
|
h_zone = "center"
|
|
else:
|
|
h_zone = "right"
|
|
|
|
position = f"{v_zone}-{h_zone}"
|
|
|
|
insights.append({
|
|
"type": "info",
|
|
"title": f"First fixation: {position}",
|
|
"description": (
|
|
f"Viewers are predicted to look at the {position} area first "
|
|
f"({x_pct:.0f}% from left, {y_pct:.0f}% from top). "
|
|
"Place your most important message or brand element here for maximum impact."
|
|
),
|
|
})
|
|
|
|
# 4. Spatial Balance
|
|
if len(hotspots) >= 3:
|
|
quadrants = {"TL": 0, "TR": 0, "BL": 0, "BR": 0}
|
|
for hs in hotspots:
|
|
cx = hs.get("center_x", hs["x"])
|
|
cy = hs.get("center_y", hs["y"])
|
|
q_h = "L" if cx < w / 2 else "R"
|
|
q_v = "T" if cy < h / 2 else "B"
|
|
quadrants[q_v + q_h] += 1
|
|
|
|
max_q = max(quadrants.values())
|
|
max_q_name = {
|
|
"TL": "top-left", "TR": "top-right",
|
|
"BL": "bottom-left", "BR": "bottom-right",
|
|
}
|
|
dominant = [k for k, v in quadrants.items() if v == max_q]
|
|
|
|
if max_q >= 3:
|
|
zone = max_q_name[dominant[0]]
|
|
insights.append({
|
|
"type": "warning",
|
|
"title": f"Attention clusters in {zone}",
|
|
"description": (
|
|
f"{max_q} of {len(hotspots)} hotspots fall in the {zone} quadrant. "
|
|
"The opposite areas of your design may go largely unnoticed. "
|
|
"Consider rebalancing if key information is in the neglected zones."
|
|
),
|
|
})
|
|
|
|
# 5. Edge Risk
|
|
edge_threshold = 0.10 # 10% from edge
|
|
edge_hotspots = []
|
|
for hs in hotspots[:3]: # check top 3
|
|
cx = hs.get("center_x", hs["x"])
|
|
cy = hs.get("center_y", hs["y"])
|
|
near_edge = (
|
|
cx < w * edge_threshold
|
|
or cx > w * (1 - edge_threshold)
|
|
or cy < h * edge_threshold
|
|
or cy > h * (1 - edge_threshold)
|
|
)
|
|
if near_edge:
|
|
edge_hotspots.append(hs["rank"])
|
|
|
|
if edge_hotspots:
|
|
ranks = ", ".join(f"#{r}" for r in edge_hotspots)
|
|
insights.append({
|
|
"type": "warning",
|
|
"title": "Key attention near edge",
|
|
"description": (
|
|
f"Hotspot {ranks} {'is' if len(edge_hotspots) == 1 else 'are'} close to the "
|
|
"image edge. When printed or cropped, this attention area may be partially cut off. "
|
|
"Consider adding safe margins around critical content."
|
|
),
|
|
})
|
|
|
|
# 6. Attention Drop-off
|
|
if len(hotspots) >= 3:
|
|
intensities = [hs["intensity"] for hs in hotspots[:5]]
|
|
if intensities[0] > 0:
|
|
dropoff = intensities[-1] / intensities[0]
|
|
if dropoff < 0.2:
|
|
insights.append({
|
|
"type": "info",
|
|
"title": "Steep attention drop-off",
|
|
"description": (
|
|
f"Attention drops sharply from hotspot #1 ({intensities[0]:.0%}) to "
|
|
f"#{len(intensities)} ({intensities[-1]:.0%}). This means the design "
|
|
"has a very strong primary focus but secondary elements get little attention."
|
|
),
|
|
})
|
|
|
|
return insights
|