In-browser ROI + sulcus drawing and export for pycortex WebGL viewers. Draw on the flattened cortical surface and the stroke is fitted to a smooth, editable bezier that renders as a colored outline (its palette color, over a white halo for legibility) + label baked into the surface, so it occludes and morphs correctly.
Two kinds of shape, each exported in the format its consumer expects:
- ROIs are closed curves. They carry per-hemisphere vertex membership and export to a portable JSON vertex set. The bezier is stored alongside it, so reloaded ROIs re-edit by dragging their control points.
- Sulci are open curves. They carry no vertex membership and export as a standalone SVG
whose
sulcilayer drops straight into a subject'soverlays.svg— the way pycortex itself stores sulci.
The whole feature ships as one self-contained script (dist/roidraw.bundle.js, CSS included), so
it can be dropped into any pycortex static viewer — one already built (like a make_static
export) or one generated fresh from your data.
-
Download
roidraw.bundle.jsfrom the latest release, or build it yourself (see Building). -
Copy
roidraw.bundle.jsnext to the viewer's HTML. -
Add two tags before the closing
</body>(pycortexmake_staticfragments have no</body>— append at the end instead):<script src="roidraw.bundle.js"></script> <script>window.ROIDraw.autoAttach();</script>
autoAttach() waits for the viewer to finish loading, then attaches. That's the entire
integration.
# Static viewer (already built): inject in place, additively and idempotently.
python bake.py path/to/viewer_dir # adds the bundle + the two <script> tags
# From data: generate a fresh static viewer with make_static, then bake it (example, dummy data).
.venv/bin/python examples/make_viewer.pybake.py injects into index.html (what make_static writes), falling back to viewer.html;
pass --html NAME for any other file. Rerunning it refreshes the bundle and leaves the tags alone.
A Display / Draw toggle sits at the top of the viewer.
- Display — the normal pycortex viewer + control panel.
- Draw — the draw panel appears. ROI and sulcus drawing is flat-only: with those tools the brain flattens, and inflating the surface (the unfold slider) returns you to Display. The Point tool works at any unfold, folded or inflated, and leaves the view where it is.
At the top of the panel a ROI / Sulcus / Point selector picks what the pointer draws:
| Gesture | Action |
|---|---|
| Drag, with ROI selected | Lasso a region → name it → fitted to a smooth closed bezier and drawn onto the surface |
| Drag, with Sulcus selected | Trace along the sulcus → name it → fitted to a smooth open bezier |
| Click, with Point selected | Mark the vertex under the cursor → name it (a drag still rotates the brain) |
| Scroll wheel | Zoom (to draw fine detail) |
| Shift + drag | Pan the surface |
| Shift + click | Inspect the voxel under the cursor |
Esc |
Cancel the current stroke (or finish editing) |
The panel lists everything drawn — a glyph marks each row's kind (◯ ROI, ∿ sulcus, ◉ point), and the count column shows enclosed vertices for an ROI, anchors for a sulcus, and hemisphere + vertex for a point (✎ move re-places a point with the next click). Below the list sit Export ROIs (JSON) / Export sulci (SVG) / Export points (JSON) / Import / Clear all.
A viewer without a flatmap (e.g. a make_static of a subject with only fiducial + inflated
surfaces) still gets the tool: ROI and Sulcus are disabled there, and Point is selected. Everything drawn lives
in one toggleable overlay layer (Surface → overlays → "drawn ROIs") alongside the built-in
rois/sulci.
A stray click can't create a shape: any stroke, lasso or trace, must span more than a few pixels before it registers.
Click ✎ edit next to any shape in the panel (this re-flattens the surface if needed). Its anchors appear on the flatmap, and you get the full set of vector-editing controls. ROIs and sulci edit identically:
| Gesture | Action |
|---|---|
| Drag an anchor (●/■) | Move it; its tangent handles travel with it |
| Click an anchor | Select it → its tangent handles (○) appear |
| Drag a handle (○) | Bend the curve. A smooth anchor mirrors the opposite handle; a corner anchor moves each side independently |
| Double-click the curve | Insert a new anchor there (the curve shape is preserved) |
| Double-click an anchor | Toggle it between smooth (●, circle) and corner (■, square) |
Delete / Backspace |
Remove the selected anchor (a closed curve keeps ≥ 3; an open one keeps ≥ 2) |
| Scroll wheel | Zoom · Shift + drag |
An open curve's two endpoints are special: each has only one live tangent (the other would coincide with the anchor), so a single handle appears there, and endpoints are always corners — there is no symmetric tangent to mirror at the end of a curve.
The anchors track the surface as you zoom/pan. For an ROI, vertex membership is re-derived from
the bezier on every change, so the exported vertex set always matches the curve you see. A sulcus
has no membership to re-derive; its label instead follows the curve's midpoint. Click
✓ Done editing (or Esc) to finish. Imported ROIs are editable too — older files without a
bezier get one fitted from their boundary ring on import, and a freshly fit curve starts fully
smooth.
Every export names vertices by the index the viewer uses. pycortex's default mg2 CTM
compression reorders vertices, so for a make_static viewer these are not the subject's
FreeSurfer/pycortex vertex indices. Map them with pycortex itself:
ctm2fs_left, ctm2fs_right = cortex.utils.get_ctmmap(subject, method="mg2", level=9), then
subject_vertex = ctm2fs_left[i] (left) or ctm2fs_right[i] (right). Files re-import exactly into
any viewer built from the same CTM cache.
viewerVertex is the per-hemisphere index in the viewer's mesh order (see Vertex indices);
xyz is that vertex's base-surface position. On import each point's xyz is checked against this
viewer's mesh: a point whose vertex is missing or elsewhere (another subject, another CTM build) is
dropped and reported, never drawn at the wrong place.
Per-hemisphere vertex indices (viewer order — see Vertex indices), an ordered boundary ring, a label vertex,
and the editable bezier (control points in view-independent flat-UV [0,1]). It re-imports
(here or in any viewer on the same surface) to the exact same outline, ready to re-edit:
{
"format": "pycortex-roidraw/vertexset-v2",
"surface": "fsaverage",
"rois": [
{ "name": "V1", "color": "#e6194b",
"vertices": { "left": [ ... ], "right": [ ... ] },
"outline": [ { "h": "left", "g": 1234 }, ... ],
"labelVert": { "h": "left", "g": 1290 },
"bezier": { "closed": true,
"anchors": [ [0.41, 0.55], ... ],
"inHandles": [ [0.40, 0.55], ... ],
"outHandles": [ [0.42, 0.55], ... ],
"smooth": [ true, false, ... ] } }
]
}The bezier carries explicit tangent handles and a per-anchor smooth flag, so a re-imported curve
re-edits identically. v1 files (no bezier) still import — the bezier is back-filled from the
outline ring; a bezier from an earlier build (no smooth) is treated as all-smooth. Sulci never
appear in this file; it is an ROI format.
Sulci are not exported as JSON. Export sulci (SVG) downloads a standalone SVG whose sulci
layer is exactly the layer pycortex's cortex/svgoverlay.py reads — no roidraw-specific format
involved.
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1" width="1024" height="768" viewBox="0 0 1024 768">
<g inkscape:groupmode="layer" id="sulci" inkscape:label="sulci" style="display:inline">
<g inkscape:groupmode="layer" id="sulci_shapes" inkscape:label="shapes">
<g inkscape:groupmode="layer" inkscape:label="CS">
<path style="fill:none;stroke:white;stroke-width:6;stroke-opacity:0.6;stroke-linecap:round"
d="M412.55,301.90C…" /> <!-- left hemisphere -->
<path style="fill:none;stroke:white;stroke-width:6;stroke-opacity:0.6;stroke-linecap:round"
d="M598.31,297.44C…" /> <!-- right hemisphere -->
</g>
</g>
<g inkscape:groupmode="layer" id="sulci_labels" inkscape:label="labels" />
</g>
</svg>Copy the
<g inkscape:label="…">groups out of#sulci_shapesand into the subject's existing#sulci_shapesgroup.
Do not paste the whole <g id="sulci"> layer. SVGOverlay keys its layers by
inkscape:label, so a second layer labeled sulci silently replaces the subject's own — every
sulcus already in that file disappears. The downloaded file repeats this warning in an XML comment.
Then db.get_overlay(subject) exposes the new curves under svg.sulci, and
quickflat.make_figure(…, with_sulci=True), the WebGL viewer, and Inkscape all render them.
- The paths never close. A missing trailing
Zis the only on-disk marker separating a sulcus from an ROI inoverlays.svg— both arefill:none. - Same-named sulci merge into one group. Trace a sulcus on each hemisphere and give both strokes
the same name; they become one
<g inkscape:label="CS">with a<path>child each, exactly as pycortex's ownCaSis stored. Duplicate names are the intended workflow, not a mistake. - Sulci carry no vertex membership. pycortex stores none either — there is no
get_sulci_verts; sulci are display geometry. - The
labelslayer is present and empty, and that is deliberate. pycortex computes each sulcus's label position from its path geometry at load (Shape.get_labelpos, one label per path, so a two-hemisphere sulcus is labeled twice for free) and writesdata-ptidxitself from that position.data-ptidxis pycortex's output, not its input: a realoverlays.svgcontains zero<text>elements, andLabels.__init__readsfloat(text.get('x'))off every<text>it finds, so a label carrying only a vertex index would makedb.get_overlay()raiseTypeError. The empty layer itself is mandatory —_find_layer(layer, "labels")raisesValueErrorwithout it. (roidraw's live, in-browser overlay does place labels bydata-ptidx; that is the WebGL viewer's own convention, and it stops at the browser.) - The on-disk
styleis for Inkscape's benefit only. pycortex overwrites every path's style at load from[overlay_paths], andquickflatre-applies[sulci_paths]at render time.
Export is one-way: sulci are not re-imported from SVG.
Three layers; only the adapter knows the host viewer.
core/ pure JS — no DOM, no THREE, no host globals (unit-tested under node)
geom.js point-in-polygon, bounds, RDP simplify, Chaikin smooth, ndc↔pixel, centroid
selection.js projected vertices + polygon → selected vertex set (works in px OR uv)
outline.js polygon → ordered boundary ring of vertices (+ label vertex)
bezier.js fit an editable bezier — closed (ROI ring) or open (sulcus trace) — sample it,
edit it; owns the segment topology (segCount/segControls/minAnchors) that the
samplers, the editor and the SVG path writer all read
transform.js uv↔px homography (place/grab edit knots; map a traced stroke back to uv)
shape-model.js the shape collection (ROIs + sulci + points), default names, the vertexset-v2 ROI
and points-v1 export/import
svg-export.js pure writer for pycortex overlays.svg sulci markup (paths only, never labels)
svg-path.js bezier / outline ring → SVG path `d` in the overlay's viewBox coordinates
draw-mode.js the Draw state machine (the "reached flat" latch, flat-only for the ROI/sulcus tools)
+ the MODE and TOOL names
timer-set.js tracked setTimeouts + retry polls that one clear() cancels (controller + adapter)
hemis.js the hemisphere names, in the order every loop walks them, + isHemi()
adapter/ the ViewerAdapter CONTRACT + one host implementation
viewer-adapter.js documented interface the core/ui depend on, + uvPxCorrespondences()
(the one place projectVerticesInUvBounds is flattened for a homography fit)
pycortex-adapter.js projection, camera, host chrome — together with the two files below, the
only code that touches pycortex internals
pycortex-overlay.js the "drawn ROIs" layer inside pycortex's SVG overlay (paths, labels,
the layer toggle, the sulci export read-back)
pycortex-points.js drawn points: the side-effect-free vertex pick + one marker per point in
the picker's marker groups (works without a flatmap)
ui/ host-agnostic DOM components (talk only to core + adapter)
overlay-canvas.js base class: the transparent canvas over the surface (size/position sync,
event→px, teardown) that both overlays below extend
lasso-overlay.js bezier-edit-overlay.js draw-panel.js mode-toggle.js roidraw.css
point-picker.js the Point tool's click listener on the surface canvas (no overlay; drags rotate)
overlay-geom.js pure hit-testing math for the edit overlay (no DOM; unit-tested)
dom-utils.js button() + isTextEntry(): the one "is the user typing?" rule every key handler uses
draw-pipeline.js ROI: lasso → select → fit bezier → re-derive membership.
Sulcus: trace → px→uv via homography → fit open bezier → label vertex
(the label is for the live overlay only; the export carries none).
(pure; uses core + an adapter)
index.js controller wiring core + adapter + ui; exposes window.ROIDraw
io.js browser file I/O for export/import (download a text file, read a picked file)
build.mjs esbuild → dist/roidraw.bundle.js (CSS inlined)
Implement adapter/viewer-adapter.js for your viewer
(projectVertices, allVertexUV, vertexUV, projectVerticesInUvBounds, setOverlayLayer,
flatten, setCameraTarget/setCameraRadius, zoom/pan, onMixChange, …) and construct it
in index.js in place of PycortexAdapter. The pure core/ and ui/ are reused unchanged.
Every pycortex-specific quirk is quarantined in adapter/: the flat-offset, pivot-matrix refresh
and control-panel internals in pycortex-adapter.js; the SVG viewBox coords and label data-ptidx
convention in pycortex-overlay.js.
npm install # one-time (esbuild + eslint)
npm run build # -> dist/roidraw.bundle.jsnpm test # lints, builds the bundle, then runs the JS suite (node) + Python tooling tests
npm run lint # eslint alone (eslint.config.js: recommended rules + this repo's conventions)The JS suite layers property-based geometry invariants (closed and open curves), the Draw-mode
state machine, the draw pipeline (driven headless against a synthetic-surface adapter), the pure
overlays.svg writer, the edit-overlay hit-testing, the bezier segment-topology agreement between
the samplers/editor/adapter path writer, an adapter-contract guard, a host preflight, and a smoke
test of the built bundle. CI (.github/workflows/test.yml) runs it on every push and pull request to main. See
TESTING.md for what each layer guarantees — and the gaps (live-browser integration)
it can't.
- JS: ES modules,
const/let, 4-space indent, double quotes, semicolons;camelCasefor functions and variables,UPPER_SNAKEfor module constants, a leading_for private methods and for deliberately unused parameters. A file starts with a block comment saying what it owns and what it must not know about.npm run lintenforces the mechanical part. - Python (
bake.py,examples/,test/test_*.py): PEP 8,unittest, the same 4-space/snake_case conventions as pycortex itself.bake.pyandtest/test_*.pyare stdlib only;examples/needs pycortex. - One definition per rule. Anything two code paths must agree on — the bezier's segment topology, the overlay's coordinate mapping, the "is the user typing?" test, default shape names, the timer bookkeeping — lives in exactly one module and is imported from there (see the Architecture notes above). If you find the same rule restated in two places, that is the bug.
- Node ≥ 20.19 to build/test the JS (eslint 10's floor).
- Python 3 for
bake.pyand thetest/test_*.pysuite, both stdlib only. The example (examples/make_viewer.py) additionally needs pycortex (Python ≤ 3.12) in.venv.
General pycortex viewer-maintenance tooling (reengine.py, fixups.py, add_help.py,
convert_huth.py) once lived here and was moved out in July 2026 to a separate tooling repository
(jackgallant/pycortex-viewer-tools, currently private). This repo is the ROI/sulcus-drawing
project only; bake.py stays because it injects the drawing bundle.
{ "format": "pycortex-roidraw/points-v1", "surface": "mni152", "points": [ { "name": "VWFA", "color": "#e6194b", "hemi": "left", "viewerVertex": 94268, "xyz": [-32.4, 33.6, 34.0] } ] }