Render any planar cross-section of a 3D voxel volume as a video: one NRRD
volume + one CSV plane-list → one .mp4, one frame per plane, in list order.
Live demo & interactive walkthrough: https://ofeq-dev.github.io/plane-projection/
- The problem
- Scope
- Quick start
- How it works
- Testing
- Performance
- Example runs & outputs
- Docs
- Repository layout
- License
Given a 3D raster matrix
Mand a planep, compute the 2D image of the projection ofMontop.—
I2SE_Exe1.docx(exercise brief, repo root)
A plane arrives as 3 or 4 points in a CSV row:
| Points | Geometry | Validity rule (with EPS = 0.001) |
|---|---|---|
| 3 | isosceles triangle (a, b, c) |
|dist(a,b) − dist(a,c)| < EPS |
| 4 | isosceles trapezoid (a1, a2, b, c) |
|dist(a1,b) − dist(a2,c)| < EPS, dist(a1,a2) ≤ dist(b,c), both legs on one plane |
An invalid row raises internally and becomes a black error frame with a red
banner naming the row and the failing rule; the remaining rows still render, so
one bad plane never kills the run. Each sample picks the closest voxel
(nearest-neighbour, half-to-even tie-break) — no interpolation or
"super-resolution", per the brief's Q&A. Samples outside M are black.
Requirements, acceptance criteria and the exact frame geometry live in
docs/srs.md and docs/algorithm.md;
terms are defined in GLOSSARY.md.
flowchart LR
A["NRRD volume\n(8/16/32-bit)"] --> L["Volume loader"]
B["CSV plane list"] --> P["Parse + validate\n(3- or 4-point planes)"]
L --> R["Closest-voxel\nprojection renderer"]
P --> R
R --> F["Frame per plane\n(invalid → error frame)"]
F --> V["MP4 writer\n(OpenCV mp4v)"]
Each plane is parsed and validated against the isosceles-triangle / trapezoid
rules; valid planes are rendered by nearest-voxel sampling onto a canvas sized
to the largest plane in the list, and everything — good and bad frames — is
written to one MP4. The full frame geometry and rounding rules are in
docs/algorithm.md; key implementation choices and why
they were made are in docs/adr/:
| ADR | Decision |
|---|---|
| 0001 | Store large volumes/videos in Git LFS |
| 0002 | Canonical voxel-index space (no world-space headers) |
| 0004 | Video encoding via OpenCV mp4v (pip-only, no system ffmpeg) |
In scope: NRRD volume input, CSV plane-list input, per-plane rendering, per-row validation and error frames, MP4 output, a documented runtime budget, and a CLI with the brief's exact call signature.
Out of scope: interactive/guided use, world-space (physically scaled) rendering (voxel-index space only — ADR 0002), and out-of-plane interpolation (rationale).
Python ≥ 3.12 (numpy 2.5.x requires it). Video encoding uses OpenCV's
cv2.VideoWriter with the mp4v codec — everything is pip-installable, no
system binary needed (ADR 0004).
Large assets (
.nrrdvolumes,.mp4outputs) are stored in Git LFS — clone normally, then rungit lfs pullto fetch the real files (seeassets/nrrd/andassets/videos/).
git clone https://github.com/ofeq-dev/plane-projection.git
cd plane-projection
git lfs pull
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
# development extras (tests, typechecking):
pip install -r requirements-dev.txtRun the brief's demo contract (SRS FR-1):
python Ex0.py file.nrrd file.csv file.mp4
# the lecturer's example: 2 valid planes + 2 deliberately invalid ones
python Ex0.py assets/nrrd/MRHead_1.nrrd assets/csv/lecturer_example.csv out.mp4row 1: ok (triangle)
row 2: ok (trapezoid)
row 3: |dist(a,b) − dist(a,c)| = 20.5284 ≥ EPS = 0.001 (dist(a,b) = 497.393, dist(a,c) = 476.865)
row 4: |dist(a1,b) − dist(a2,c)| = 100 ≥ EPS = 0.001 (dist(a1,b) = 300, dist(a2,c) = 400)
wrote 4 frames (500x448, mp4v @30 fps) to 'out.mp4' (2 error row(s))
Exit code is non-zero on usage errors (missing file, wrong argument count), never because an individual plane is invalid — an invalid plane is reported on stderr and in the video.
One header row, then one row per plane; the 4th point's cells are empty for 3-point rows:
Number of Points (3 or 4),x1,y1,z1,x2,y2,z2,x3,y3,z3,x4,y4,z4
3,200,200,0,0,200,400,400,200,400,,,
4,200,0,0,200,0,500,200,400,0,200,400,500Coordinates are voxel indices into M[x][y][z].
python -m pytest # 123 tests, ~10 sThe suite decodes the produced MP4 and compares rendered frames against
hand-derived pixel tables and committed goldens; it covers validation, error
frames, dtype scaling, canvas/padding, the CLI contract, and the benchmark
harness. Seams, oracles and the golden protocol are documented in
docs/testing-strategy.md.
The brief asks for ≥ 30 FPS (target 100 FPS) on a 500³ byte volume. Measured end-to-end (load + render, min-of-repeats) on the reference machine:
| Input | Frames | FPS |
|---|---|---|
croissant.nrrd × croissant.csv — 500³ uint8, the canonical 500³ case |
100 | 50.1 |
onion.nrrd × head.csv |
280 | 433.5 |
MRHead_1.nrrd × head.csv |
280 | 380.2 |
The ≥ 30 FPS hard bar is met; the 100 FPS aspirational target is not met on
this (2013 dual-core) machine. Every row of the full 30-case cross-product —
with the exact one-line command that reproduced it — is in
benchmarks/results-2026-11-07.md.
Reproduce with:
python bench.py assets/nrrd/croissant.nrrd assets/csv/croissant.csv --repeats 3
python bench.py --corpus --repeats 3 --out benchmarks/results-$(date +%F).md| Video | Volume / planes | What it shows |
|---|---|---|
lecturer_example.mp4 |
MRHead_1 × lecturer_example.csv |
The brief's demo: 2 projections + 2 error frames |
head.mp4 |
MRHead_1 × head.csv |
280 axial slices, black outside the volume |
onion.mp4 |
onion (uint8) |
120 frames with real texture |
MRHead_1.nrrd is int16; frames are shown over the full dtype range (no
content stretch), so in-volume samples are mid-gray and the projection is
visible as the gray/black boundary. A uint8 volume (onion) has full contrast:
Frame-by-frame commentary, canvas sizing and byte-identical reproducibility
notes: docs/example-run.md.
The corpus covers the brief's 8/16/32-bit unsigned element types (plus the
lecturer's signed int16 starter volume) and both 3- and 4-point planes:
| Volume | Dimensions | Type | Plane list |
|---|---|---|---|
MRHead_1.nrrd |
256 × 256 × 130 | int16 |
lecturer_example, head |
croissant.nrrd |
500 × 500 × 500 | uint8 |
croissant |
onion.nrrd |
256 × 256 × 130 | uint8 |
onion |
biscuit_cake.nrrd |
256 × 256 × 130 | uint16 |
biscuit_cake |
swiss_roll.nrrd |
256 × 256 × 130 | uint32 |
swiss_roll |
MRHead_1.nrrd is the lecturer's 3D Slicer MRI sample (donated to the Slicer
project without restrictions — see
docs/research/01-data-formats.md);
its signed int16 type is tolerated per SRS FR-4. The other
volumes are the exercise corpus covering each unsigned element type. Every
volume has a matching CSV in assets/csv/, and
bench.py --corpus runs the whole cross-product.
| Document | Contents |
|---|---|
docs/srs.md |
Requirements (FR-1…FR-21, NFR-1…NFR-4), scope, acceptance criteria |
docs/algorithm.md |
Frame geometry, sampling, rounding and orientation |
docs/algorithm.html |
Interactive, plain-words walkthrough of the algorithm |
docs/testing-strategy.md |
Seams, oracles, goldens, traceability to requirements |
docs/example-run.md |
A real run, frame by frame, with exported videos |
docs/adr/ |
Architecture decision records |
docs/research/ |
Verified research notes (formats, primitives, perf testing) |
benchmarks/ |
Benchmark harness results |
Ex0.py CLI orchestrator (imports from modules below)
errors.py Shared UsageError exception
geometry.py Scalar geometry helpers (dist, cross, sub, dot)
csv_reader.py FR-9 tolerant CSV row iterator
plane_validation.py Plane parsing + validation (parse_planes, count_planes)
bench.py Benchmark harness (corpus cross-product, min-of-repeats)
tests/ pytest suite (unit, golden, CLI/MP4, benchmark)
assets/nrrd|csv|videos/ Sample volumes, plane lists and rendered outputs (Git LFS for large files)
docs/ SRS, algorithm, testing strategy, example run, ADRs, research
site/ Static GitHub Pages demo (deployed on master)
MIT — © 2026 Josh Hall (ofeq-dev). Sample NRRD volumes retain their
original provenance; see assets/nrrd/ for details.

