Generate a animated video clip showing sites of compression of radial nerve

Reading File
Finding Sources
Searching the Web

radial nerve compression sites anatomy spiral groove posterior interosseous nerve

Reading File
Reading File
Loading Image
Reading File
Running Command

mkdir -p /home/daytona/workspace/radial-nerve-animation && ls /home/daytona/workspace/radial-nerve-animation/

Writing File

~/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>
Running Command

cd /home/daytona/workspace/radial-nerve-animation && node -e "const puppeteer = require('puppeteer'); console.log('puppeteer ok');" 2>&1 | head -5

Running Command

which ffmpeg && python3 -c "from PIL import Image; print('PIL ok')" 2>&1; node --version 2>&1; python3 --version 2>&1

Writing File

~/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}")
Running Command

cd /home/daytona/workspace/radial-nerve-animation && python3 generate_frames.py 2>&1

Running Command

ls /home/daytona/workspace/radial-nerve-animation/frames/ | wc -l

Writing File

~/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}")
Your credits are exhausted. Your daily credits reset tomorrow, or you can purchase a top-up for more.
This is a shared conversation. Sign in to Orris to start your own chat.