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 + Design Effectiveness {info.label}
{showTooltip && ( -
-

Attention Focus Score

+
+

Design Effectiveness Score

{info.desc}

- 60-100: High — clear visual hierarchy, strong focal point + 55-100: High — clear hierarchy, dominant focal point, coherent gaze flow
- 30-59: Medium — multiple areas compete for attention + 35-54: Medium — some competing elements dilute focus
- 0-29: Low — diffuse, no dominant element + 0-34: Low — scattered attention, no clear hierarchy
-

- 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 && ( +
+
+ Entropy Concentration: + {entropyScore.toFixed(0)}/100 +
+

+ Raw measure of how spatially concentrated attention is. Lower values mean attention spreads across more of the image. +

+
+ )}
)}
@@ -235,7 +246,7 @@ export default function AnalysisView() { {analysis.overall_score !== undefined && (
- +
)} diff --git a/frontend/src/types/analysis.ts b/frontend/src/types/analysis.ts index a6e5c52..06de5bd 100644 --- a/frontend/src/types/analysis.ts +++ b/frontend/src/types/analysis.ts @@ -43,6 +43,7 @@ export interface AnalysisDetail extends Analysis { model_used?: string; original_filename?: string; overall_score?: number; + entropy_score?: number; gaze_points?: GazePoint[]; gaze_sequence?: any[]; hotspots: Hotspot[];