Replace entropy score with composite Design Effectiveness Score
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>
This commit is contained in:
parent
8df679c9c8
commit
c1b80eb9a7
10 changed files with 193 additions and 44 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'<font name="Montserrat-SemiBold" size="9" color="#666666">ATTENTION SCORE</font><br/><font name="Montserrat-Bold" size="28" color="#{score_color.hexval()[2:]}">{score:.0f}</font><font name="Montserrat" size="12" color="#999999"> / 100</font>'
|
||||
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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<span className="text-xs text-gray-400">/100</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-500">Attention Focus</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-500">Design Effectiveness</span>
|
||||
<span className="text-xs font-semibold" style={{ color: info.color }}>{info.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
{showTooltip && (
|
||||
<div className="absolute top-full left-0 mt-2 z-50 w-72 bg-white rounded-lg shadow-lg border border-gray-200 p-3 text-xs">
|
||||
<p className="font-semibold text-gray-800 mb-1">Attention Focus Score</p>
|
||||
<div className="absolute top-full left-0 mt-2 z-50 w-80 bg-white rounded-lg shadow-lg border border-gray-200 p-3 text-xs">
|
||||
<p className="font-semibold text-gray-800 mb-1">Design Effectiveness Score</p>
|
||||
<p className="text-gray-600 mb-2">{info.desc}</p>
|
||||
<div className="space-y-1 text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span><strong>60-100:</strong> High — clear visual hierarchy, strong focal point</span>
|
||||
<span><strong>55-100:</strong> High — clear hierarchy, dominant focal point, coherent gaze flow</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
<span><strong>30-59:</strong> Medium — multiple areas compete for attention</span>
|
||||
<span><strong>35-54:</strong> Medium — some competing elements dilute focus</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<span><strong>0-29:</strong> Low — diffuse, no dominant element</span>
|
||||
<span><strong>0-34:</strong> Low — scattered attention, no clear hierarchy</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-400 mt-2 border-t border-gray-100 pt-2">
|
||||
Higher isn't always better — it depends on your design goal. A product ad wants focused attention; an infographic spreads it intentionally.
|
||||
<p className="text-gray-400 mt-2 text-[10px]">
|
||||
Composite of peak dominance, hierarchy clarity, gaze coherence, and entropy concentration.
|
||||
</p>
|
||||
{entropyScore !== undefined && (
|
||||
<div className="mt-2 border-t border-gray-100 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">Entropy Concentration:</span>
|
||||
<span className="font-semibold text-gray-700">{entropyScore.toFixed(0)}/100</span>
|
||||
</div>
|
||||
<p className="text-gray-400 mt-1 text-[10px]">
|
||||
Raw measure of how spatially concentrated attention is. Lower values mean attention spreads across more of the image.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -235,7 +246,7 @@ export default function AnalysisView() {
|
|||
</div>
|
||||
{analysis.overall_score !== undefined && (
|
||||
<div className="mt-2">
|
||||
<ScoreCard score={analysis.overall_score} />
|
||||
<ScoreCard score={analysis.overall_score} entropyScore={analysis.entropy_score} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue