diff --git a/backend/app/api/endpoints/analysis.py b/backend/app/api/endpoints/analysis.py
index 2c212cd..afa27c1 100644
--- a/backend/app/api/endpoints/analysis.py
+++ b/backend/app/api/endpoints/analysis.py
@@ -167,22 +167,14 @@ def run_analysis_pipeline(analysis_id: str, image_data: bytes, model_name: str):
# 8. Extract gaze sequence
gaze_seq = extract_gaze_sequence(saliency_full, num_fixations=5)
- # 9. Compute overall attention score
- # Normalize saliency to a proper probability distribution
- sal_sum = saliency_full.sum()
- if sal_sum > 0:
- prob_dist = saliency_full / sal_sum
- prob_dist = prob_dist[prob_dist > 0] # remove zeros for log
- entropy = -np.sum(prob_dist * np.log2(prob_dist))
- max_entropy = np.log2(saliency_full.size)
- concentration = (1 - entropy / max_entropy) * 100
- overall_score = round(float(np.clip(concentration, 0, 100)), 1)
- else:
- overall_score = 0.0
-
- # 10. Extract hotspots
+ # 9. Extract hotspots (needed for composite score)
hotspots = _extract_hotspots(saliency_full, num_hotspots=5)
+ # 10. Compute composite design effectiveness score
+ overall_score, entropy_score = _compute_design_score(
+ saliency_full, hotspots, gaze_seq
+ )
+
# 11. Generate gaze sequence image
gaze_img = _draw_gaze_sequence(image, gaze_seq)
buf = io.BytesIO()
@@ -195,8 +187,9 @@ def run_analysis_pipeline(analysis_id: str, image_data: bytes, model_name: str):
analysis.gaze_sequence = gaze_seq
analysis.hotspots = hotspots
analysis.overall_score = overall_score
+ analysis.entropy_score = entropy_score
db.commit()
- logger.info(f"Analysis {analysis_id} completed (score={overall_score})")
+ logger.info(f"Analysis {analysis_id} completed (score={overall_score}, entropy={entropy_score})")
except Exception as e:
logger.error(f"Analysis {analysis_id} failed: {e}", exc_info=True)
@@ -210,6 +203,118 @@ def run_analysis_pipeline(analysis_id: str, image_data: bytes, model_name: str):
pass
+def _compute_design_score(saliency_full, hotspots, gaze_seq):
+ """Compute composite Design Effectiveness Score (0-100) and raw entropy score.
+
+ Four components:
+ A. Peak Dominance (0.30) — how much stronger the top hotspot is vs rest
+ B. Hierarchy Clarity (0.25) — whether intensities decrease monotonically
+ C. Gaze Coherence (0.25) — whether gaze follows a smooth spatial path
+ D. Entropy Concentration (0.20) — Shannon entropy, softened with sqrt
+ """
+ import numpy as np
+
+ # --- D. Entropy Concentration (also gives us the raw entropy_score) ---
+ sal_sum = saliency_full.sum()
+ if sal_sum > 0:
+ prob_dist = saliency_full / sal_sum
+ prob_dist = prob_dist[prob_dist > 0]
+ entropy = -np.sum(prob_dist * np.log2(prob_dist))
+ max_entropy = np.log2(saliency_full.size)
+ raw_concentration = (1 - entropy / max_entropy) * 100
+ else:
+ raw_concentration = 0.0
+
+ entropy_score = round(float(np.clip(raw_concentration, 0, 100)), 1)
+ entropy_adjusted = float(np.sqrt(max(raw_concentration, 0) / 100)) * 100
+
+ # --- A. Peak Dominance ---
+ if len(hotspots) >= 2:
+ top_intensity = hotspots[0]["intensity"]
+ rest_intensities = [h["intensity"] for h in hotspots[1:]]
+ rest_mean = float(np.mean(rest_intensities)) if rest_intensities else 0.0
+ if rest_mean > 0:
+ dominance_ratio = top_intensity / rest_mean
+ else:
+ dominance_ratio = 10.0
+ peak_dominance = float(100 * (1 - np.exp(-0.5 * dominance_ratio)))
+ elif len(hotspots) == 1:
+ peak_dominance = 95.0 # single hotspot = very dominant
+ else:
+ peak_dominance = 50.0 # no hotspots, neutral
+
+ # --- B. Hierarchy Clarity ---
+ intensities = [h["intensity"] for h in hotspots]
+ n = len(intensities)
+ if n >= 2:
+ concordant = 0
+ total_pairs = 0
+ for i in range(n):
+ for j in range(i + 1, n):
+ total_pairs += 1
+ if intensities[i] > intensities[j]:
+ concordant += 1
+ monotonicity = concordant / total_pairs if total_pairs > 0 else 1.0
+
+ if intensities[0] > 0:
+ drop_ratio = 1 - (intensities[-1] / intensities[0])
+ else:
+ drop_ratio = 0.0
+
+ hierarchy_clarity = float((0.6 * monotonicity + 0.4 * drop_ratio) * 100)
+ else:
+ hierarchy_clarity = 70.0 # neutral default
+
+ # --- C. Gaze Coherence ---
+ gaze_points = [(g["x"], g["y"]) for g in gaze_seq] if gaze_seq else []
+ ng = len(gaze_points)
+ if ng >= 3:
+ # Angle smoothness
+ angles = []
+ for i in range(ng - 2):
+ ax = gaze_points[i + 1][0] - gaze_points[i][0]
+ ay = gaze_points[i + 1][1] - gaze_points[i][1]
+ bx = gaze_points[i + 2][0] - gaze_points[i + 1][0]
+ by = gaze_points[i + 2][1] - gaze_points[i + 1][1]
+ mag_a = np.sqrt(ax ** 2 + ay ** 2)
+ mag_b = np.sqrt(bx ** 2 + by ** 2)
+ if mag_a > 0 and mag_b > 0:
+ cos_angle = np.clip((ax * bx + ay * by) / (mag_a * mag_b), -1, 1)
+ angle = float(np.degrees(np.arccos(cos_angle)))
+ angles.append(angle)
+
+ if angles:
+ avg_angle = float(np.mean(angles))
+ angle_smoothness = max(0, 100 - (avg_angle / 180) * 100)
+ else:
+ angle_smoothness = 70.0
+
+ # Path efficiency
+ total_path = sum(
+ np.sqrt((gaze_points[i + 1][0] - gaze_points[i][0]) ** 2 +
+ (gaze_points[i + 1][1] - gaze_points[i][1]) ** 2)
+ for i in range(ng - 1)
+ )
+ direct_dist = np.sqrt((gaze_points[-1][0] - gaze_points[0][0]) ** 2 +
+ (gaze_points[-1][1] - gaze_points[0][1]) ** 2)
+ path_efficiency = float(direct_dist / total_path) if total_path > 0 else 1.0
+
+ gaze_coherence = 0.7 * angle_smoothness + 0.3 * (path_efficiency * 100)
+ else:
+ gaze_coherence = 70.0 # neutral default for too few points
+
+ # --- Composite ---
+ composite = (
+ 0.30 * peak_dominance
+ + 0.25 * hierarchy_clarity
+ + 0.25 * gaze_coherence
+ + 0.20 * entropy_adjusted
+ )
+ overall_score = round(float(np.clip(composite, 0, 100)), 1)
+
+ return overall_score, entropy_score
+
+
def _extract_hotspots(saliency, num_hotspots=5):
import numpy as np
from scipy.ndimage import gaussian_filter
@@ -317,6 +422,7 @@ async def get_analysis(
gaze_sequence=analysis.gaze_sequence,
hotspots=analysis.hotspots,
insights=insights,
+ entropy_score=analysis.entropy_score,
ai_insights=analysis.ai_insights,
ai_score=analysis.ai_score,
ai_score_reason=analysis.ai_score_reason,
@@ -404,6 +510,7 @@ async def generate_ai_insights_endpoint(
metadata = {
"overall_score": analysis.overall_score,
+ "entropy_score": analysis.entropy_score,
"hotspots": analysis.hotspots or [],
"gaze_sequence": analysis.gaze_sequence or [],
"image_width": analysis.image_width,
diff --git a/backend/app/db/migrations/versions/2f6f70f606a1_initial_schema.py b/backend/app/db/migrations/versions/2f6f70f606a1_initial_schema.py
index e237b69..bb8943e 100644
--- a/backend/app/db/migrations/versions/2f6f70f606a1_initial_schema.py
+++ b/backend/app/db/migrations/versions/2f6f70f606a1_initial_schema.py
@@ -45,6 +45,7 @@ def upgrade() -> None:
sa.Column('gaze_sequence', sa.JSON(), nullable=True),
sa.Column('hotspots', sa.JSON(), nullable=True),
sa.Column('overall_score', sa.Float(), nullable=True),
+ sa.Column('entropy_score', sa.Float(), nullable=True),
sa.Column('ai_insights', sa.JSON(), nullable=True),
sa.Column('ai_score', sa.Integer(), nullable=True),
sa.Column('ai_score_reason', sa.String(length=500), nullable=True),
diff --git a/backend/app/main.py b/backend/app/main.py
index b197d9e..3edb837 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -12,6 +12,19 @@ logger = logging.getLogger("olivas")
@asynccontextmanager
async def lifespan(app: FastAPI):
+ # Startup: ensure DB tables exist
+ from app.db.session import engine
+ from app.models.base import Base
+ # Import all models so Base.metadata knows about them
+ import app.models.analysis # noqa: F401
+ import app.models.project # noqa: F401
+ import app.models.aoi # noqa: F401
+ import app.models.comparison # noqa: F401
+
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ logger.info("Database tables verified")
+
# Startup: load ML models
logger.info(f"Starting OliVAS backend (device={settings.device})")
from app.services.saliency.model_manager import model_manager
diff --git a/backend/app/models/analysis.py b/backend/app/models/analysis.py
index 1bd3f6d..0bdccd0 100644
--- a/backend/app/models/analysis.py
+++ b/backend/app/models/analysis.py
@@ -28,6 +28,7 @@ class Analysis(Base):
gaze_sequence: Mapped[dict | None] = mapped_column(JSON, nullable=True)
hotspots: Mapped[dict | None] = mapped_column(JSON, nullable=True)
overall_score: Mapped[float | None] = mapped_column(Float, nullable=True)
+ entropy_score: Mapped[float | None] = mapped_column(Float, nullable=True)
ai_insights: Mapped[dict | None] = mapped_column(JSON, nullable=True)
ai_score: Mapped[int | None] = mapped_column(Integer, nullable=True)
ai_score_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
diff --git a/backend/app/schemas/analysis.py b/backend/app/schemas/analysis.py
index cbfff67..a62fa00 100644
--- a/backend/app/schemas/analysis.py
+++ b/backend/app/schemas/analysis.py
@@ -12,6 +12,7 @@ class AnalysisSummary(BaseModel):
image_width: int
image_height: int
overall_score: float | None = None
+ entropy_score: float | None = None
created_at: datetime
model_config = {"from_attributes": True}
diff --git a/backend/app/services/ai_insights.py b/backend/app/services/ai_insights.py
index db70dff..fe9f4b0 100644
--- a/backend/app/services/ai_insights.py
+++ b/backend/app/services/ai_insights.py
@@ -53,20 +53,23 @@ def generate_ai_insights(
heatmap_b64 = base64.standard_b64encode(heatmap_png).decode("utf-8")
score = analysis_metadata.get("overall_score", 0)
+ entropy = analysis_metadata.get("entropy_score", None)
hotspots = analysis_metadata.get("hotspots", [])
gaze_sequence = analysis_metadata.get("gaze_sequence", [])
width = analysis_metadata.get("image_width", 0)
height = analysis_metadata.get("image_height", 0)
+ entropy_line = f"\n- Entropy Concentration: {entropy}/100 (raw measure of how spatially concentrated attention is)" if entropy is not None else ""
+
metrics_text = f"""Analysis Metrics:
- Image dimensions: {width} x {height} pixels
-- Overall Attention Score: {score}/100 (higher = more concentrated attention)
+- Design Effectiveness Score: {score}/100 (composite: peak dominance, hierarchy clarity, gaze coherence, entropy){entropy_line}
- Number of hotspots detected: {len(hotspots)}
- Top hotspot location: ({hotspots[0]['center_x']}, {hotspots[0]['center_y']}) with intensity {hotspots[0]['intensity']:.4f}
- First gaze fixation point: ({gaze_sequence[0]['x']}, {gaze_sequence[0]['y']}) — this is where viewers look first
""" if hotspots and gaze_sequence else f"""Analysis Metrics:
- Image dimensions: {width} x {height} pixels
-- Overall Attention Score: {score}/100
+- Design Effectiveness Score: {score}/100
"""
prompt = f"""You are a visual attention and design expert analyzing an image using saliency prediction data. You have been given:
diff --git a/backend/app/services/insights.py b/backend/app/services/insights.py
index f619cd2..d183af1 100644
--- a/backend/app/services/insights.py
+++ b/backend/app/services/insights.py
@@ -26,33 +26,34 @@ def generate_insights(analysis) -> list[dict]:
if score is None or not hotspots:
return insights
- # 1. Attention Concentration
- if score >= 60:
+ # 1. Design Effectiveness
+ if score >= 55:
insights.append({
"type": "success",
- "title": "Strong focal point",
+ "title": "Strong design effectiveness",
"description": (
- f"Attention is highly concentrated (score {score:.0f}/100). "
- "Most viewers will fixate on the same areas — your design has a clear visual hierarchy."
+ 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 >= 30:
+ elif score >= 35:
insights.append({
"type": "info",
- "title": "Moderate attention spread",
+ "title": "Moderate design effectiveness",
"description": (
- f"Attention is moderately distributed (score {score:.0f}/100). "
+ 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."
+ "is prominent enough to stand out from competing elements."
),
})
else:
insights.append({
"type": "warning",
- "title": "Diffuse attention",
+ "title": "Low design effectiveness",
"description": (
- f"Attention is widely spread (score {score:.0f}/100). "
- "No single element dominates — viewers may struggle to find your key message. "
+ 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."
),
})
diff --git a/backend/app/services/report_generator.py b/backend/app/services/report_generator.py
index ef2e02d..0a4b200 100644
--- a/backend/app/services/report_generator.py
+++ b/backend/app/services/report_generator.py
@@ -250,8 +250,18 @@ def generate_report(
# Score badge
if analysis.overall_score is not None:
score = analysis.overall_score
- score_color = GREEN if score >= 60 else (AMBER if score >= 30 else colors.HexColor("#ef4444"))
- score_text = f'ATTENTION SCORE
{score:.0f} / 100'
+ score_color = GREEN if score >= 55 else (AMBER if score >= 35 else colors.HexColor("#ef4444"))
+ score_text = (
+ f'DESIGN EFFECTIVENESS
'
+ f'{score:.0f}'
+ f' / 100'
+ )
+ entropy = getattr(analysis, "entropy_score", None)
+ if entropy is not None:
+ score_text += (
+ f'
'
+ f'Entropy concentration: {entropy:.0f}/100'
+ )
elements.append(Paragraph(score_text, styles["body"]))
elements.append(Spacer(1, 10))
diff --git a/frontend/src/pages/AnalysisView.tsx b/frontend/src/pages/AnalysisView.tsx
index ed30893..b5fba3e 100644
--- a/frontend/src/pages/AnalysisView.tsx
+++ b/frontend/src/pages/AnalysisView.tsx
@@ -6,12 +6,12 @@ import { getAnalysisImageUrl, checkAIInsightsAvailable, generateAIInsights } fro
import type { Insight } from "../types/analysis";
function getScoreInfo(score: number) {
- if (score >= 60) return { label: "High", color: "#16a34a", bg: "#f0fdf4", desc: "Strong focal point — viewers will focus on the same areas" };
- if (score >= 30) return { label: "Medium", color: "#d97706", bg: "#fffbeb", desc: "Moderate spread — several areas compete for attention" };
- return { label: "Low", color: "#dc2626", bg: "#fef2f2", desc: "Diffuse attention — no clear focal point" };
+ if (score >= 55) return { label: "High", color: "#16a34a", bg: "#f0fdf4", desc: "Strong design effectiveness — clear visual hierarchy with intentional attention flow" };
+ if (score >= 35) return { label: "Medium", color: "#d97706", bg: "#fffbeb", desc: "Moderate effectiveness — some competing elements dilute the primary focus" };
+ return { label: "Low", color: "#dc2626", bg: "#fef2f2", desc: "Low effectiveness — attention is scattered without clear hierarchy" };
}
-function ScoreCard({ score }: { score: number }) {
+function ScoreCard({ score, entropyScore }: { score: number; entropyScore?: number }) {
const info = getScoreInfo(score);
const [showTooltip, setShowTooltip] = useState(false);
@@ -29,31 +29,42 @@ function ScoreCard({ score }: { score: number }) {
/100
Attention Focus Score
+Design Effectiveness Score
{info.desc}
- Higher isn't always better — it depends on your design goal. A product ad wants focused attention; an infographic spreads it intentionally. +
+ Composite of peak dominance, hierarchy clarity, gaze coherence, and entropy concentration.
+ {entropyScore !== undefined && ( ++ Raw measure of how spatially concentrated attention is. Lower values mean attention spreads across more of the image. +
+