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 viewer — a static one (like a make_static export) or a
freshly generated/dynamic one.
-
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, non-destructively.
python bake.py path/to/viewer_dir # adds the bundle + the two <script> tags
# Dynamic viewer: generate a fresh pycortex viewer with drawing baked in (example, dummy data).
.venv/bin/python examples/make_viewer.pyA Display / Draw toggle sits at the top of the viewer.
- Display — the normal pycortex viewer + control panel.
- Draw — the brain flattens and the draw panel appears. Drawing is flat-only: inflating the surface (the unfold slider) returns you to Display.
At the top of the panel a ROI / Sulcus selector picks what a plain drag 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 |
| 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), and the count column shows enclosed vertices for an ROI, anchors for a sulcus. Below the list sit Export ROIs (JSON) / Export sulci (SVG) / Import / Clear all. 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.
Per-hemisphere subject 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:
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 labelled 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 labelled 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 adapter's 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), default names, the vertexset-v2 ROI export/import
svg-export.js pure writer for pycortex overlays.svg sulci markup (paths only, never labels)
draw-mode.js the flat-only Draw state machine (the "reached flat" latch) + the MODE names
timer-set.js tracked setTimeouts that one destroy() cancels (controller + adapter share it)
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 the ONLY file that touches pycortex internals
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
overlay-geom.js pure hit-testing math for the edit overlay (no DOM; unit-tested)
dom-utils.js 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
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 point
index.js at it. The pure core/ and ui/ are
reused unchanged. Every pycortex-specific quirk (the flat-offset, pivot-matrix refresh, SVG
viewBox coords, label data-ptidx convention, control-panel internals) is quarantined in
pycortex-adapter.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. 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, stdlib only,unittest, the same 4-space/snake_case conventions as pycortex itself. - 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 ≥ 18 to build/test the JS.
- Python 3 for
bake.py(stdlib only). The dynamic example (examples/make_viewer.py) additionally needs pycortex (Python ≤ 3.12) in.venv.
This repo is written to be incorporated into pycortex itself (proposed in gallantlab/pycortex#642) with a diff that reads native there. The whole PR is generated mechanically:
npm run build
python upstream/stage_into_pycortex.py /path/to/pycortex # on a branch; `git diff` = the PRThat stages four things, modeled line-for-line on pycortex's merged pattern for optional webgl features (the guided-tour PR, #660):
| In pycortex | What |
|---|---|
cortex/webgl/resources/js/roidraw.js |
the built bundle, global roidraw (lowercase, like mriview/svgoverlay) |
cortex/webgl/template.html |
a {% if roidraw %} block: the script tag + the same one-line window.ROIDraw.autoAttach() bootstrap bake.py injects |
cortex/webgl/view.py |
make_static(..., roidraw=False) — kwarg, docstring, template flag |
cortex/tests/test_webgl_roidraw.py |
renders the template with the flag on/off (mirrors test_webgl_tour.py) |
Then cortex.webgl.make_static(outpath, data, roidraw=True) bakes drawing into any static viewer.
Deliberate choices, for pycortex compatibility:
- One self-contained file, CSS inside the JS. pycortex's
html_embedpipeline (cortex/webgl/htmlembed.py) parses stylesheets with a non-nesting brace regex, so a separateroidraw.csscould be silently mangled the moment it grew a nested at-rule; and_embed_jsregex-rewritesnew Worker(...)/attr('src', ...)inside every embedded script — the bundle contains neither pattern, andtest/test_upstream.pypins that it never grows one. - The staging script's patches are transactional and loud: if pycortex drifts and an anchor goes missing or ambiguous, nothing is written and the error names the anchor.
- The roidraw source stays modular ES (this repo's tests depend on it); what pycortex receives
is the single script-tag-ready file its viewers load, like every other
resources/js/*.js.
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/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, ... ] } } ] }