Skip to content

Commit 6587040

Browse files
committed
Add get_template_roi_tree: Template ROI Browser endpoint
Replaces the legacy v2 frontend path where VFBTreeConfiguration.js POSTed a multi-step Cypher with UNWIND nodes(p) AS n UNWIND nodes(p) AS m straight to pdb.virtualflybrain.org (Basic neo4j:vfb baked in JS source). That query timed out at 40s on JFRC2 in production; the new endpoint returns the same tree in ~1.7s. What this adds: - get_template_roi_tree(template_short_form) in vfb_queries.py — a single-statement Cypher (no UNWIND-pairs cartesian) that anchors on the template's INSTANCEOF root Class, walks through part_of and SUBCLASSOF (depth 0..20) down to every Class that has a painted-domain Individual on this template, and returns the nodes / edges / painted rows needed to assemble a tree in Python. Decorated with @with_solr_cache('template_roi_tree') for the standard 90-day TTL. - get_template_roi_tree_cached wrapper in cached_functions.py + monkey-patch registration in patch_vfbquery_with_caching. - "TemplateROIBrowser" entry in ha_api.QUERY_TYPE_MAP routing to get_template_roi_tree. Response shape — self-describing, single round trip: { "template": {"short_form", "label"}, "anatomy_root": {"short_form", "label"} | null, "summary_md": markdown header / blurb for tooltips, "tree": [ { "id": "FBbt_…", "label": str, "painted_domain": [{"individual_id", "individual_label"}, ...], "summary_md": markdown per-node tooltip, "children": [...] } ], "painted_domain_index": { "<individual_id>": {"class_id", "class_label"}, ... } } Design notes: - painted_domain is always a LIST (empty when none). Adult T1 Leg has bilateral L/R painted Individuals on the same Class; a single-value field would silently drop one side. - FBbt's DAG character is preserved — multi-parent classes (e.g. adult cerebrum sits under both supraesophageal zone and central brain) appear under each parent. Matches today's VFBTree behaviour. Cycle guard via ancestor-chain tracking, not flattening. - painted_domain_index is the reverse lookup the v2 visibility-toggle handler needs: scene reports "Individual X just hidden" -> map back to the Class node to grey the row. - All IDs are short_form strings; no Neo4j internal ids (those aren't stable across DB rebuilds, which the legacy query relied on). - template_short_form is interpolated into a Cypher string literal (matching every other query in this module — neo4j_client.commit_list doesn't pass parameters). A new _VFB_SHORT_FORM_RE guard rejects anything outside [A-Za-z0-9_]+ before interpolation. Tested against 10 templates (snapshots in projects/template-roi-browser/PROBE_NOTES.md): JRC2018Unisex, JFRC2, JRC_FlyEM_Hemibrain, Ito2014, JRC2018UnisexVNC, VNS Court2018, L1 / L3 larval CNS, Adult T1 Leg, Adult Head. All cold-cache runs under 4s. JFRC2 baseline: 70 unique nodes, 58 painted Individuals, depth 6 — matches the v2-dev rendered tree byte-for-byte. No __version__ bump — publish_to_pypi workflow seds the version from the git tag.
1 parent 2f1f2e8 commit 6587040

3 files changed

Lines changed: 239 additions & 0 deletions

File tree

src/vfbquery/cached_functions.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def is_valid_term_info_result(result):
7676
get_similar_morphology_nb_exp as _original_get_similar_morphology_nb_exp,
7777
get_similar_morphology_userdata as _original_get_similar_morphology_userdata,
7878
get_painted_domains as _original_get_painted_domains,
79+
get_template_roi_tree as _original_get_template_roi_tree,
7980
get_dataset_images as _original_get_dataset_images,
8081
get_all_aligned_images as _original_get_all_aligned_images,
8182
get_aligned_datasets as _original_get_aligned_datasets,
@@ -324,6 +325,29 @@ def get_painted_domains_cached(template_short_form: str, return_dataframe=True,
324325
"""
325326
return _original_get_painted_domains(template_short_form=template_short_form, return_dataframe=return_dataframe, limit=limit)
326327

328+
@with_solr_cache('template_roi_tree')
329+
def get_template_roi_tree_cached(template_short_form: str, return_dataframe: bool = False, force_refresh: bool = False):
330+
"""
331+
Enhanced get_template_roi_tree with SOLR caching.
332+
333+
Drives the v2 Template ROI Browser. Returns a nested tree of FBbt
334+
classes from the template's anatomy root down to every class that
335+
carries a painted-domain Individual, with per-node markdown summaries
336+
and a reverse-lookup index for the v2 visibility-toggle handler.
337+
338+
Args:
339+
template_short_form: Template short form (e.g. VFB_00101567)
340+
return_dataframe: Accepted for API symmetry. The query is
341+
hierarchical and always returns the dict
342+
shape regardless.
343+
force_refresh: Bypass SOLR cache and re-run the live
344+
Cypher.
345+
346+
Returns:
347+
ROI tree dict — see get_template_roi_tree docstring for shape.
348+
"""
349+
return _original_get_template_roi_tree(template_short_form=template_short_form, return_dataframe=return_dataframe)
350+
327351
def get_dataset_images_cached(dataset_short_form: str, return_dataframe=True, limit: int = -1, force_refresh: bool = False):
328352
"""
329353
Enhanced get_dataset_images with SOLR caching.
@@ -737,6 +761,7 @@ def patch_vfbquery_with_caching():
737761
vfb_queries.get_similar_morphology_nb_exp = get_similar_morphology_nb_exp_cached
738762
vfb_queries.get_similar_morphology_userdata = get_similar_morphology_userdata_cached
739763
vfb_queries.get_painted_domains = get_painted_domains_cached
764+
vfb_queries.get_template_roi_tree = get_template_roi_tree_cached
740765
vfb_queries.get_dataset_images = get_dataset_images_cached
741766
vfb_queries.get_all_aligned_images = get_all_aligned_images_cached
742767
vfb_queries.get_aligned_datasets = get_aligned_datasets_cached
@@ -778,6 +803,7 @@ def patch_vfbquery_with_caching():
778803
vfbquery.get_similar_morphology_nb_exp = get_similar_morphology_nb_exp_cached
779804
vfbquery.get_similar_morphology_userdata = get_similar_morphology_userdata_cached
780805
vfbquery.get_painted_domains = get_painted_domains_cached
806+
vfbquery.get_template_roi_tree = get_template_roi_tree_cached
781807
vfbquery.get_dataset_images = get_dataset_images_cached
782808
vfbquery.get_all_aligned_images = get_all_aligned_images_cached
783809
vfbquery.get_aligned_datasets = get_aligned_datasets_cached

src/vfbquery/ha_api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ async def security_middleware(request, handler):
311311

312312
# Templates / datasets
313313
"PaintedDomains": "get_painted_domains",
314+
"TemplateROIBrowser": "get_template_roi_tree",
314315
"DatasetImages": "get_dataset_images",
315316
"AllAlignedImages": "get_all_aligned_images",
316317
"AlignedDatasets": "get_aligned_datasets",

src/vfbquery/vfb_queries.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4875,6 +4875,218 @@ def get_painted_domains(template_short_form: str, return_dataframe=True, limit:
48754875
return {"headers": {"id": {"title": "ID", "type": "selection_id", "order": -1}, "name": {"title": "Domain", "type": "markdown", "order": 0}, "type": {"title": "Type", "type": "text", "order": 1}, "description": {"title": "Definition", "type": "text", "order": 2}, "thumbnail": {"title": "Thumbnail", "type": "markdown", "order": 9}}, "rows": [{key: row[key] for key in ["id", "name", "type", "description", "thumbnail"]} for row in safe_to_dict(df, sort_by_id=False)], "count": total_count}
48764876

48774877

4878+
# Short_form syntactic guard for any value interpolated into a Cypher
4879+
# string-literal (Neo4j REST client doesn't pass parameters separately —
4880+
# all queries in this module interpolate). VFB short_forms are
4881+
# alphanumerics + underscore; the guard rejects anything that could break
4882+
# out of the literal.
4883+
_VFB_SHORT_FORM_RE = re.compile(r"^[A-Za-z0-9_]+$")
4884+
4885+
4886+
@with_solr_cache('template_roi_tree')
4887+
def get_template_roi_tree(template_short_form: str, return_dataframe=False):
4888+
"""Build a hierarchical ROI tree for a template.
4889+
4890+
Anchors on the template's INSTANCEOF root Class (so this works for
4891+
every template regardless of anatomical character — adult brain
4892+
variants, adult VNC, larval CNS, the Adult T1 Leg with muscles +
4893+
neuropils, Adult Head). Walks down through ``part_of`` and
4894+
``SUBCLASSOF`` to every Class that has a painted-domain Individual on
4895+
this template, materialising the nodes and edges required to render
4896+
the tree. FBbt's DAG character is preserved (multi-parent classes
4897+
appear under each parent), matching the legacy VFBTree behaviour.
4898+
4899+
Returned shape (`return_dataframe` is accepted for symmetry; this
4900+
query is hierarchical and always returns the dict shape):
4901+
4902+
{
4903+
"template": {"short_form", "label"},
4904+
"anatomy_root": {"short_form", "label"} | None,
4905+
"summary_md": "## ROI tree for <template> …",
4906+
"tree": [
4907+
{
4908+
"id": str,
4909+
"label": str | None,
4910+
"painted_domain": [
4911+
{"individual_id", "individual_label"}, ...
4912+
],
4913+
"summary_md": str,
4914+
"children": [...]
4915+
}
4916+
],
4917+
"painted_domain_index": {
4918+
"<individual_id>": {"class_id", "class_label"},
4919+
...
4920+
}
4921+
}
4922+
4923+
Notes:
4924+
4925+
- ``painted_domain`` is always a list (empty when the class has no
4926+
painted Individual on this template). T1 Leg has bilateral L/R
4927+
Individuals on the same Class, so a single-value field would
4928+
silently drop one side.
4929+
- ``painted_domain_index`` is the reverse lookup the v2 visibility
4930+
toggle needs: scene reports "Individual X just hidden" -> map
4931+
back to the Class node to grey the row.
4932+
- DAG cycle guard: tracks the ancestor chain so we don't recurse
4933+
indefinitely if part_of ever loops in FBbt.
4934+
"""
4935+
if not isinstance(template_short_form, str) or not _VFB_SHORT_FORM_RE.match(template_short_form):
4936+
raise ValueError(f"Invalid template_short_form: {template_short_form!r}")
4937+
4938+
cypher = (
4939+
f"MATCH (t:Template {{short_form: '{template_short_form}'}})"
4940+
f"-[:INSTANCEOF]->(root:Class:Anatomy) "
4941+
f"OPTIONAL MATCH (t)<-[:depicts]-(tc:Template)"
4942+
f"<-[ie:in_register_with]-(:Individual)"
4943+
f"-[:depicts]->(pd:Individual)-[:INSTANCEOF]->(painted_class:Class) "
4944+
f"WHERE exists(ie.index) "
4945+
f"WITH t, root, "
4946+
f"collect(distinct {{class_id: painted_class.short_form, "
4947+
f"class_label: painted_class.label, "
4948+
f"individual_id: pd.short_form, "
4949+
f"individual_label: pd.label}}) AS painted_rows "
4950+
f"WITH t, root, "
4951+
f"[r IN painted_rows WHERE r.class_id IS NOT NULL] AS painted_rows "
4952+
f"WITH t, root, painted_rows, [r IN painted_rows | r.class_id] AS leaf_ids "
4953+
f"OPTIONAL MATCH path = (root)<-[:SUBCLASSOF|part_of*0..20]-(leaf:Class) "
4954+
f"WHERE leaf.short_form IN leaf_ids "
4955+
f"WITH t, root, painted_rows, "
4956+
f"collect(distinct [n IN nodes(path) | "
4957+
f"{{id: n.short_form, label: n.label}}]) AS path_node_lists, "
4958+
f"collect(distinct [rel IN relationships(path) | "
4959+
f"{{parent: endNode(rel).short_form, "
4960+
f"child: startNode(rel).short_form, "
4961+
f"rel_type: type(rel)}}]) AS path_edge_lists "
4962+
f"RETURN t.short_form AS template_id, "
4963+
f" t.label AS template_label, "
4964+
f" root.short_form AS root_id, "
4965+
f" root.label AS root_label, "
4966+
f" apoc.coll.toSet(apoc.coll.flatten(path_node_lists)) AS nodes, "
4967+
f" apoc.coll.toSet(apoc.coll.flatten(path_edge_lists)) AS edges, "
4968+
f" painted_rows AS painted"
4969+
)
4970+
results = vc.nc.commit_list([cypher])
4971+
rows = get_dict_cursor()(results) if results else []
4972+
if not rows:
4973+
return _empty_roi_tree(template_short_form)
4974+
row = rows[0]
4975+
4976+
nodes_by_id = {n['id']: n.get('label') for n in (row.get('nodes') or []) if n and n.get('id')}
4977+
edges = [e for e in (row.get('edges') or []) if e and e.get('parent') and e.get('child')]
4978+
painted_rows = row.get('painted') or []
4979+
4980+
painted_by_class = {}
4981+
for r in painted_rows:
4982+
if not r.get('class_id'):
4983+
continue
4984+
painted_by_class.setdefault(r['class_id'], []).append({
4985+
'individual_id': r.get('individual_id'),
4986+
'individual_label': r.get('individual_label'),
4987+
})
4988+
4989+
children = {}
4990+
for e in edges:
4991+
children.setdefault(e['parent'], set()).add(e['child'])
4992+
4993+
template_label = row.get('template_label') or template_short_form
4994+
root_id = row.get('root_id')
4995+
root_label = row.get('root_label')
4996+
4997+
def _node_summary_md(class_id, class_label, painted_list, parent_label):
4998+
parts = [
4999+
f"**{class_label or class_id}** "
5000+
f"([{class_id}](http://virtualflybrain.org/reports/{class_id}))",
5001+
"",
5002+
]
5003+
if parent_label:
5004+
parts.append(f"Part of: *{parent_label}*.")
5005+
parts.append("")
5006+
if painted_list:
5007+
ind_lines = [
5008+
f"[{p.get('individual_label') or p.get('individual_id')}]"
5009+
f"(http://virtualflybrain.org/reports/{p.get('individual_id')})"
5010+
for p in painted_list if p.get('individual_id')
5011+
]
5012+
if ind_lines:
5013+
parts.append(
5014+
f"Painted domain on **{template_label}**: "
5015+
+ "; ".join(ind_lines) + "."
5016+
)
5017+
return "\n".join(parts).rstrip()
5018+
5019+
def _build(node_id, ancestors, parent_label):
5020+
if node_id in ancestors:
5021+
return None
5022+
label = nodes_by_id.get(node_id)
5023+
painted_list = painted_by_class.get(node_id, [])
5024+
new_ancestors = ancestors | {node_id}
5025+
kids = []
5026+
for c in children.get(node_id, ()):
5027+
built = _build(c, new_ancestors, label)
5028+
if built is not None:
5029+
kids.append(built)
5030+
kids.sort(key=lambda n: (n.get('label') or '').lower())
5031+
return {
5032+
'id': node_id,
5033+
'label': label,
5034+
'painted_domain': painted_list,
5035+
'summary_md': _node_summary_md(node_id, label, painted_list, parent_label),
5036+
'children': kids,
5037+
}
5038+
5039+
tree_root = _build(root_id, frozenset(), None) if root_id else None
5040+
5041+
painted_class_count = len(painted_by_class)
5042+
painted_individual_count = sum(len(v) for v in painted_by_class.values())
5043+
5044+
summary_md = (
5045+
f"## ROI tree for **{template_label}**\n\n"
5046+
f"Anatomy root: "
5047+
f"[{root_label or root_id}](http://virtualflybrain.org/reports/{root_id}) "
5048+
f"({root_id}).\n\n"
5049+
f"{painted_class_count} painted region"
5050+
f"{'s' if painted_class_count != 1 else ''} "
5051+
f"({painted_individual_count} painted individual"
5052+
f"{'s' if painted_individual_count != 1 else ''}) "
5053+
f"across the FBbt class hierarchy. Click the eye icon next to a "
5054+
f"region to toggle its overlay on the 3D viewer."
5055+
)
5056+
5057+
painted_index = {
5058+
p['individual_id']: {
5059+
'class_id': cid,
5060+
'class_label': nodes_by_id.get(cid),
5061+
}
5062+
for cid, plist in painted_by_class.items()
5063+
for p in plist
5064+
if p.get('individual_id')
5065+
}
5066+
5067+
return {
5068+
'template': {'short_form': row.get('template_id') or template_short_form,
5069+
'label': template_label},
5070+
'anatomy_root': ({'short_form': root_id, 'label': root_label}
5071+
if root_id else None),
5072+
'summary_md': summary_md,
5073+
'tree': [tree_root] if tree_root else [],
5074+
'painted_domain_index': painted_index,
5075+
}
5076+
5077+
5078+
def _empty_roi_tree(template_short_form):
5079+
"""Stable response shape when the template has no INSTANCEOF root
5080+
(data bug) — keeps v2's renderer happy."""
5081+
return {
5082+
'template': {'short_form': template_short_form, 'label': template_short_form},
5083+
'anatomy_root': None,
5084+
'summary_md': f"No ROI tree available for template `{template_short_form}`.",
5085+
'tree': [],
5086+
'painted_domain_index': {},
5087+
}
5088+
5089+
48785090
def get_dataset_images(dataset_short_form: str, return_dataframe=True, limit: int = -1):
48795091
"""List all images in a dataset."""
48805092
count_query = f"MATCH (c:DataSet {{short_form:'{dataset_short_form}'}})<-[:has_source]-(primary:Individual)<-[:depicts]-(channel:Individual)-[irw:in_register_with]->(template:Individual)-[:depicts]->(template_anat:Individual) RETURN count(primary) AS count"

0 commit comments

Comments
 (0)