Skip to content

Commit 7d4e847

Browse files
jackgallantclaude
andcommitted
Release v0.3.4: colored ROI outlines + 3 audit-followup fixes
Follow-up to the v0.3.3 adversarial audit (four findings from a full-project review): 1. Colored ROI outlines. The palette color was stored/exported and shown as a panel swatch but the baked outline was always white, so the swatch meant nothing on the surface. The adapter now strokes each ROI in its color over a white halo (kept legible on data + anatomy). Imported colors run through a hex-only safeColor() before entering the SVG style attribute. 2. Bezier/vertex consistency. deriveRoiFromLasso no longer attaches a fitted bezier when re-derivation from it encloses zero vertices — it would leave the source-of-truth curve disagreeing with the fallback lasso vertices. New headless test covers the drop + the normal-case equality. 3. Display-mode framing. _onMix now auto-frames only while in Draw mode, so a user unfolding the surface in Display no longer has the camera fight their zoom/pan every morph frame. 4. _worldOf hardening. Apply the flatoff offset to our own vector instead of mutating get_position()'s returned pos (may be shared/cached in mriview). Suite 93 -> 94 JS tests. Bundle rebuilt (94,844 B). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 21fea47 commit 7d4e847

7 files changed

Lines changed: 62 additions & 17 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
In-browser **ROI drawing + export** for [pycortex](https://github.com/gallantlab/pycortex) WebGL
44
viewers. Lasso a region on the flattened cortical surface; the stroke is fitted to a smooth,
5-
**editable bezier** that renders as a white outline + label **baked into the surface** (so it
5+
**editable bezier** that renders as a colored outline (its palette color, over a white halo for
6+
legibility) + label **baked into the surface** (so it
67
occludes and morphs correctly), and exports to a portable JSON. The bezier is stored alongside the
78
vertex set, so reloaded ROIs can be re-edited by dragging their control points.
89

adapter/pycortex-adapter.js

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,18 @@ const COLLAPSE_WINDOW_MS = 8000; // ...and on setData within this startup windo
3434
const DEFAULT_THICKMIX = 0.5; // thick-surface blend fed to get_position (constant; we don't expose it)
3535
const FALLBACK_TEX_W = 1024; // overlay viewBox width fallback when the surface reports no size
3636
const FALLBACK_TEX_H = 768; // overlay viewBox height fallback
37-
const OUTLINE_STROKE_PX = 3; // ROI white-outline stroke width, in overlay viewBox px
37+
const OUTLINE_STROKE_PX = 3; // ROI colored-outline stroke width, in overlay viewBox px
38+
const OUTLINE_HALO_PX = 2; // extra width of the white halo drawn under the colored stroke
39+
const OUTLINE_FALLBACK_COLOR = "#ffffff"; // stroke when an ROI has no (valid) color
3840
const LABEL_FONT_PT = 14; // ROI label font size, in pt
3941

42+
// A CSS hex color (#rgb / #rrggbb / #rrggbbaa) or the fallback. ROI colors are our own palette, but an
43+
// imported file could carry anything, and the value goes into an SVG style attribute — restrict it to
44+
// a hex literal so it can't smuggle in extra style declarations.
45+
function safeColor(c) {
46+
return (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c)) ? c : OUTLINE_FALLBACK_COLOR;
47+
}
48+
4049
// Vertex count of a THREE BufferAttribute (pycortex's old three.js lacks `.count`).
4150
function attrCount(attr) {
4251
if (attr.count !== undefined && !isNaN(attr.count)) return attr.count;
@@ -174,10 +183,13 @@ export class PycortexAdapter extends ViewerAdapter {
174183

175184
// World position of geometry-local vertex `i` at the current mix (incl. the flatoff offset
176185
// so it lands on the *rendered* mesh, not floating above it). Mutates+returns this._v.
186+
// Applies the flatoff offset to our OWN vector (never to get_position's returned `pos`, which
187+
// may be a shared/cached vector inside mriview — mutating it would corrupt host state).
177188
_worldOf(pd, mw, i, surfmix, foy) {
178189
const gp = this.mriview.get_position(pd, surfmix, this._thickmix, i).pos;
179-
gp.y -= foy;
180-
return this._v.copy(gp).applyMatrix4(mw);
190+
this._v.copy(gp);
191+
this._v.y -= foy;
192+
return this._v.applyMatrix4(mw);
181193
}
182194

183195
projectVertices({ subsample = 1 } = {}) {
@@ -430,9 +442,16 @@ export class PycortexAdapter extends ViewerAdapter {
430442
for (const roi of rois) {
431443
const d = this._roiSvgPath(roi, W, H);
432444
if (d) {
445+
// White halo under a colored stroke: the halo keeps the outline legible on any
446+
// background (colored data or white anatomy), while the color carries the ROI
447+
// identity the panel swatch shows. Same path `d`, drawn wider + white underneath.
448+
const halo = doc.createElementNS(SVGNS, "path");
449+
halo.setAttribute("d", d);
450+
halo.setAttribute("style", "fill:none;stroke:#ffffff;stroke-width:" + (OUTLINE_STROKE_PX + OUTLINE_HALO_PX) + ";stroke-opacity:0.9");
451+
shapesEl.appendChild(halo);
433452
const path = doc.createElementNS(SVGNS, "path");
434453
path.setAttribute("d", d);
435-
path.setAttribute("style", "fill:none;stroke:#ffffff;stroke-width:" + OUTLINE_STROKE_PX + ";stroke-opacity:1");
454+
path.setAttribute("style", "fill:none;stroke:" + safeColor(roi.color) + ";stroke-width:" + OUTLINE_STROKE_PX + ";stroke-opacity:1");
436455
shapesEl.appendChild(path);
437456
}
438457
const ptidx = this._labelPtidx(roi.labelVert);

adapter/viewer-adapter.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,11 @@ export class ViewerAdapter {
6363

6464
/**
6565
* Create/replace a named overlay layer rendered INTO the surface (so it occludes and morphs
66-
* like built-in ROIs). `rois` carries, per ROI, the boundary ring + label vertex (and, when
67-
* present, an editable flat-UV `bezier` the adapter renders as a smooth cubic path); the
68-
* adapter converts vertices/bezier→uv→layer geometry.
66+
* like built-in ROIs). `rois` carries, per ROI, the boundary ring + label vertex + display
67+
* color (and, when present, an editable flat-UV `bezier` the adapter renders as a smooth cubic
68+
* path); the adapter converts vertices/bezier→uv→layer geometry and strokes it in the ROI color.
6969
* @param {string} name
70-
* @param {Array<{name, outline:[{h,g}], labelVert:{h,g}, bezier?}>} rois
70+
* @param {Array<{name, color?, outline:[{h,g}], labelVert:{h,g}, bezier?}>} rois
7171
*/
7272
setOverlayLayer(_name, _rois) { throw new Error("ViewerAdapter.setOverlayLayer not implemented"); }
7373

draw-pipeline.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,17 @@ export function deriveRoiFromLasso(adapter, pts) {
7272

7373
const lassoRing = buildOutline(pts, sel0); // px-space ring of the stroke
7474
const ringUv = ringToUv(adapter, lassoRing);
75-
const bezier = ringUv && ringUv.length >= 3 ? fitClosedBezier(ringUv) : null;
76-
// membership from the bezier when we have one; otherwise keep the raw lasso selection
77-
const derived = bezier ? roiFromBezier(adapter, bezier) : null;
78-
const sel = (derived && derived.total) ? derived : {
75+
const fitted = ringUv && ringUv.length >= 3 ? fitClosedBezier(ringUv) : null;
76+
// Prefer bezier-derived membership so the stored vertices match the editable curve. But only keep
77+
// the bezier if it actually encloses something: a curve that re-derives to zero vertices (a very
78+
// thin/tiny ROI the smoothing shrank past every vertex) would leave the bezier — the source of
79+
// truth for the drawn outline and future edits — disagreeing with the fallback lasso vertices. In
80+
// that case drop it, so the ROI stays a consistent (non-editable) vertex set.
81+
const derived = fitted ? roiFromBezier(adapter, fitted) : null;
82+
if (derived && derived.total)
83+
return { left: derived.left, right: derived.right, outline: derived.outline, labelVert: derived.labelVert, bezier: fitted, total: derived.total };
84+
return {
7985
left: sel0.left, right: sel0.right, outline: lassoRing,
80-
labelVert: pickLabelVertex(sel0), total: sel0.total,
86+
labelVert: pickLabelVertex(sel0), bezier: null, total: sel0.total,
8187
};
82-
return { left: sel.left, right: sel.right, outline: sel.outline, labelVert: sel.labelVert, bezier, total: sel.total };
8388
}

index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ class ROIDrawer {
9595
if (this._dm.noteMix(this.adapter.isFlat()).exit) { this.setMode("display"); return; }
9696
this._updateDrawActive(); // lasso turns on once the flatten finishes
9797
if (this.editOverlay.isEditing()) this.editOverlay.reproject(); // keep knots on the surface
98-
this._frame();
98+
// Auto-frame only while drawing (so Draw's flatten glide stays centered). In Display the user
99+
// owns the camera; re-framing on every unfold-slider morph there would fight their zoom/pan.
100+
if (this.mode === "draw") this._frame();
99101
this._renderStatus();
100102
}
101103

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pycortex-roidraw",
3-
"version": "0.3.3",
3+
"version": "0.3.4",
44
"private": true,
55
"type": "module",
66
"description": "Pycortex WebGL viewer with in-browser ROI drawing + editing + export. Modular: pure core / viewer adapter / UI.",

test/draw-pipeline.test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,24 @@ test("backfillLabel: returns null for an empty/uv-less ring", () => {
101101
assert.strictEqual(backfillLabel(adapter(), []), null);
102102
});
103103

104+
test("deriveRoiFromLasso: a returned bezier always matches the stored vertices; else it is dropped", () => {
105+
// Force the fallback branch deterministically: a surface where the lasso projection still selects
106+
// vertices (projectVertices non-empty) but the uv-space membership is empty (allVertexUV empty), so
107+
// roiFromBezier re-derives to zero. The pipeline must NOT keep a bezier that encloses nothing.
108+
const base = adapter();
109+
const starved = Object.create(base);
110+
starved.allVertexUV = () => ({ left: { idx: [], uv: [] }, right: { idx: [], uv: [] } });
111+
const roi = deriveRoiFromLasso(starved, lassoUvRect(base, 0.25, 0.25, 0.75, 0.75));
112+
assert.ok(roi.total > 0, "the lasso still selects vertices (fallback path)");
113+
assert.strictEqual(roi.bezier, null, "a bezier that encloses nothing must not be attached");
114+
115+
// and in the normal case the attached bezier's re-derived membership equals the stored vertices
116+
const ok = deriveRoiFromLasso(adapter(), lassoUvRect(base, 0.25, 0.25, 0.75, 0.75));
117+
assert.ok(ok.bezier, "normal case keeps the editable bezier");
118+
assert.deepStrictEqual(new Set(roiFromBezier(adapter(), ok.bezier).left), new Set(ok.left),
119+
"stored vertices equal the bezier's own membership (no disagreement)");
120+
});
121+
104122
test("round-trip: a drawn lasso's bezier re-derives a non-empty membership inside the lassoed region", () => {
105123
const a = adapter();
106124
const pts = lassoUvRect(a, 0.3, 0.3, 0.7, 0.7);

0 commit comments

Comments
 (0)