02 · Architecture
The four layers (with the pose-runner extension)
┌─────────────────────────────────────────────────────────────────┐
│ L4 · UI │
│ • Electron renderer (React, /src/renderer) │
│ · Brand: AceCrush (parent) / Swing-Analysis (this app) │
│ · Main window + detachable Clips panel + detachable │
│ Event Log panel + Settings overlay │
│ • CLI sub-commands: segment / annotate │
│ • Browser / mobile / anything that speaks HTTP (open API) │
└──────────────────────────────┬──────────────────────────────────┘
│ calls into L3
┌──────────────────────────────▼──────────────────────────────────┐
│ L3 · Service / Transport │
│ • FastAPI app (REST + WS + Range streaming) │
│ • JobManager (lifecycle + WS broadcast) │
│ • pydantic schemas (wire types) │
└──────────────────────────────┬──────────────────────────────────┘
│ calls into L2
┌──────────────────────────────▼──────────────────────────────────┐
│ L2 · Pipeline (the seam — composes per user flags) │
│ • backend/service/pipeline.py run_pipeline() │
│ • backend/service/pose_runners/ │
│ ├── rtmdet.py ONNX RTMDet person detector │
│ ├── rtmpose.py ONNX RTMPose COCO-13 estimator │
│ ├── mediapipe.py MediaPipe 33-point estimator │
│ ├── drawing.py bbox / skeleton overlay (cv2 only) │
│ ├── annotate.py ClipAnnotator (bbox + skel on a clip) │
│ └── clip_codec.py ffmpeg H.264 transcode for GUI │
└──────────────────────────────┬──────────────────────────────────┘
│ imports from L1 (no edits)
┌──────────────────────────────▼──────────────────────────────────┐
│ L1 · Algorithm (the truth) │
│ • backend/core/segment_swing.py (vendored, byte-for-byte) │
│ • backend/core/analyze_swing.py (MediaPipe 33-point path) │
│ • backend/core/gen_skeleton_anim.py (RTMDet+RTMPose compositor) │
│ • MediaPipe task model (5.5 MB, Git LFS) │
│ • RTMDet ONNX (104 MB, Git LFS) │
│ • RTMPose ONNX (52 MB, Git LFS) │
└─────────────────────────────────────────────────────────────────┘The seam is between L2 and L1. Every UI goes through run_pipeline(). The algorithm is never imported by anything except the pipeline.
Each pose-runner module is pure — no I/O, no orchestration. The pipeline layer (pipeline.py) composes them based on user flags; the CLI annotate sub-command composes them standalone.
Layout rule. backend/core/* is byte-for-byte vendored from the upstream lab. backend/service/* is our transport + composition layer; the cut lives between L2 and L1, never crossed outward.
What lives in each layer
L1 — Algorithm (backend/core/)
Three vendored scripts, each independently runnable as a CLI and each importable as a library:
| Script | Lines | Purpose | Public surface |
|---|---|---|---|
segment_swing.py | 952 | v2.1 wrist-signal cut pipeline (the one backend.cli segment wraps) | PoseRunner, OnlineSegmenter, segment_cycles, bridge_gaps, ema_smooth, compute_velocity_2d, extract_one_clip, phase_timeline, SwingSegment, _frames_to_tc |
analyze_swing.py | 450 | MediaPipe 33-point once. Wrist feeds OnlineSegmenter; full 33 stored per frame so clips + viz.mp4 are guaranteed 1:1 with the segments list | MediaPipePoseRunner, draw_skeleton_33, extract_skel_clip, render_full_viz, main() |
gen_skeleton_anim.py | 1021 | RTMDet (bbox) + RTMPose / MediaPipe (skeleton) four-quadrant compositor. Optional smart-zoom ROI + stable smoother + auto-sizing | RtmdetRunner, RtmposeRunner, MediaPipePoseRunner, KeypointSmoother, CenterSmoother, StableBoxAutoSizer, build_algo_label, draw_skeleton, resolve_models, build_runners |
All three are byte-for-byte vendored — no edits inside core/. Drift between this repo and the underlying source is resolved by cp, never by hand-merging.
Models committed to the repo:
pose_landmaker_lite.task(5.5 MB) — MediaPipe Pose model.rtmdet-m-487628.onnx(104 MB) — RTMDet person detector.rtmpose-m-27c0e6.onnx(52 MB) — RTMPose COCO-13 estimator.
L2 — Pipeline (backend/service/pipeline.py)
A single function:
run_pipeline(
video_path: Path,
task_path: Path,
out_dir: Path,
params: Optional[Dict] = None,
progress_cb: Optional[Callable[[Dict], None]] = None,
on_segment: Optional[Callable[[Dict], None]] = None,
on_clip_annotated: Optional[Callable[[Dict], None]] = None,
should_cancel: Optional[Callable[[], bool]] = None,
on_clip_progress: Optional[Callable[[Dict], None]] = None,
) -> DictIt reproduces the Pass 1 + Pass 1.5 + Pass 2 control flow from core.segment_swing.main(), replacing the stdout ProgressBar with a progress_cb callback and the print() of each emitted segment with an on_segment callback. If params["clip_bbox"] or params["clip_skel"] is set, each extracted clip is post-processed by ClipAnnotator (L2 pose-runners module) which uses RTMDet and/or RTMPose/MediaPipe to overlay bbox + skeleton. on_clip_progress fires every 5 frames during per-clip annotation so the GUI can render the dual progress bar. Returns the full segments.json payload as a dict.
L2 — pose-runners (backend/service/pose_runners/)
Each module does one thing; pipeline / annotate CLI compose them.
| Module | Class / fn | Inputs | Outputs |
|---|---|---|---|
rtmdet.py | RtmdetRunner | BGR frame | List[BBox] (person detections) |
rtmpose.py | RtmposeRunner | BGR frame + optional BBox | List[(x,y,conf)] (COCO-13 keypoints) |
mediapipe.py | MediaPipePoseRunner | BGR frame + ts_ms | List[(x,y,conf)] (33 keypoints) |
drawing.py | draw_bboxes / draw_skeleton_coco13 / draw_skeleton_mp33 | canvas + payload | mutated canvas |
annotate.py | ClipAnnotator | clip mp4 + flags | annotated mp4 |
clip_codec.py | find_ffmpeg + transcode_h264 | mp4v mp4 + (opt) bbox/skel flags | H.264 sibling mp4 + lazy thumb |
Composition happens in:
pipeline.run_pipeline()— chainsextract_one_clip→ClipAnnotator.annotate_clipwhenclip_bboxorclip_skelis set.cli.cmd_annotate()— runsClipAnnotatorstandalone on everyclip_*.mp4in a directory (post-hoc).
L3 — Service / Transport (backend/service/)
app.py— FastAPI factory; CORS for any localhost port; routes for health / jobs / events / videos / artifacts / clips.jobs.py—JobManagerkeeps an in-memory registry of_JobRecords. AThreadPoolExecutor(max_workers=1)enforces single-job concurrency (MediaPipe VIDEO mode is stateful and CPU-bound). Each job has an event replay buffer (deque, maxlen=1024) for late WS subscribers.schemas.py—JobParams,JobCreate,JobAccepted,JobInfo,SegmentOut,ProgressEvent. Field names mirror the CLI flags exactly.__main__.py— argparse + uvicorn; printsSWING_SERVICE_URL=...to stdout so Electron can discover the bound port; writesservice.jsonas a fallback.
__main__.py flags:
| Flag | Default | Notes |
|---|---|---|
--host | 127.0.0.1 | bind address |
--port | 8321 | bind port (0 lets uvicorn pick) |
--models-dir | <repo>/backend/models | MediaPipe / ONNX models directory |
--data-dir | <repo>/backend/data (dev) / <userData>/backend-data (packaged) | jobs root |
--log-level | info | uvicorn log level |
L4 — UI
- CLI (
backend/cli.py) — argparse with defaults fromDEFAULT_PARAMS, stdout progress line printer, real-time segment echo, SIGINT handler that flips a cancellation flag. Two sub-commands:segmentandannotate. - Electron (this app —
AceCrush Swing-Analysis) — see 05 · Electron GUI for the full component map, sidecar lifecycle, detachable panel system, settings overlay, and brand conventions.package.jsonproductNameisAceCrush Swing-Analysis, the macOS app-menu slot carriesAceCrushalone;appIdiscom.leochan007.acecrush.swinganalysis. Installer builds for Windows / macOS / Linux are produced byelectron-builder(see 08 · Build & Package).
Vendor discipline
The rule is short: the only thing that imports core.segment_swing is service/pipeline.py. If a future change requires importing a core helper from anywhere else (e.g. the FastAPI app wants to peek at SwingSegment), put the helper in pipeline.py first, then have core re-export. This keeps core/ interchangeable with any future implementation.
When the underlying source updates one of the three vendored scripts:
cp <new-segment_swing.py> backend/core/segment_swing.py
cp <new-analyze_swing.py> backend/core/analyze_swing.py
cp <new-gen_skeleton_anim.py> backend/core/gen_skeleton_anim.py
git add backend/core/
git commit -m "vendor: sync from underlying source @ <hash>"No merge conflicts. No "did anyone change this locally?" questions — the files are committed verbatim.
Concurrency model
- One job at a time.
ThreadPoolExecutor(max_workers=1). MediaPipe VIDEO mode is stateful (eachPoseLandmarkerinstance carries per-frame ROI tracking state) and saturates one CPU core. Parallelism would not help and would risk corrupting state. - WS broadcasts are cross-thread. Worker thread calls
loop.call_soon_threadsafe(self._safe_send, ws, event)to schedule a send on the asyncio loop. The receiver side is independent of the worker thread, so a slow / dropped WS client never blocks job progress. - Job lifecycle is independent of WS. You can submit a job, close the WS, reopen it 10 minutes later, and reconnect will replay the buffered events (deque, maxlen=1024) followed by a final
GET /api/jobs/{id}to reconcile.
Cancellation
POST /api/jobs/{id}/cancelflips athreading.Eventin the_JobRecord.- The pipeline loop checks
should_cancel()once per frame and raisesJobCancelledcleanly. The thread exits, the job state moves tocancelled, and any partial artifacts inout_dirare left on disk for inspection. - The CLI installs a SIGINT handler that sets the same flag.
What this design deliberately does not do
- No persistent job queue. Jobs live in memory; service restart loses them. Disk artifacts (
segments.json, clips) survive. - No multi-user. Bind address is
127.0.0.1by default. Phase C adds bind-to-0.0.0.0+ token auth for LAN scenarios. - No horizontal scaling. Single-process, single-job. Adequate for a desktop tool.