totes-emosh / scratch /build_process_infographic.py
drdeception
feat: six-emotion replication challenge — totes-emosh EmotionMap build
0d27c43
Raw
History Blame Contribute Delete
13.2 kB
"""
Generate the W3L4 EmotionMap process infographic.
Wide horizontal layout, in the style of Gordon's reference image:
five numbered steps (dark circles, amber+dark caps headlines, flat
geometric icons, captions) plus a sixth amber-outlined call-out box
on the right for the Porter & Ten Brinke factor.
Outputs PNG and SVG into the repo root so both can be dropped into
EngageLMS / Word / slide decks. British English, no emojis.
Run with:
uv run python scratch/build_process_infographic.py
"""
from __future__ import annotations
from pathlib import Path
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import FancyBboxPatch, Circle, Polygon, Rectangle
REPO_ROOT = Path(__file__).resolve().parent.parent
# Palette aligned with the EngageLMS template + verdict bands
DARK = "#3C373C"
AMBER = "#FFAA2D"
GREEN = "#228B54"
ORANGE = "#C85A28"
SKIN = "#e0a78a"
GREY = "#dcdce0"
LIGHT = "#fafafa"
# ---------- Icons (each draws into the supplied axes at (cx, cy)) ------
def icon_watch(ax, cx, cy):
"""Monitor with play triangle + a small face."""
# Monitor body
ax.add_patch(FancyBboxPatch(
(cx - 0.62, cy - 0.32), 0.95, 0.65,
boxstyle="round,pad=0.02",
ec=DARK, fc="white", lw=2.2,
))
# Stand
ax.add_patch(Rectangle((cx - 0.18, cy - 0.40), 0.20, 0.08, color=DARK))
# Play triangle
ax.add_patch(Polygon(
[[cx - 0.27, cy - 0.10],
[cx - 0.27, cy + 0.20],
[cx - 0.02, cy + 0.05]],
color=AMBER,
))
# Face beside monitor (Ekman headshot stand-in)
ax.add_patch(Circle((cx + 0.55, cy + 0.02), 0.20, fc=SKIN, ec=DARK, lw=1.8))
# Eyes
ax.add_patch(Circle((cx + 0.48, cy + 0.06), 0.025, color=DARK))
ax.add_patch(Circle((cx + 0.62, cy + 0.06), 0.025, color=DARK))
def icon_phone(ax, cx, cy):
"""Phone outline with a wireframe face on the screen."""
# Phone body
ax.add_patch(FancyBboxPatch(
(cx - 0.30, cy - 0.45), 0.60, 0.95,
boxstyle="round,pad=0.04",
ec=DARK, fc=DARK, lw=1.5,
))
# Screen
ax.add_patch(Rectangle(
(cx - 0.24, cy - 0.36), 0.48, 0.78, color="white",
))
# Speaker dot
ax.add_patch(Circle((cx, cy + 0.46), 0.018, color="white"))
# Wireframe face on screen
ax.add_patch(Circle((cx, cy + 0.04), 0.18, ec=DARK, fc="none", lw=1.4))
# Eyes + nose + mouth (geometric)
ax.plot([cx - 0.10, cx - 0.04], [cy + 0.10, cy + 0.10], color=DARK, lw=1.4)
ax.plot([cx + 0.04, cx + 0.10], [cy + 0.10, cy + 0.10], color=DARK, lw=1.4)
ax.plot([cx, cx], [cy + 0.05, cy - 0.04], color=DARK, lw=1.4)
ax.plot([cx - 0.07, cx + 0.07], [cy - 0.10, cy - 0.10], color=DARK, lw=1.4)
# Mesh hint
ax.plot([cx - 0.18, cx + 0.18], [cy + 0.04, cy + 0.04],
color=DARK, lw=0.6, alpha=0.4)
ax.plot([cx, cx], [cy + 0.22, cy - 0.14],
color=DARK, lw=0.6, alpha=0.4)
def icon_webcam(ax, cx, cy):
"""Webcam lens with a face inside."""
# Outer lens
ax.add_patch(Circle((cx, cy), 0.45, fc=DARK, ec=DARK, lw=1))
# Mid ring
ax.add_patch(Circle((cx, cy), 0.38, fc="white", ec=DARK, lw=1))
# Face inside
ax.add_patch(Circle((cx, cy - 0.02), 0.22, fc=SKIN, ec=DARK, lw=1.5))
# Hair cap
ax.add_patch(mpatches.Wedge((cx, cy - 0.02), 0.22, 30, 150,
fc=DARK, ec=DARK))
# Eyes
ax.add_patch(Circle((cx - 0.07, cy - 0.02), 0.022, color=DARK))
ax.add_patch(Circle((cx + 0.07, cy - 0.02), 0.022, color=DARK))
# Mouth (small line)
ax.plot([cx - 0.05, cx + 0.05], [cy - 0.12, cy - 0.12], color=DARK, lw=1.4)
# Tripod hint
ax.add_patch(Rectangle((cx - 0.06, cy - 0.55), 0.12, 0.10, color=DARK))
def icon_verdict(ax, cx, cy):
"""Monitor showing the AGREES / DISAGREES bars."""
# Monitor body
ax.add_patch(FancyBboxPatch(
(cx - 0.62, cy - 0.32), 1.25, 0.70,
boxstyle="round,pad=0.02",
ec=DARK, fc="white", lw=2.2,
))
# Stand
ax.add_patch(Rectangle((cx - 0.10, cy - 0.40), 0.20, 0.08, color=DARK))
# AGREES bar
ax.text(cx - 0.52, cy + 0.16, "✓", color=GREEN, fontsize=12, weight="bold",
va="center")
ax.text(cx - 0.40, cy + 0.16, "AGREES", color=DARK, fontsize=8,
weight="bold", va="center")
ax.add_patch(Rectangle((cx - 0.05, cy + 0.10), 0.55, 0.12,
color=GREEN))
ax.add_patch(Rectangle((cx + 0.50, cy + 0.10), 0.08, 0.12,
color=GREY))
# DISAGREES bar
ax.text(cx - 0.52, cy - 0.10, "✗", color=ORANGE, fontsize=12, weight="bold",
va="center")
ax.text(cx - 0.40, cy - 0.10, "DISAGREES", color=DARK, fontsize=8,
weight="bold", va="center")
ax.add_patch(Rectangle((cx - 0.05, cy - 0.16), 0.25, 0.12,
color=ORANGE))
ax.add_patch(Rectangle((cx + 0.20, cy - 0.16), 0.38, 0.12,
color=GREY))
def icon_pdfs(ax, cx, cy):
"""Two stacked PDF pages — one labelled FACE, one WIREFRAME."""
# Face page
page_w, page_h = 0.50, 0.70
fx, fy = cx - 0.36, cy - 0.30
ax.add_patch(Rectangle((fx, fy), page_w, page_h,
fc="white", ec=DARK, lw=1.8))
# PDF tag
ax.add_patch(Rectangle((fx, fy + page_h - 0.16), 0.20, 0.16, color=ORANGE))
ax.text(fx + 0.10, fy + page_h - 0.08, "PDF",
color="white", fontsize=7, weight="bold",
ha="center", va="center")
# Face on page
ax.add_patch(Circle((fx + page_w/2, fy + 0.30), 0.13,
fc=SKIN, ec=DARK, lw=1))
ax.add_patch(Circle((fx + page_w/2 - 0.04, fy + 0.32), 0.012, color=DARK))
ax.add_patch(Circle((fx + page_w/2 + 0.04, fy + 0.32), 0.012, color=DARK))
ax.text(fx + page_w/2, fy - 0.05, "FACE",
color=DARK, fontsize=8, weight="bold", ha="center")
# Wireframe page
wx, wy = cx + 0.18, cy - 0.30
ax.add_patch(Rectangle((wx, wy), page_w, page_h,
fc="white", ec=DARK, lw=1.8))
ax.add_patch(Rectangle((wx, wy + page_h - 0.16), 0.20, 0.16, color=ORANGE))
ax.text(wx + 0.10, wy + page_h - 0.08, "PDF",
color="white", fontsize=7, weight="bold",
ha="center", va="center")
# Wireframe face — circle + crosshairs
wcx, wcy = wx + page_w/2, wy + 0.30
ax.add_patch(Circle((wcx, wcy), 0.13, fc="none", ec=DARK, lw=1.2))
ax.plot([wcx - 0.08, wcx - 0.03], [wcy + 0.04, wcy + 0.04],
color=DARK, lw=1)
ax.plot([wcx + 0.03, wcx + 0.08], [wcy + 0.04, wcy + 0.04],
color=DARK, lw=1)
ax.plot([wcx, wcx], [wcy + 0.02, wcy - 0.04], color=DARK, lw=1)
ax.plot([wcx - 0.05, wcx + 0.05], [wcy - 0.07, wcy - 0.07],
color=DARK, lw=1)
ax.plot([wcx - 0.13, wcx + 0.13], [wcy, wcy],
color=DARK, lw=0.5, alpha=0.4)
ax.text(wx + page_w/2, wy - 0.05, "WIREFRAME",
color=DARK, fontsize=7, weight="bold", ha="center")
# ---------- Sidebar face icons -----------------------------------------
def icon_smiley(ax, cx, cy, r=0.22):
ax.add_patch(Circle((cx, cy), r, fc=AMBER, ec=DARK, lw=1.5))
ax.add_patch(Circle((cx - r * 0.35, cy + r * 0.18), r * 0.10, color=DARK))
ax.add_patch(Circle((cx + r * 0.35, cy + r * 0.18), r * 0.10, color=DARK))
# Smile arc — approximate with two short lines
ax.plot([cx - r * 0.45, cx, cx + r * 0.45],
[cy - r * 0.25, cy - r * 0.45, cy - r * 0.25],
color=DARK, lw=1.6, solid_capstyle="round")
def icon_wireframe_face(ax, cx, cy, r=0.22):
ax.add_patch(Circle((cx, cy), r, fc="none", ec=DARK, lw=1.5))
# Brows
ax.plot([cx - r * 0.55, cx - r * 0.20], [cy + r * 0.40, cy + r * 0.30],
color=DARK, lw=1)
ax.plot([cx + r * 0.20, cx + r * 0.55], [cy + r * 0.30, cy + r * 0.40],
color=DARK, lw=1)
# Eyes
ax.plot([cx - r * 0.50, cx - r * 0.20], [cy + r * 0.05, cy + r * 0.05],
color=DARK, lw=1)
ax.plot([cx + r * 0.20, cx + r * 0.50], [cy + r * 0.05, cy + r * 0.05],
color=DARK, lw=1)
# Nose
ax.plot([cx, cx], [cy, cy - r * 0.30], color=DARK, lw=1)
# Mouth — straight (uncertain)
ax.plot([cx - r * 0.30, cx + r * 0.30],
[cy - r * 0.55, cy - r * 0.55], color=DARK, lw=1.2)
# ---------- Build the figure -------------------------------------------
STEPS = [
{
"num": "1",
"verb": "WATCH THE",
"rest": "FOUNDATION",
"icon": icon_watch,
"body": (
"A short video on FACS\n"
"and why facial lie-\n"
"detection is barely\n"
"above chance."
),
},
{
"num": "2",
"verb": "OPEN THE",
"rest": "APP",
"icon": icon_phone,
"body": (
"One challenge across\n"
"six slots: happy, sad,\n"
"fear, disgust, anger,\n"
"and surprise."
),
},
{
"num": "3",
"verb": "POSE &",
"rest": "SUBMIT",
"icon": icon_webcam,
"body": (
"For each slot capture\n"
"your webcam image,\n"
"pick the emotion, and\n"
"submit."
),
},
{
"num": "4",
"verb": "READ THE",
"rest": "VERDICT",
"icon": icon_verdict,
"body": (
"The classifier's bar\n"
"chart shows whether it\n"
"AGREES or DISAGREES\n"
"with you."
),
},
{
"num": "5",
"verb": "DOWNLOAD YOUR",
"rest": "EMOTIONMAP",
"icon": icon_pdfs,
"body": (
"Export the six-pose\n"
"PDF in FACE format,\n"
"or WIREFRAME for a\n"
"face-free version."
),
},
]
def build():
fig_w, fig_h = 17.0, 6.2
fig, ax = plt.subplots(figsize=(fig_w, fig_h))
ax.set_xlim(0, fig_w)
ax.set_ylim(0, fig_h)
ax.set_aspect("equal")
ax.axis("off")
fig.patch.set_facecolor("white")
# 5 step columns + 6th sidebar.
step_w = 2.55
margin_x = 0.35
base_y = 5.55 # number circle centre
icon_y = 3.25
body_y = 1.85
for i, step in enumerate(STEPS):
cx = margin_x + step_w / 2 + i * step_w
# Number circle
ax.add_patch(Circle((cx, base_y), 0.48, fc=DARK, ec=DARK))
ax.text(cx, base_y, step["num"], color="white",
fontsize=28, weight="bold", ha="center", va="center")
# Headline — two-line: amber verb, then dark rest
ax.text(cx, base_y - 0.88, step["verb"],
color=AMBER, fontsize=15, weight="bold", ha="center")
ax.text(cx, base_y - 1.22, step["rest"],
color=DARK, fontsize=15, weight="bold", ha="center")
# Icon
step["icon"](ax, cx, icon_y)
# Body
ax.text(cx, body_y, step["body"],
color=DARK, fontsize=10, ha="center", va="top",
linespacing=1.5)
# ---- Sidebar (Porter & Ten Brinke factor) ----
sb_left = margin_x + 5 * step_w + 0.10
sb_w = fig_w - sb_left - margin_x
sb_bottom = 0.70
sb_top = 5.95
ax.add_patch(FancyBboxPatch(
(sb_left, sb_bottom), sb_w, sb_top - sb_bottom,
boxstyle="round,pad=0.05",
ec=AMBER, fc="white", lw=2.6,
))
sb_cx = sb_left + sb_w / 2
ax.text(sb_cx, sb_top - 0.55, "THE PORTER",
color=DARK, fontsize=14, weight="bold", ha="center")
ax.text(sb_cx, sb_top - 0.90, "& TEN BRINKE",
color=DARK, fontsize=14, weight="bold", ha="center")
ax.text(sb_cx, sb_top - 1.25, "FACTOR",
color=DARK, fontsize=14, weight="bold", ha="center")
# Divider
ax.plot([sb_left + 0.35, sb_left + sb_w - 0.35],
[sb_top - 1.55, sb_top - 1.55],
color=AMBER, lw=1.4)
# Body
ax.text(sb_cx, sb_top - 1.90,
"Research shows that\n"
"happiness is easier to\n"
"fake than negative\n"
"emotions. Does your\n"
"EmotionMap match?",
color=DARK, fontsize=10, ha="center", va="top",
linespacing=1.5)
# Faces — smiley + wireframe with question marks
face_y = sb_bottom + 0.75
icon_smiley(ax, sb_cx - 0.55, face_y, r=0.32)
icon_wireframe_face(ax, sb_cx + 0.50, face_y, r=0.32)
ax.text(sb_cx - 0.13, face_y + 0.15, "?",
color=DARK, fontsize=16, weight="bold")
ax.text(sb_cx + 0.92, face_y + 0.15, "?",
color=DARK, fontsize=16, weight="bold")
# ---- Footer attribution ----
fig.text(
0.5, 0.015,
"Created by Dr. Gordon Wright — A LittleMonkeyLab caper. "
"Part of the Goldsmiths MSc in Psychology, Week 3 Part 4.",
ha="center", fontsize=8, color="#888", style="italic",
)
out_png = REPO_ROOT / "process-infographic.png"
out_svg = REPO_ROOT / "process-infographic.svg"
fig.savefig(out_png, dpi=200, bbox_inches="tight", facecolor="white")
fig.savefig(out_svg, bbox_inches="tight", facecolor="white")
plt.close(fig)
print(f"Wrote {out_png}")
print(f"Wrote {out_svg}")
if __name__ == "__main__":
build()