Generate a animated video clip showing sites of compression of radial nerve
radial nerve compression sites anatomy spiral groove posterior interosseous nerve
mkdir -p /home/daytona/workspace/radial-nerve-animation && ls /home/daytona/workspace/radial-nerve-animation/
~/radial-nerve-animation/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Radial Nerve Compression Sites – Animated Anatomy</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0f1e;
font-family: 'Segoe UI', Arial, sans-serif;
color: #e8f4fd;
overflow: hidden;
width: 900px;
height: 620px;
}
canvas { display: block; }
#ui {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 10px;
align-items: center;
}
button {
background: #1a3a5c;
color: #7dd6f8;
border: 1px solid #2a6090;
border-radius: 6px;
padding: 6px 16px;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
}
button:hover { background: #22507a; }
#siteLabel {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
background: rgba(10,20,50,0.88);
border: 1px solid #2a6090;
border-radius: 8px;
padding: 8px 22px;
font-size: 15px;
font-weight: 600;
color: #7dd6f8;
letter-spacing: 0.5px;
text-align: center;
min-width: 340px;
pointer-events: none;
}
#stepCounter {
position: absolute;
top: 52px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: #5a8ab0;
pointer-events: none;
}
</style>
</head>
<body>
<canvas id="c" width="900" height="620"></canvas>
<div id="siteLabel">Radial Nerve Compression Sites</div>
<div id="stepCounter">Site 1 of 5</div>
<div id="ui">
<button id="prevBtn">◀ Prev</button>
<button id="playBtn">⏸ Pause</button>
<button id="nextBtn">Next ▶</button>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = 900, H = 620;
// ──────────────────────────────────────────────
// ANATOMY DATA
// ──────────────────────────────────────────────
// Arm silhouette (right arm, lateral view, hanging down)
// Defined as bezier path points for the arm outline
// Coordinate system: x right, y down. Arm occupies roughly x: 380-520, y: 60-580
const ARM = {
// Upper arm outline (lateral side)
lateralOuter: [
{x:430, y:80}, {x:450, y:100}, {x:465, y:200}, {x:470, y:310}, {x:468, y:370}
],
// Medial side
medialOuter: [
{x:390, y:80}, {x:375, y:100}, {x:368, y:200}, {x:368, y:310}, {x:370, y:370}
],
// Forearm (lateral)
forearmLateral: [
{x:468, y:370}, {x:472, y:420}, {x:475, y:490}, {x:470, y:560}
],
// Forearm (medial)
forearmMedial: [
{x:370, y:370}, {x:366, y:420}, {x:362, y:490}, {x:365, y:560}
]
};
// Humerus bone path
const HUMERUS = [
{x:415, y:100}, {x:428, y:105}, {x:435, y:200}, {x:436, y:320},
{x:432, y:370}, {x:425, y:390}, {x:413, y:390}, {x:408, y:370},
{x:406, y:320}, {x:408, y:200}, {x:412, y:105}
];
// Radius & Ulna simplified
const RADIUS = [
{x:445, y:400}, {x:448, y:450}, {x:452, y:540}, {x:455, y:570}
];
const ULNA = [
{x:405, y:400}, {x:402, y:450}, {x:396, y:540}, {x:394, y:570}
];
// Radial nerve path points (key waypoints along the nerve)
// Site index corresponds to compression points
const NERVE_PATH = [
{x:408, y:90}, // 0 – origin from posterior cord (axilla region)
{x:395, y:120}, // 1 – axilla / triangular space
{x:390, y:170}, // 2 – upper arm entering radial groove
{x:418, y:240}, // 3 – spiral/radial groove (winds around humerus)
{x:450, y:290}, // 4 – lateral side after groove
{x:460, y:340}, // 5 – lateral intermuscular septum
{x:462, y:370}, // 6 – elbow region (anterior to lateral epicondyle)
{x:465, y:395}, // 7 – radial tunnel entry
{x:458, y:420}, // 8 – arcade of Frohse / supinator entry (PIN)
{x:450, y:465}, // 9 – PIN in supinator canal
{x:442, y:510}, // 10 – distal forearm
{x:438, y:555} // 11 – terminal
];
// Compression sites definition
const SITES = [
{
id: 0,
name: "Site 1 – Axilla (Crutch Palsy)",
subtitle: "Triangular Space · Posterior Wall of Axilla",
ptIdx: 1,
color: "#ff6b6b",
glow: "#ff3333",
info: [
"Cause: Prolonged crutch use, axillary mass",
"Level: Posterior cord → radial nerve origin",
"Effect: Wrist drop + triceps weakness",
"Sensory: Dorsum of hand affected"
],
waveY: 120, waveX: 390,
arrowFrom: {x:330, y:135}, arrowTo: {x:385, y:122}
},
{
id: 1,
name: "Site 2 – Spiral (Radial) Groove",
subtitle: "Mid-Humerus · Saturday Night Palsy · Park Bench Palsy",
ptIdx: 3,
color: "#ffb347",
glow: "#ff8c00",
info: [
"Cause: Humeral shaft fracture, prolonged arm compression",
"'Saturday night palsy' – compression during deep sleep",
"Effect: Wrist drop WITHOUT triceps weakness",
"Sensory: Disturbances on dorsoradial hand"
],
waveY: 240, waveX: 420,
arrowFrom: {x:330, y:255}, arrowTo: {x:412, y:242}
},
{
id: 2,
name: "Site 3 – Lateral Intermuscular Septum",
subtitle: "Proximal to Elbow · Mid-level Lesion",
ptIdx: 5,
color: "#ffd700",
glow: "#ccaa00",
info: [
"Cause: Bridging vessels, connective-tissue septa",
"Chronic compression as nerve pierces septum",
"Effect: Wrist drop with sensory disturbances",
"Level: Between brachialis & brachioradialis"
],
waveY: 340, waveX: 460,
arrowFrom: {x:545, y:355}, arrowTo: {x:466, y:342}
},
{
id: 3,
name: "Site 4 – Radial Tunnel / Arcade of Frohse",
subtitle: "Entry of PIN into Supinator (Posterior Interosseous Nerve)",
ptIdx: 8,
color: "#48d1cc",
glow: "#00ced1",
info: [
"PIN = Deep branch of radial nerve",
"Arcade of Frohse: sharp fibrous arch of supinator",
"Effect: No wrist drop, no hand sensory loss",
"Motor: Finger extensors / thumb extensors weak",
"Pain: Radial tunnel syndrome (lateral elbow ache)"
],
waveY: 420, waveX: 458,
arrowFrom: {x:545, y:435}, arrowTo: {x:464, y:422}
},
{
id: 4,
name: "Site 5 – Supinator Canal (PIN Syndrome)",
subtitle: "Deep Branch in Supinator Muscle",
ptIdx: 9,
color: "#98fb98",
glow: "#32cd32",
info: [
"Cause: Fracture/dislocation of radial head or radius",
"Compression of deep branch within supinator canal",
"Effect: No wrist drop, NO sensory loss",
"Weakness: EPL, EPB, APL, EDC, EI, ECU"
],
waveY: 465, waveX: 450,
arrowFrom: {x:545, y:480}, arrowTo: {x:456, y:467}
}
];
// ──────────────────────────────────────────────
// STATE
// ──────────────────────────────────────────────
let currentSite = 0;
let animFrame = 0;
let playing = true;
let siteTimer = 0;
const SITE_DURATION = 280; // frames per site
let pulsePhase = 0;
let revealProgress = 0; // 0→1 for site card entry
// ──────────────────────────────────────────────
// DRAWING HELPERS
// ──────────────────────────────────────────────
function smoothPath(pts, close=false) {
if (pts.length < 2) return;
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length - 1; i++) {
const mx = (pts[i].x + pts[i+1].x)/2;
const my = (pts[i].y + pts[i+1].y)/2;
ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my);
}
const last = pts[pts.length-1];
ctx.lineTo(last.x, last.y);
if (close) ctx.closePath();
}
function drawArm() {
// Arm silhouette fill
ctx.save();
ctx.beginPath();
// Lateral outline upper arm
ctx.moveTo(430, 80);
ctx.bezierCurveTo(455,95, 472,180, 470,370);
// Forearm lateral
ctx.bezierCurveTo(473,420, 477,500, 472,562);
// Wrist / hand stub
ctx.lineTo(358,562);
// Forearm medial
ctx.bezierCurveTo(362,500, 364,420, 368,370);
// Upper arm medial
ctx.bezierCurveTo(366,180, 377,95, 388,80);
ctx.closePath();
const armGrad = ctx.createLinearGradient(360,80, 480,80);
armGrad.addColorStop(0, '#c8a882');
armGrad.addColorStop(0.35, '#dbbf95');
armGrad.addColorStop(0.7, '#c8a882');
armGrad.addColorStop(1, '#b8966a');
ctx.fillStyle = armGrad;
ctx.fill();
ctx.strokeStyle = '#9a7855';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.restore();
}
function drawHumerus() {
ctx.save();
ctx.beginPath();
ctx.moveTo(412,100);
ctx.bezierCurveTo(432,100, 442,160, 442,310);
ctx.bezierCurveTo(442,360, 438,390, 425,395);
ctx.bezierCurveTo(418,398, 410,398, 405,395);
ctx.bezierCurveTo(392,390, 388,360, 388,310);
ctx.bezierCurveTo(388,160, 398,100, 412,100);
ctx.closePath();
const boneGrad = ctx.createLinearGradient(388,100, 442,100);
boneGrad.addColorStop(0,'#e8dcc8');
boneGrad.addColorStop(0.5,'#f5edd8');
boneGrad.addColorStop(1,'#d8c8a8');
ctx.fillStyle = boneGrad;
ctx.fill();
ctx.strokeStyle = '#b8a888';
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
}
function drawBonesForearm() {
// Radius
ctx.save();
ctx.beginPath();
ctx.moveTo(442,400); ctx.bezierCurveTo(448,440,452,510,456,568);
ctx.lineTo(464,568); ctx.bezierCurveTo(460,510,458,440,452,400);
ctx.closePath();
ctx.fillStyle = '#f0e6d0'; ctx.fill();
ctx.strokeStyle = '#c0b090'; ctx.lineWidth=1; ctx.stroke();
// Ulna
ctx.beginPath();
ctx.moveTo(398,400); ctx.bezierCurveTo(393,440,388,510,385,568);
ctx.lineTo(393,568); ctx.bezierCurveTo(396,510,401,440,406,400);
ctx.closePath();
ctx.fillStyle = '#f0e6d0'; ctx.fill();
ctx.strokeStyle = '#c0b090'; ctx.lineWidth=1; ctx.stroke();
ctx.restore();
}
function drawNerve(highlightSiteIdx) {
// Draw full nerve path
ctx.save();
ctx.shadowBlur = 0;
// Inactive segments (dim yellow)
ctx.beginPath();
ctx.moveTo(NERVE_PATH[0].x, NERVE_PATH[0].y);
for (let i=1; i<NERVE_PATH.length; i++) {
const p = NERVE_PATH[i], pp = NERVE_PATH[i-1];
const mx=(p.x+pp.x)/2, my=(p.y+pp.y)/2;
ctx.quadraticCurveTo(pp.x,pp.y,mx,my);
}
ctx.strokeStyle='rgba(255,220,80,0.35)';
ctx.lineWidth=4;
ctx.setLineDash([]);
ctx.stroke();
ctx.restore();
// Animated pulsing highlight from origin to compression site
if (highlightSiteIdx >= 0) {
const site = SITES[highlightSiteIdx];
const endIdx = site.ptIdx;
ctx.save();
ctx.shadowBlur = 14;
ctx.shadowColor = site.glow;
// draw path up to site point
ctx.beginPath();
ctx.moveTo(NERVE_PATH[0].x, NERVE_PATH[0].y);
for(let i=1; i<=endIdx; i++){
const p=NERVE_PATH[i], pp=NERVE_PATH[i-1];
const mx=(p.x+pp.x)/2, my=(p.y+pp.y)/2;
ctx.quadraticCurveTo(pp.x,pp.y,mx,my);
}
ctx.strokeStyle=site.color;
ctx.lineWidth=5;
ctx.setLineDash([]);
ctx.stroke();
ctx.restore();
// Draw remaining nerve dim after compression site
ctx.save();
ctx.beginPath();
ctx.moveTo(NERVE_PATH[endIdx].x, NERVE_PATH[endIdx].y);
for(let i=endIdx+1; i<NERVE_PATH.length; i++){
const p=NERVE_PATH[i], pp=NERVE_PATH[i-1];
const mx=(p.x+pp.x)/2, my=(p.y+pp.y)/2;
ctx.quadraticCurveTo(pp.x,pp.y,mx,my);
}
ctx.strokeStyle='rgba(255,220,80,0.15)';
ctx.lineWidth=3;
ctx.setLineDash([4,4]);
ctx.stroke();
ctx.restore();
}
}
function drawCompressionSite(site, phase) {
const pt = NERVE_PATH[site.ptIdx];
const pulse = 0.7 + 0.3*Math.sin(phase*0.12);
const r = 14 * pulse;
// Outer glow rings
for (let i=3; i>=1; i--) {
ctx.save();
ctx.beginPath();
ctx.arc(pt.x, pt.y, r*(1+i*0.55), 0, Math.PI*2);
ctx.strokeStyle=site.glow;
ctx.globalAlpha=0.08*(4-i);
ctx.lineWidth=2;
ctx.stroke();
ctx.restore();
}
// Inner filled circle
ctx.save();
ctx.beginPath();
ctx.arc(pt.x, pt.y, r, 0, Math.PI*2);
const rg = ctx.createRadialGradient(pt.x,pt.y,0, pt.x,pt.y,r);
rg.addColorStop(0,'white');
rg.addColorStop(0.3,site.color);
rg.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle = rg;
ctx.shadowBlur = 22;
ctx.shadowColor = site.glow;
ctx.fill();
ctx.restore();
// Arrow from label to nerve
const af = site.arrowFrom, at2 = site.arrowTo;
ctx.save();
ctx.beginPath();
ctx.moveTo(af.x, af.y);
ctx.bezierCurveTo(af.x+15,af.y, at2.x-15,at2.y, at2.x, at2.y);
ctx.strokeStyle=site.color;
ctx.lineWidth=1.8;
ctx.globalAlpha=0.75;
ctx.setLineDash([4,3]);
ctx.stroke();
// Arrow head
ctx.setLineDash([]);
ctx.globalAlpha=1;
const angle = Math.atan2(at2.y-af.y, at2.x-af.x);
ctx.translate(at2.x, at2.y);
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0,0); ctx.lineTo(-9,-4); ctx.lineTo(-9,4);
ctx.closePath();
ctx.fillStyle=site.color;
ctx.fill();
ctx.restore();
}
function drawInfoCard(site, revealP) {
const x = 18;
const baseY = site.arrowFrom.y - 10;
const cardW = 292;
const lineH = 19;
const cardH = 32 + site.info.length * lineH + 10;
// Card from left (slide in)
const slideX = x - (1-revealP)*320;
ctx.save();
ctx.globalAlpha = revealP;
// Background
ctx.fillStyle='rgba(5,15,38,0.88)';
roundRect(ctx, slideX, baseY - cardH + 10, cardW, cardH, 8);
ctx.fill();
ctx.strokeStyle=site.color;
ctx.lineWidth=1.5;
roundRect(ctx, slideX, baseY - cardH + 10, cardW, cardH, 8);
ctx.stroke();
// Left accent bar
ctx.fillStyle=site.color;
roundRect(ctx, slideX, baseY - cardH + 10, 4, cardH, 4);
ctx.fill();
// Title
ctx.fillStyle=site.color;
ctx.font='bold 12.5px Segoe UI, Arial';
ctx.fillText(site.subtitle, slideX+14, baseY-cardH+28);
// Info lines
ctx.font='12px Segoe UI, Arial';
ctx.fillStyle='#c8e8f8';
site.info.forEach((line,i)=>{
ctx.fillText('• '+line, slideX+14, baseY-cardH+28+(i+1)*lineH+4);
});
ctx.restore();
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x+r, y);
ctx.lineTo(x+w-r, y); ctx.quadraticCurveTo(x+w,y,x+w,y+r);
ctx.lineTo(x+w,y+h-r); ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h);
ctx.lineTo(x+r,y+h); ctx.quadraticCurveTo(x,y+h,x,y+h-r);
ctx.lineTo(x,y+r); ctx.quadraticCurveTo(x,y,x+r,y);
ctx.closePath();
}
function drawBackground() {
const bg = ctx.createLinearGradient(0,0,900,620);
bg.addColorStop(0,'#07101e');
bg.addColorStop(1,'#0d1a30');
ctx.fillStyle=bg;
ctx.fillRect(0,0,W,H);
}
function drawTitle() {
ctx.save();
ctx.font='bold 17px Segoe UI, Arial';
ctx.fillStyle='rgba(120,200,240,0.9)';
ctx.textAlign='center';
ctx.fillText('Radial Nerve – Sites of Compression', W/2, 30);
// Origin label
ctx.font='11px Segoe UI, Arial';
ctx.fillStyle='rgba(255,220,80,0.7)';
ctx.textAlign='left';
ctx.fillText('Posterior cord (C5–T1)', 520, 92);
ctx.restore();
}
function drawNerveLabel() {
// Label the nerve itself
ctx.save();
ctx.font='italic 11px Segoe UI';
ctx.fillStyle='rgba(255,220,80,0.8)';
ctx.textAlign='left';
ctx.fillText('Radial n.', 480, 195);
ctx.restore();
}
function drawAnatomicLabels() {
ctx.save();
ctx.font='10px Segoe UI, Arial';
ctx.fillStyle='rgba(200,220,240,0.45)';
ctx.fillText('Axilla', 496, 105);
ctx.fillText('Upper arm', 496, 200);
ctx.fillText('Elbow', 496, 380);
ctx.fillText('Forearm', 496, 460);
// Bone labels
ctx.fillStyle='rgba(200,200,180,0.3)';
ctx.fillText('Humerus', 343, 250);
ctx.fillText('Radius', 475, 510);
ctx.fillText('Ulna', 375, 530);
ctx.restore();
}
// Progress dots
function drawDots() {
const dotY = 590;
const dotX0 = W/2 - (SITES.length-1)*16;
SITES.forEach((s,i)=>{
ctx.save();
ctx.beginPath();
ctx.arc(dotX0+i*32, dotY, i===currentSite?6:4, 0, Math.PI*2);
ctx.fillStyle = i===currentSite ? s.color : 'rgba(255,255,255,0.25)';
if(i===currentSite){ctx.shadowBlur=10;ctx.shadowColor=s.glow;}
ctx.fill();
ctx.restore();
});
}
// Nerve signal pulse animation
function drawNerveSignal(site) {
const endIdx = site.ptIdx;
// Compute position along nerve based on animFrame
const t = ((animFrame*1.8) % (endIdx*40)) / (endIdx*40);
let pathLen = 0;
const segs = [];
for(let i=1; i<=endIdx; i++){
const p=NERVE_PATH[i], pp=NERVE_PATH[i-1];
const d=Math.hypot(p.x-pp.x,p.y-pp.y);
pathLen+=d; segs.push({start:pathLen-d, end:pathLen, p, pp, d});
}
const target = t * pathLen;
let px=NERVE_PATH[0].x, py=NERVE_PATH[0].y;
for(const seg of segs){
if(target>=seg.start && target<=seg.end){
const f=(target-seg.start)/seg.d;
px=seg.pp.x+(seg.p.x-seg.pp.x)*f;
py=seg.pp.y+(seg.p.y-seg.pp.y)*f;
break;
}
}
ctx.save();
ctx.beginPath();
ctx.arc(px,py,5,0,Math.PI*2);
const sg=ctx.createRadialGradient(px,py,0,px,py,6);
sg.addColorStop(0,'white');
sg.addColorStop(0.4,site.color);
sg.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=sg;
ctx.shadowBlur=16; ctx.shadowColor=site.glow;
ctx.fill();
ctx.restore();
}
// ──────────────────────────────────────────────
// MAIN LOOP
// ──────────────────────────────────────────────
function draw() {
ctx.clearRect(0,0,W,H);
drawBackground();
drawArm();
drawHumerus();
drawBonesForearm();
drawNerve(currentSite);
drawNerveSignal(SITES[currentSite]);
drawAnatomicLabels();
drawNerveLabel();
const site = SITES[currentSite];
revealProgress = Math.min(revealProgress + 0.04, 1);
drawCompressionSite(site, animFrame);
drawInfoCard(site, revealProgress);
drawDots();
drawTitle();
pulsePhase++;
animFrame++;
if(playing) {
siteTimer++;
if(siteTimer >= SITE_DURATION) {
siteTimer = 0;
revealProgress = 0;
currentSite = (currentSite+1) % SITES.length;
updateUI();
}
}
requestAnimationFrame(draw);
}
function updateUI() {
document.getElementById('siteLabel').textContent = SITES[currentSite].name;
document.getElementById('stepCounter').textContent = `Site ${currentSite+1} of ${SITES.length}`;
}
// ──────────────────────────────────────────────
// CONTROLS
// ──────────────────────────────────────────────
document.getElementById('playBtn').addEventListener('click', ()=>{
playing=!playing;
document.getElementById('playBtn').textContent = playing ? '⏸ Pause' : '▶ Play';
});
document.getElementById('nextBtn').addEventListener('click', ()=>{
siteTimer=0; revealProgress=0;
currentSite=(currentSite+1)%SITES.length;
updateUI();
});
document.getElementById('prevBtn').addEventListener('click', ()=>{
siteTimer=0; revealProgress=0;
currentSite=(currentSite-1+SITES.length)%SITES.length;
updateUI();
});
updateUI();
draw();
</script>
</body>
</html>
cd /home/daytona/workspace/radial-nerve-animation && node -e "const puppeteer = require('puppeteer'); console.log('puppeteer ok');" 2>&1 | head -5
which ffmpeg && python3 -c "from PIL import Image; print('PIL ok')" 2>&1; node --version 2>&1; python3 --version 2>&1
~/radial-nerve-animation/generate_frames.py
"""
Generate animated frames for radial nerve compression sites video.
Uses matplotlib to render anatomical diagrams + PIL to composite.
"""
import os, math
import numpy as np
from PIL import Image, ImageDraw, ImageFont
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch
from matplotlib.patheffects import withStroke
import matplotlib.patheffects as pe
OUT_DIR = "/home/daytona/workspace/radial-nerve-animation/frames"
os.makedirs(OUT_DIR, exist_ok=True)
W, H = 1280, 720
FPS = 24
FRAMES_PER_SITE = FPS * 4 # 4 seconds per site
TRANSITION_FRAMES = FPS // 2 # 0.5 s transition
SITES = [
{
"name": "Site 1 – Axilla",
"subtitle": "Crutch Palsy | Posterior Wall of Axilla",
"nerve_y_end": 0.18, # fraction down the arm
"nerve_x_mid": 0.43,
"color": "#FF6B6B",
"glow": (1.0, 0.42, 0.42),
"causes": [
"Prolonged crutch use",
"Axillary mass / compression",
],
"effects": [
"Wrist drop + triceps weakness",
"Sensory loss: entire radial territory",
"Loss of all radial-innervated muscles",
],
"level": "High (proximal) lesion",
},
{
"name": "Site 2 – Spiral (Radial) Groove",
"subtitle": "Saturday Night Palsy | Park Bench Palsy | Mid-humerus fracture",
"nerve_y_end": 0.44,
"nerve_x_mid": 0.50,
"color": "#FFB347",
"glow": (1.0, 0.70, 0.28),
"causes": [
"Humeral shaft fracture",
"Compression during sleep (coma, anesthesia)",
"Callus / exuberant bone healing",
],
"effects": [
"Wrist drop WITHOUT triceps weakness",
"Triceps spared (branches given ABOVE groove)",
"Sensory loss: dorsoradial hand",
],
"level": "Classic mid-arm radial palsy",
},
{
"name": "Site 3 – Lateral Intermuscular Septum",
"subtitle": "Proximal Forearm Entry | Mid-level Lesion",
"nerve_y_end": 0.62,
"nerve_x_mid": 0.53,
"color": "#FFD700",
"glow": (1.0, 0.84, 0.0),
"causes": [
"Bridging vessels & connective-tissue septa",
"Chronic compression, tight casts",
],
"effects": [
"Wrist drop with sensory disturbances",
"Triceps preserved",
"Brachioradialis may be preserved",
],
"level": "Mid-level lesion",
},
{
"name": "Site 4 – Radial Tunnel / Arcade of Frohse",
"subtitle": "PIN Entrapment at Supinator Entry",
"nerve_y_end": 0.71,
"nerve_x_mid": 0.54,
"color": "#48D1CC",
"glow": (0.28, 0.82, 0.80),
"causes": [
"Sharp fibrous arch of supinator (Arcade of Frohse)",
"Lipoma, ganglion, rheumatoid synovitis",
],
"effects": [
"NO wrist drop (ECRL, ECRB, brachioradialis spared)",
"NO sensory loss on hand",
"Finger & thumb extensors weak",
"Lateral elbow/forearm ache (Radial Tunnel Syndrome)",
],
"level": "Posterior Interosseous Nerve (PIN) – deep branch",
},
{
"name": "Site 5 – Supinator Canal (PIN Syndrome)",
"subtitle": "Deep Branch Within Supinator Muscle",
"nerve_y_end": 0.79,
"nerve_x_mid": 0.54,
"color": "#98FB98",
"glow": (0.60, 0.98, 0.60),
"causes": [
"Radial head fracture/dislocation",
"Compression within supinator canal",
],
"effects": [
"NO wrist drop",
"NO sensory loss",
"Weakness: EPL, EPB, APL, EDC, EI, ECU",
"ECRL + brachioradialis preserved",
],
"level": "Distal PIN – pure motor lesion",
},
]
# ─── COORDINATE HELPERS ───────────────────────────────────────────────────────
# Arm drawn in figure coords (figsize-based). We'll use axis coords 0→1.
# Arm outline in axis normalized coords (x: arm span ~0.40–0.58, y: 0.05–0.95)
def arm_lateral_x(y_norm):
"""Right edge of arm (lateral) at normalized y position"""
if y_norm < 0.5:
return 0.565 - 0.01*(y_norm/0.5)
else:
t = (y_norm-0.5)/0.5
return 0.555 - 0.015*t
def arm_medial_x(y_norm):
"""Left edge of arm (medial) at normalized y position"""
if y_norm < 0.5:
return 0.415 + 0.01*(y_norm/0.5)
else:
t=(y_norm-0.5)/0.5
return 0.425 + 0.01*t
def nerve_x(y_norm):
"""Centre-ish path of radial nerve along the arm, with spiral groove curve"""
if y_norm < 0.10:
# axilla: medial side
return 0.425
elif y_norm < 0.22:
# winds around humerus posteriorly
t = (y_norm-0.10)/0.12
return 0.425 + 0.055*t # crosses to lateral
elif y_norm < 0.50:
# continues in groove on lateral/posterior
t = (y_norm-0.22)/0.28
return 0.480 + 0.025*t
elif y_norm < 0.65:
# pierces lateral intermuscular septum
t = (y_norm-0.50)/0.15
return 0.505 - 0.010*t
else:
# forearm – deep branch
return 0.495 - 0.010*(y_norm-0.65)/0.30
# ─── DRAWING ──────────────────────────────────────────────────────────────────
def draw_frame(site_idx, frame_in_site, alpha=1.0, prev_site_idx=None):
fig, ax = plt.subplots(figsize=(1280/100, 720/100), dpi=100)
fig.patch.set_facecolor('#080e1c')
ax.set_facecolor('#080e1c')
ax.set_xlim(0, 1); ax.set_ylim(1, 0) # y-axis flipped (0=top)
ax.set_aspect('equal', adjustable='box')
ax.axis('off')
site = SITES[site_idx]
t_anim = frame_in_site / FRAMES_PER_SITE # 0→1
pulse = 0.5 + 0.5*math.sin(frame_in_site * 0.22)
# ── ARM SILHOUETTE ──
y_vals = np.linspace(0.04, 0.96, 200)
lat_x = [arm_lateral_x(y) for y in y_vals]
med_x = [arm_medial_x(y) for y in y_vals]
from matplotlib.patches import Polygon as MPoly
arm_pts = list(zip(lat_x, y_vals)) + list(zip(reversed(med_x), reversed(y_vals)))
arm_poly = MPoly(arm_pts, closed=True,
facecolor='#c8a070', edgecolor='#9a7040', linewidth=1.2, alpha=0.92, zorder=2)
ax.add_patch(arm_poly)
# ── HUMERUS ──
hy = np.linspace(0.06, 0.62, 150)
hw = 0.040
hx_center = 0.490
hx_l = [hx_center - hw*(0.5+0.3*math.sin((y-0.06)*8)) for y in hy]
hx_r = [hx_center + hw*(0.5+0.3*math.sin((y-0.06)*8)) for y in hy]
bone_pts = list(zip(hx_r, hy)) + list(zip(reversed(hx_l), reversed(hy)))
bone_poly = MPoly(bone_pts, closed=True,
facecolor='#f0e6d0', edgecolor='#c0b090', linewidth=0.8, alpha=0.9, zorder=3)
ax.add_patch(bone_poly)
ax.text(0.542, 0.35, 'Humerus', fontsize=7.5, color='rgba(200,200,180,0.35)' if False else '#8a8060',
va='center', ha='left', zorder=10,
path_effects=[pe.withStroke(linewidth=2, foreground='#080e1c')])
# ── FOREARM BONES ──
# Radius (lateral)
for (bx_c, by_s, bby_e, blabel, boff) in [
(0.515, 0.62, 0.95, 'Radius', 0.015),
(0.460, 0.62, 0.95, 'Ulna', -0.03),
]:
by_v = np.linspace(by_s, bby_e, 80)
bpts = [(bx_c+0.013+boff, by_s)] + [(bx_c+0.012+boff, y) for y in by_v] + \
[(bx_c-0.012+boff, y) for y in reversed(by_v)] + [(bx_c-0.013+boff, by_s)]
bp = MPoly(bpts, closed=True,
facecolor='#ede2cc', edgecolor='#c0b090', linewidth=0.7, alpha=0.85, zorder=3)
ax.add_patch(bp)
# ── FULL NERVE PATH (dim) ──
nerve_y = np.linspace(0.06, 0.94, 300)
nerve_x_coords = [nerve_x(y) for y in nerve_y]
ax.plot(nerve_x_coords, nerve_y, color='rgba(255,220,80,0.25)' if False else '#ffdd5044',
linewidth=2.8, zorder=5, solid_capstyle='round')
# ── HIGHLIGHTED NERVE UP TO COMPRESSION SITE ──
y_end = site["nerve_y_end"]
nerve_mask = nerve_y <= y_end
hn_y = nerve_y[nerve_mask]
hn_x = np.array(nerve_x_coords)[nerve_mask]
col = site["color"]
glow_col = site["glow"]
# glow shadow layers
for lw, alpha_v in [(14, 0.08), (9, 0.18), (5.5, 0.45)]:
ax.plot(hn_x, hn_y, color=col, linewidth=lw, alpha=alpha_v, zorder=5,
solid_capstyle='round')
ax.plot(hn_x, hn_y, color='white', linewidth=2.0, alpha=0.55, zorder=6,
solid_capstyle='round')
ax.plot(hn_x, hn_y, color=col, linewidth=3.0, alpha=0.95, zorder=6,
solid_capstyle='round')
# ── DASHED NERVE BELOW SITE ──
nerve_after_mask = nerve_y > y_end
af_y = nerve_y[nerve_after_mask]
af_x = np.array(nerve_x_coords)[nerve_after_mask]
ax.plot(af_x, af_y, color='#ffdd50', linewidth=1.8, alpha=0.15, zorder=5,
linestyle='--', dashes=(4, 4))
# ── NERVE SIGNAL PULSE (travelling dot) ──
# travels along the highlighted segment
pulse_t = (frame_in_site * 1.8 / FRAMES_PER_SITE) % 1.0
pt_idx = int(pulse_t * (len(hn_x)-1))
if pt_idx < len(hn_x):
px, py = hn_x[pt_idx], hn_y[pt_idx]
for r, a in [(0.025, 0.08), (0.015, 0.18), (0.007, 0.7)]:
circle = plt.Circle((px, py), r, color=col, alpha=a, zorder=8, transform=ax.transData)
ax.add_patch(circle)
ax.plot(px, py, 'o', color='white', markersize=5, alpha=0.9, zorder=9,
markeredgecolor=col, markeredgewidth=1.2)
# ── COMPRESSION SITE MARKER ──
cx = nerve_x(y_end)
cy = y_end
pulse_r = 0.022 + 0.008*pulse
for r, a in [(pulse_r*3.0, 0.06), (pulse_r*2.0, 0.12), (pulse_r*1.3, 0.22), (pulse_r, 0.8)]:
c = plt.Circle((cx, cy), r, color=col, alpha=a, zorder=10, transform=ax.transData)
ax.add_patch(c)
ax.plot(cx, cy, 'o', color='white', markersize=9, alpha=0.9, zorder=11,
markeredgecolor=col, markeredgewidth=2)
# X marker
ax.plot(cx, cy, 'x', color=col, markersize=7, markeredgewidth=2.5, zorder=12)
# ── COMPRESSION LABEL (right side) ──
card_x = 0.62
card_y = max(0.08, min(0.88, cy - 0.04))
# Arrow from marker to card
ax.annotate('', xy=(cx+0.005, cy), xytext=(card_x-0.005, card_y+0.025),
arrowprops=dict(arrowstyle='->', color=col, lw=1.5,
connectionstyle='arc3,rad=-0.2'),
zorder=9)
# Card background
card_h = 0.035 * (3 + len(site["effects"]))
card_bg = FancyBboxPatch((card_x, card_y-0.01), 0.34, card_h,
boxstyle="round,pad=0.012",
facecolor='#050e26', edgecolor=col,
linewidth=1.6, alpha=0.92*min(1, t_anim*3), zorder=10)
ax.add_patch(card_bg)
card_alpha = min(1.0, t_anim * 3)
# Site name
ax.text(card_x+0.008, card_y+0.025, site["subtitle"],
fontsize=8.5, color=col, fontweight='bold', va='top', ha='left',
zorder=12, alpha=card_alpha,
path_effects=[pe.withStroke(linewidth=2, foreground='#050e26')])
# Level badge
ax.text(card_x+0.008, card_y+0.055, site["level"],
fontsize=7.5, color='#a0d4f0', fontstyle='italic', va='top', ha='left',
zorder=12, alpha=card_alpha*0.9)
# Causes
y_off = card_y + 0.080
ax.text(card_x+0.008, y_off, 'Causes:', fontsize=7.5, color='#ffdd88',
fontweight='bold', va='top', zorder=12, alpha=card_alpha)
y_off += 0.026
for cause in site["causes"]:
ax.text(card_x+0.014, y_off, f'• {cause}',
fontsize=7, color='#d0e8f8', va='top', zorder=12, alpha=card_alpha)
y_off += 0.024
# Effects
ax.text(card_x+0.008, y_off, 'Clinical features:', fontsize=7.5,
color='#ff9988', fontweight='bold', va='top', zorder=12, alpha=card_alpha)
y_off += 0.026
for eff in site["effects"]:
ax.text(card_x+0.014, y_off, f'• {eff}',
fontsize=7, color='#d0e8f8', va='top', zorder=12, alpha=card_alpha)
y_off += 0.024
# ── TITLE ──
fig.text(0.50, 0.97, 'Radial Nerve – Sites of Compression',
ha='center', va='top', fontsize=16, fontweight='bold',
color='#78c8f0',
path_effects=[pe.withStroke(linewidth=3, foreground='#080e1c')])
# ── SITE NAME BANNER ──
fig.text(0.50, 0.91, site["name"],
ha='center', va='top', fontsize=13.5, fontweight='bold',
color=col,
path_effects=[pe.withStroke(linewidth=4, foreground='#080e1c')])
# ── ANATOMY LABELS (static) ──
anatomy_labels = [
(0.395, 0.10, 'Axilla\n(post. cord C5–T1)'),
(0.385, 0.28, 'Spiral\nGroove'),
(0.385, 0.49, 'Lat. Intermuscular\nSeptum'),
(0.385, 0.635, 'Radial Tunnel\n(Arcade of Frohse)'),
(0.385, 0.73, 'Supinator\nCanal (PIN)'),
]
for lx, ly, ltxt in anatomy_labels:
ax.text(lx, ly, ltxt, fontsize=6.5, color='#445566', va='center', ha='right',
zorder=7, multialignment='right',
path_effects=[pe.withStroke(linewidth=1.5, foreground='#080e1c')])
# Small tick mark
ax.plot([lx+0.003, nerve_x(ly)+0.002], [ly, ly],
color='#334455', linewidth=0.6, alpha=0.4, zorder=6)
# ── PROGRESS DOTS ──
for i, s in enumerate(SITES):
dx = 0.46 + i*0.02
dy = 0.975
if i == site_idx:
c2 = plt.Circle((dx, dy), 0.008, color=s["color"], alpha=0.95, zorder=15)
else:
c2 = plt.Circle((dx, dy), 0.005, color='#334466', alpha=0.6, zorder=15)
ax.add_patch(c2)
# ── SAVE FRAME ──
plt.tight_layout(pad=0)
fname = os.path.join(OUT_DIR, f"frame_{frame_in_site + site_idx*FRAMES_PER_SITE:05d}.png")
fig.savefig(fname, dpi=100, bbox_inches='tight', pad_inches=0,
facecolor='#080e1c')
plt.close(fig)
return fname
# ── GENERATE ALL FRAMES ────────────────────────────────────────────────────────
print(f"Generating frames: {len(SITES)} sites × {FRAMES_PER_SITE} frames = {len(SITES)*FRAMES_PER_SITE} total")
total = 0
for si, site in enumerate(SITES):
print(f" Site {si+1}/{len(SITES)}: {site['name']}")
for fi in range(FRAMES_PER_SITE):
draw_frame(si, fi)
total += 1
if fi % 24 == 0:
print(f" frame {fi}/{FRAMES_PER_SITE}")
print(f"\nDone! {total} frames saved to {OUT_DIR}")
cd /home/daytona/workspace/radial-nerve-animation && python3 generate_frames.py 2>&1
ls /home/daytona/workspace/radial-nerve-animation/frames/ | wc -l
~/radial-nerve-animation/generate_fast.py
"""
Fast frame generator using PIL only – no matplotlib.
Draws radial nerve compression animation to PNG frames, then ffmpeg encodes.
"""
import os, math
from PIL import Image, ImageDraw, ImageFont
OUT_DIR = "/home/daytona/workspace/radial-nerve-animation/frames2"
os.makedirs(OUT_DIR, exist_ok=True)
W, H = 1280, 720
FPS = 24
FRAMES_PER_SITE = FPS * 4 # 4 s per site
# ── SITES DATA ────────────────────────────────────────────────────────────────
SITES = [
dict(
name="Site 1 – Axilla (Crutch Palsy)",
subtitle="Triangular Space · Posterior Wall of Axilla",
nerve_end_y=160, # pixel y where compression occurs
color=(255,107,107), glow=(255,50,50),
causes=["Prolonged crutch use","Axillary mass / compression"],
effects=["Wrist drop + triceps WEAKNESS","Sensory loss: entire radial territory",
"All radial-innervated muscles affected"],
level="HIGH (proximal) lesion",
),
dict(
name="Site 2 – Spiral (Radial) Groove",
subtitle="Saturday Night / Park Bench Palsy · Humeral Fracture",
nerve_end_y=310,
color=(255,179,71), glow=(220,120,0),
causes=["Humeral shaft fracture","Prolonged arm compression during sleep",
"Callus after fracture"],
effects=["Wrist drop WITHOUT triceps weakness",
"Triceps spared (branches leave ABOVE groove)",
"Sensory loss: dorsoradial hand"],
level="Classic mid-arm palsy",
),
dict(
name="Site 3 – Lateral Intermuscular Septum",
subtitle="Proximal Forearm Entry",
nerve_end_y=430,
color=(255,215,0), glow=(180,150,0),
causes=["Bridging vessels & connective-tissue septa","Chronic pressure, tight casts"],
effects=["Wrist drop with sensory disturbances","Triceps preserved",
"Brachioradialis may be preserved"],
level="MID-level lesion",
),
dict(
name="Site 4 – Radial Tunnel / Arcade of Frohse",
subtitle="PIN Entrapment at Supinator Entry",
nerve_end_y=520,
color=(72,209,204), glow=(0,180,170),
causes=["Sharp fibrous arch of supinator (Arcade of Frohse)",
"Lipoma, ganglion, rheumatoid synovitis"],
effects=["NO wrist drop | NO sensory loss on hand",
"Finger & thumb extensors weak",
"Lateral elbow ache (Radial Tunnel Syndrome)"],
level="Posterior Interosseous Nerve (PIN) – deep branch",
),
dict(
name="Site 5 – Supinator Canal (PIN Syndrome)",
subtitle="Deep Branch Within Supinator Muscle",
nerve_end_y=590,
color=(152,251,152), glow=(40,160,40),
causes=["Radial head fracture / dislocation",
"Compression within supinator canal"],
effects=["NO wrist drop | NO sensory loss",
"Weak: EPL, EPB, APL, EDC, EI, ECU",
"ECRL + brachioradialis preserved"],
level="Distal PIN – pure MOTOR lesion",
),
]
# ── ARM GEOMETRY ──────────────────────────────────────────────────────────────
ARM_TOP = 50
ARM_BOT = 680
ARM_CX = 480 # centre-x of arm
def arm_half_w(y):
"""Half-width of arm outline at pixel y"""
frac = (y - ARM_TOP) / (ARM_BOT - ARM_TOP)
if frac < 0.55:
return 68 - 5*frac
else:
t = (frac - 0.55)/0.45
return 65 - 8*t
def nerve_cx(y):
"""Pixel x of radial nerve at pixel y"""
frac = (y - ARM_TOP) / (ARM_BOT - ARM_TOP)
if frac < 0.14:
return ARM_CX - 30 # medial in axilla
elif frac < 0.25:
t = (frac - 0.14)/0.11
return ARM_CX - 30 + t*70 # winds to lateral (spiral groove)
elif frac < 0.65:
return ARM_CX + 40 - 10*(frac-0.25)/0.40
else:
return ARM_CX + 32 - 8*(frac-0.65)/0.35
def nerve_path_pts(y_end=ARM_BOT, step=4):
pts = []
for y in range(ARM_TOP, min(int(y_end), ARM_BOT), step):
pts.append((nerve_cx(y), y))
return pts
# ── COLOUR HELPERS ───────────────────────────────────────────────────────────
def blend(c1, c2, t):
return tuple(int(c1[i]*(1-t)+c2[i]*t) for i in range(3))
def alpha_col(col, a):
return col + (a,)
# ── DRAW SINGLE FRAME ─────────────────────────────────────────────────────────
def draw_frame(site_idx, frame_i):
site = SITES[site_idx]
col = site["color"]
glow_col = site["glow"]
t = frame_i / FRAMES_PER_SITE # 0→1
pulse = 0.5 + 0.5*math.sin(frame_i * 0.22)
img = Image.new("RGB", (W, H), (8, 14, 28))
draw = ImageDraw.Draw(img, "RGBA")
# ── BACKGROUND GRADIENT (manual horizontal strips) ──
for gy in range(H):
frac = gy/H
r = int(8 + frac*4); g = int(14 + frac*6); b = int(28 + frac*12)
draw.line([(0,gy),(W,gy)], fill=(r,g,b))
# ── ARM SILHOUETTE ──
arm_pts_l, arm_pts_r = [], []
for y in range(ARM_TOP, ARM_BOT, 2):
hw = arm_half_w(y)
arm_pts_l.append((ARM_CX - hw, y))
arm_pts_r.append((ARM_CX + hw, y))
arm_poly = arm_pts_l + list(reversed(arm_pts_r))
draw.polygon(arm_poly, fill=(195,162,110), outline=(155,120,70))
# ── HUMERUS ──
bone_cx = ARM_CX + 10
bone_pts_l, bone_pts_r = [], []
for y in range(ARM_TOP+20, ARM_TOP+380, 2):
bw = 24 + 3*math.sin((y-ARM_TOP)*0.05)
bone_pts_l.append((bone_cx - bw, y))
bone_pts_r.append((bone_cx + bw, y))
bone_poly = bone_pts_l + list(reversed(bone_pts_r))
draw.polygon(bone_poly, fill=(240,230,210), outline=(190,175,150))
# ── RADIUS & ULNA ──
for bx_off, bw2 in [(30, 14), (-26, 12)]:
bp_l, bp_r = [], []
for y in range(ARM_TOP+390, ARM_BOT-10, 2):
bp_l.append((ARM_CX+bx_off-bw2, y))
bp_r.append((ARM_CX+bx_off+bw2, y))
draw.polygon(bp_l+list(reversed(bp_r)), fill=(238,228,208), outline=(188,170,145))
# ── ANATOMY LEVEL LINES (dashed) ──
level_defs = [
(160, "Axilla", (60,80,110)),
(310, "Spiral Groove", (60,80,110)),
(430, "Lat. Intermuscular Septum", (60,80,110)),
(520, "Radial Tunnel / Arcade of Frohse", (60,80,110)),
(590, "Supinator Canal (PIN)", (60,80,110)),
]
for ly, ltxt, lcol in level_defs:
for dx in range(370, 570, 8):
draw.line([(dx, ly),(dx+4, ly)], fill=lcol+(60,))
draw.text((310, ly-7), ltxt, fill=lcol+(130,))
# ── FULL NERVE PATH (dim) ──
all_pts = nerve_path_pts()
if len(all_pts) > 1:
for i in range(len(all_pts)-1):
draw.line([all_pts[i], all_pts[i+1]], fill=(255,220,80,40), width=3)
# ── HIGHLIGHTED NERVE UP TO SITE ──
y_end = site["nerve_end_y"]
hi_pts = nerve_path_pts(y_end)
if len(hi_pts) > 1:
# Glow layers
for lw, alph in [(14,18),(9,40),(5,90)]:
for i in range(len(hi_pts)-1):
draw.line([hi_pts[i], hi_pts[i+1]], fill=glow_col+(alph,), width=lw)
# Core
for i in range(len(hi_pts)-1):
draw.line([hi_pts[i], hi_pts[i+1]], fill=col+(230,), width=3)
draw.line([hi_pts[i], hi_pts[i+1]], fill=(255,255,255,100), width=1)
# ── NERVE SIGNAL PULSE ──
if len(hi_pts) > 2:
pt_idx = int((frame_i * 2.2 % FRAMES_PER_SITE) / FRAMES_PER_SITE * (len(hi_pts)-1))
pt_idx = min(pt_idx, len(hi_pts)-1)
px, py = hi_pts[pt_idx]
for r, alph in [(18,25),(11,60),(6,160)]:
draw.ellipse([(px-r,py-r),(px+r,py+r)], fill=col+(alph,))
draw.ellipse([(px-4,py-4),(px+4,py+4)], fill=(255,255,255,230))
# ── DASHED NERVE BELOW SITE ──
below_pts = nerve_path_pts(ARM_BOT)
below_start = [p for p in below_pts if p[1] > y_end]
if len(below_start) > 1:
for i in range(0, len(below_start)-1, 2):
draw.line([below_start[i], below_start[i+1]], fill=(255,220,80,30), width=2)
# ── COMPRESSION SITE MARKER ──
cx2 = int(nerve_cx(y_end))
cy2 = int(y_end)
pr = int(18 + 6*pulse)
for r, alph in [(pr*3,20),(pr*2,40),(pr*1.4,80),(pr,200)]:
draw.ellipse([(cx2-r,cy2-r),(cx2+r,cy2+r)], fill=col+(alph,))
# White X marker
xs = 8
draw.line([(cx2-xs,cy2-xs),(cx2+xs,cy2+xs)], fill=(255,255,255,230), width=3)
draw.line([(cx2-xs,cy2+xs),(cx2+xs,cy2-xs)], fill=(255,255,255,230), width=3)
# Outer ring
draw.ellipse([(cx2-pr-4,cy2-pr-4),(cx2+pr+4,cy2+pr+4)],
outline=col+(200,), width=2)
# ── INFO CARD (right side) ──
card_alpha = min(255, int(255 * min(1.0, t * 4)))
card_x = 680
card_y = max(80, min(540, cy2 - 90))
card_w, card_h = 570, 240
# Card bg
draw.rounded_rectangle([(card_x, card_y),(card_x+card_w, card_y+card_h)],
radius=10, fill=(5,12,30,int(230*card_alpha/255)),
outline=col+(card_alpha,), width=2)
# Left accent bar
draw.rounded_rectangle([(card_x, card_y),(card_x+5, card_y+card_h)],
radius=4, fill=col+(card_alpha,))
def txa(txt, pos, fsize=14, tcolor=(200,228,248), bold=False):
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold
else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", fsize)
except:
font = ImageFont.load_default()
draw.text(pos, txt, fill=tcolor+(card_alpha,), font=font)
# Subtitle
txa(site["subtitle"], (card_x+16, card_y+12), 13, col, True)
# Level badge
txa(site["level"], (card_x+16, card_y+32), 11, (140,200,240))
# Divider
draw.line([(card_x+12, card_y+52),(card_x+card_w-12, card_y+52)],
fill=col+(60,), width=1)
# Causes
txa("Causes:", (card_x+16, card_y+60), 11, (255,210,100), True)
yo = card_y + 80
for c in site["causes"]:
txa(f"• {c}", (card_x+22, yo), 11, (210,235,255))
yo += 22
# Effects
yo += 4
txa("Clinical Features:", (card_x+16, yo), 11, (255,140,120), True)
yo += 20
for e in site["effects"]:
txa(f"• {e}", (card_x+22, yo), 11, (210,235,255))
yo += 22
# Connector arrow from card to marker
arrow_start = (card_x, card_y + card_h//2)
arrow_end = (cx2 + pr + 5, cy2)
draw.line([arrow_start, arrow_end], fill=col+(int(120*card_alpha/255),), width=2)
# Arrowhead
angle = math.atan2(arrow_end[1]-arrow_start[1], arrow_end[0]-arrow_start[0])
for da in [-0.4, 0.4]:
ex = int(arrow_end[0] - 12*math.cos(angle+da))
ey = int(arrow_end[1] - 12*math.sin(angle+da))
draw.line([arrow_end, (ex,ey)], fill=col+(int(160*card_alpha/255),), width=2)
# ── TITLE BAR ──
try:
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 22)
site_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 18)
except:
title_font = site_font = ImageFont.load_default()
title = "Radial Nerve – Sites of Compression"
tw = draw.textlength(title, font=title_font) if hasattr(draw,'textlength') else 400
draw.text(((W-tw)//2, 10), title, fill=(120,200,240,235), font=title_font)
sname = site["name"]
sw = draw.textlength(sname, font=site_font) if hasattr(draw,'textlength') else 300
draw.text(((W-sw)//2, 38), sname, fill=col+(240,), font=site_font)
# ── PROGRESS DOTS ──
for i, s in enumerate(SITES):
dx = W//2 - (len(SITES)-1)*18 + i*36
dy = H - 20
if i == site_idx:
r2 = 8
draw.ellipse([(dx-r2,dy-r2),(dx+r2,dy+r2)], fill=s["color"]+(220,))
else:
draw.ellipse([(dx-5,dy-5),(dx+5,dy+5)], fill=(50,70,100,160))
fname = os.path.join(OUT_DIR, f"frame_{frame_i + site_idx*FRAMES_PER_SITE:05d}.png")
img.save(fname, optimize=False)
return fname
# ── GENERATE ──────────────────────────────────────────────────────────────────
total_frames = len(SITES) * FRAMES_PER_SITE
print(f"Generating {total_frames} frames ({len(SITES)} sites × {FRAMES_PER_SITE} frames @ {FPS}fps)...")
n = 0
for si in range(len(SITES)):
print(f" Site {si+1}/{len(SITES)}: {SITES[si]['name']}")
for fi in range(FRAMES_PER_SITE):
draw_frame(si, fi)
n += 1
print(f" done ({n} total)")
print(f"\n✓ {n} frames saved to {OUT_DIR}")