Skip to content

Namespaces

Every dataset exposes its functionality through namespaces. Which ones exist depends on what the dataset can actually do — ds.segmentation is simply absent on neuPrint, and hasattr(ds, "segmentation") is False. See Capabilities.

Every method below takes the same neuron query (see Selecting neurons) and a version= override.

Annotations

Annotations

Annotations(ds)

Bases: _Namespace

Neuron metadata: types, sides, classes, somas.

Source code in connecto/core/namespaces.py
def __init__(self, ds):
    super().__init__(ds)
    self._memo: dict = {}  # per-handle: (source, version) -> raw table

get

get(x=None, *, fields: dict | None = None, source: str | None = None, raw: bool = False, version=None, units: str = 'nm', verbose: bool = True, **filters) -> DataFrame

Annotations, with canonical columns added and every raw column kept.

Canonical: id, type, side, class, nt, status, soma_x/y/z. type is coalesced from spec.fields["type"] in priority order; side is mapped to left/right/center. Pass raw=True for the untouched backend frame.

verbose (default True) prints one line saying which source is being read and whether it came from the remote server, the local cache, or a cache refresh. Set it False to silence that.

Source code in connecto/core/namespaces.py
@requires(Cap.ANNOTATIONS)
def get(
    self,
    x=None,
    *,
    fields: dict | None = None,
    source: str | None = None,
    raw: bool = False,
    version=None,
    units: str = "nm",
    verbose: bool = True,
    **filters,
) -> pd.DataFrame:
    """Annotations, with canonical columns added and every raw column kept.

    Canonical: ``id, type, side, class, nt, status, soma_x/y/z``. ``type`` is
    coalesced from ``spec.fields["type"]`` in priority order; ``side`` is
    mapped to ``left``/``right``/``center``. Pass ``raw=True`` for the
    untouched backend frame.

    ``verbose`` (default ``True``) prints one line saying which source is being
    read and whether it came from the remote server, the local cache, or a
    cache refresh. Set it ``False`` to silence that.
    """
    ds = self._ds
    v = ds._resolve_version_arg(version)
    src = (
        ds.spec.annotation_source(source)
        if source is not None
        else ds._annotation_source
    )
    if src is None:
        raise CapabilityError(
            f"{ds.label} has no annotation source configured."
        )

    table = self._table(src, v, verbose=verbose)

    if raw:
        return table.copy()

    ann = schemas.normalize_annotations(
        table, ds, id_column=src.id_column, fields=fields, version=v, units=units
    )

    if x is not None or filters:
        crit = to_criteria(x) if x is not None else None
        ids = ds.ids(crit, version=version) if crit is not None else None
        if filters:
            from .criteria import NeuronCriteria, resolve_criteria

            extra = resolve_criteria(NeuronCriteria(**filters), ds, version=version)
            ids = extra if ids is None else np.intersect1d(ids, extra)
        ann = ann[ann["id"].isin(ids)].reset_index(drop=True)

    return ann

search

search(term: str, *, version=None, regex: bool = True) -> DataFrame

Rows whose type, class or instance matches term.

Source code in connecto/core/namespaces.py
@requires(Cap.ANNOTATIONS)
def search(self, term: str, *, version=None, regex: bool = True) -> pd.DataFrame:
    """Rows whose `type`, `class` or `instance` matches ``term``."""
    ann = self.get(version=version, verbose=False)
    cols = [c for c in ("type", "class", "instance") if c in ann.columns]
    mask = pd.Series(False, index=ann.index)
    for col in cols:
        s = ann[col].astype("string")
        mask |= (
            s.str.contains(term, case=False, regex=regex, na=False)
            if regex
            else s.str.lower().eq(term.lower())
        )
    return ann[mask].reset_index(drop=True)

ids

ids(*, version=None) -> ndarray

Every annotated neuron in the dataset.

Source code in connecto/core/namespaces.py
@requires(Cap.ANNOTATIONS)
def ids(self, *, version=None) -> np.ndarray:
    """Every annotated neuron in the dataset."""
    got = self.get(version=version, verbose=False)
    return np.unique(got["id"].to_numpy(dtype="int64"))

Connectivity

Connectivity

Connectivity(ds)

Bases: _Namespace

Edges, adjacency and synapses.

Source code in connecto/core/namespaces.py
def __init__(self, ds):
    self._ds = ds

edges

edges(x, *, upstream: bool = True, downstream: bool = True, by_roi: bool = False, rois=UNSET, min_weight: int = 1, version=None, cache: bool = False, extra: bool = False, autapses: bool = False) -> DataFrame

Edges to/from the given neurons as pre, post, weight.

A closed schema: identical columns and dtypes on every backend, so bodyId_pre and pre_pt_root_id both come back as pre, and FlyWire's edge view does not smuggle in seventeen extra columns that hemibrain has never heard of. extra=True keeps whatever else the backend happened to return.

autapses (pre == post) default to off. They are nearly always segmentation errors, neuPrint omits them entirely, and caveclient's own synapse_query drops them by default - so leaving them in would make the same neuron have different connectivity depending on which backend you asked. Pass autapses=True if you want them; you will get them from every backend that has them.

Source code in connecto/core/namespaces.py
@requires(Cap.CONNECTIVITY)
def edges(
    self,
    x,
    *,
    upstream: bool = True,
    downstream: bool = True,
    by_roi: bool = False,
    rois=UNSET,
    min_weight: int = 1,
    version=None,
    cache: bool = False,
    extra: bool = False,
    autapses: bool = False,
) -> pd.DataFrame:
    """Edges to/from the given neurons as ``pre, post, weight``.

    A closed schema: identical columns and dtypes on every backend, so
    ``bodyId_pre`` and ``pre_pt_root_id`` both come back as ``pre``, and
    FlyWire's edge view does not smuggle in seventeen extra columns that
    hemibrain has never heard of. ``extra=True`` keeps whatever else the
    backend happened to return.

    ``autapses`` (pre == post) default to **off**. They are nearly always
    segmentation errors, neuPrint omits them entirely, and caveclient's own
    ``synapse_query`` drops them by default - so leaving them in would make the
    same neuron have different connectivity depending on which backend you
    asked. Pass ``autapses=True`` if you want them; you will get them from
    every backend that has them.
    """
    ds = self._ds
    v = ds._resolve_version_arg(version)
    ids = ds.ids(x, version=version)
    if not len(ids):
        return schemas.normalize_edges(
            pd.DataFrame(columns=["pre", "post", "weight"]), ds,
            colmap={}, version=v, extra=extra,
        )

    by_roi = ds._resolve_opt("by_roi", by_roi if by_roi else UNSET,
                             Cap.ROI_CONN, default=False, neutral=False)
    rois = ds._resolve_opt("rois", rois, Cap.ROI_CONN, default=None)

    entry = None
    if cache:
        entry = _cache.CacheEntry(
            ds.name, v, "edges",
            ids.tobytes(), upstream, downstream, by_roi, rois, min_weight,
        )
        if entry.exists():
            return schemas.normalize_edges(
                entry.read(), ds, colmap=ds._edge_colmap, version=v, extra=extra
            )

    frames = []
    if downstream:
        frames.append(
            ds._fetch_edges(pre=ids, post=None, version=v,
                            by_roi=by_roi, min_weight=min_weight, rois=rois)
        )
    if upstream:
        frames.append(
            ds._fetch_edges(pre=None, post=ids, version=v,
                            by_roi=by_roi, min_weight=min_weight, rois=rois)
        )
    if not frames:
        raise ValueError("One of `upstream` or `downstream` must be True.")

    raw = pd.concat(frames, ignore_index=True) if len(frames) > 1 else frames[0]

    if len(frames) > 1:
        # A query neuron connecting to another query neuron appears in both
        # the upstream and the downstream fetch.
        keys = [c for c in raw.columns if c in set(ds._edge_colmap.values())]
        dedup_on = [
            ds._edge_colmap[k] for k in ("pre", "post") if ds._edge_colmap.get(k) in keys
        ]
        if by_roi and ds._edge_colmap.get("roi") in raw.columns:
            dedup_on.append(ds._edge_colmap["roi"])
        if dedup_on:
            raw = raw.drop_duplicates(subset=dedup_on, ignore_index=True)

    if entry is not None:
        entry.write(raw)

    out = schemas.normalize_edges(
        raw, ds, colmap=ds._edge_colmap, version=v, extra=extra
    )
    if not autapses:
        prov = out.attrs["connecto"]
        out = out[out["pre"] != out["post"]].reset_index(drop=True)
        out.attrs["connecto"] = prov  # boolean masking doesn't reliably keep attrs
    return out

adjacency

adjacency(sources, targets=None, *, version=None, min_weight: int = 1) -> DataFrame

Weight matrix, rows = sources, columns = targets.

Source code in connecto/core/namespaces.py
@requires(Cap.CONNECTIVITY)
def adjacency(
    self,
    sources,
    targets=None,
    *,
    version=None,
    min_weight: int = 1,
) -> pd.DataFrame:
    """Weight matrix, rows = sources, columns = targets."""
    ds = self._ds
    src = ds.ids(sources, version=version)
    tgt = src if targets is None else ds.ids(targets, version=version)

    edges = self.edges(
        src, upstream=False, downstream=True, min_weight=min_weight, version=version
    )
    edges = edges[edges["post"].isin(tgt)]

    adj = (
        edges.pivot_table(
            index="pre", columns="post", values="weight",
            aggfunc="sum", fill_value=0,
        )
        .reindex(index=src, columns=tgt, fill_value=0)
        .astype("int32")
    )
    adj.index.name, adj.columns.name = "pre", "post"
    return schemas.stamp(adj, ds, query="adjacency", version=ds._resolve_version_arg(version))

synapses

synapses(x, *, pre: bool = True, post: bool = True, min_score=UNSET, transmitters=UNSET, rois=UNSET, version=None, units: str = 'nm') -> DataFrame

Individual synapses, positions in nanometres.

min_score and transmitters raise on datasets that don't have them, rather than being quietly ignored.

Source code in connecto/core/namespaces.py
@requires(Cap.SYNAPSES)
def synapses(
    self,
    x,
    *,
    pre: bool = True,
    post: bool = True,
    min_score=UNSET,
    transmitters=UNSET,
    rois=UNSET,
    version=None,
    units: str = "nm",
) -> pd.DataFrame:
    """Individual synapses, positions in nanometres.

    ``min_score`` and ``transmitters`` raise on datasets that don't have them,
    rather than being quietly ignored.
    """
    ds = self._ds
    v = ds._resolve_version_arg(version)
    ids = ds.ids(x, version=version)

    min_score = ds._resolve_opt("min_score", min_score, Cap.SYNAPSE_SCORES, default=None)
    transmitters = ds._resolve_opt(
        "transmitters", transmitters, Cap.NT_PER_SYNAPSE, default=False, neutral=False
    )
    rois = ds._resolve_opt("rois", rois, Cap.ROI_CONN, default=None)

    frames = []
    if pre:
        frames.append(ds._fetch_synapses(
            pre=ids, post=None, version=v,
            min_score=min_score, transmitters=transmitters, rois=rois))
    if post:
        frames.append(ds._fetch_synapses(
            pre=None, post=ids, version=v,
            min_score=min_score, transmitters=transmitters, rois=rois))
    if not frames:
        raise ValueError("One of `pre` or `post` must be True.")

    raw = pd.concat(frames, ignore_index=True) if len(frames) > 1 else frames[0]
    idcol = ds._synapse_colmap.get("id")
    if len(frames) > 1 and idcol in raw.columns:
        raw = raw.drop_duplicates(subset=[idcol], ignore_index=True)

    return schemas.normalize_synapses(
        raw, ds, colmap=ds._synapse_colmap, version=v, units=units
    )

synapse_counts

synapse_counts(x, *, by_roi: bool = False, version=None) -> DataFrame

Pre- and post-synapse counts per neuron.

Source code in connecto/core/namespaces.py
@requires(Cap.CONNECTIVITY)
def synapse_counts(self, x, *, by_roi: bool = False, version=None) -> pd.DataFrame:
    """Pre- and post-synapse counts per neuron."""
    ds = self._ds
    ids = ds.ids(x, version=version)

    out = self.edges(ids, version=version, by_roi=by_roi)
    group = ["roi"] if by_roi and "roi" in out.columns else []

    up = (
        out[out["post"].isin(ids)]
        .groupby(["post"] + group, as_index=False)["weight"].sum()
        .rename(columns={"post": "id", "weight": "post"})
    )
    down = (
        out[out["pre"].isin(ids)]
        .groupby(["pre"] + group, as_index=False)["weight"].sum()
        .rename(columns={"pre": "id", "weight": "pre"})
    )
    counts = pd.merge(down, up, on=["id"] + group, how="outer").fillna(0)
    for col in ("pre", "post"):
        counts[col] = counts[col].astype("int32")
    return counts[["id"] + group + ["pre", "post"]]

transmitters

transmitters(x, *, version=None) -> DataFrame

Predicted transmitter per neuron, from its presynapses.

Source code in connecto/core/namespaces.py
@requires(Cap.NT_PER_SYNAPSE)
def transmitters(self, x, *, version=None) -> pd.DataFrame:
    """Predicted transmitter per neuron, from its presynapses."""
    syn = self.synapses(x, pre=True, post=False, transmitters=True, version=version)
    if "nt" not in syn.columns:
        raise CapabilityError(f"{self._ds.label} returned no transmitter column.")
    counts = (
        syn.groupby(["pre", "nt"], observed=True).size().rename("n").reset_index()
    )
    top = counts.sort_values("n", ascending=False).drop_duplicates("pre")
    total = counts.groupby("pre", observed=True)["n"].sum().rename("total")
    top = top.merge(total, on="pre")
    top["confidence"] = top["n"] / top["total"]
    return (
        top.rename(columns={"pre": "id"})[["id", "nt", "confidence"]]
        .sort_values("id")
        .reset_index(drop=True)
    )

Skeletons

Skeletons

Skeletons(ds)

Bases: _Namespace

Skeletons, as navis TreeNeurons.

Source code in connecto/core/namespaces.py
def __init__(self, ds):
    self._ds = ds

get

get(x, *, output: str = 'navis', version=None, **opts)

Skeletons for the given neurons -> navis.NeuronList.

Source code in connecto/core/namespaces.py
@requires(Cap.SKELETONS)
def get(self, x, *, output: str = "navis", version=None, **opts):
    """Skeletons for the given neurons -> ``navis.NeuronList``."""
    from . import volume

    ds = self._ds
    v = ds._resolve_version_arg(version)
    ids = ds.ids(x, version=version)

    # A published precomputed skeleton is not backend data: it comes out of a
    # plain HTTPS bucket, canonical and in nanometres, whichever door fetched it.
    # So it is normalised as itself rather than through the backend's column map
    # and voxel scaling. (See PRECOMPUTED_SKELETON_COLMAP for what that cost.)
    precomputed = ds._skeleton_source(v) is not None
    colmap = volume.PRECOMPUTED_SKELETON_COLMAP if precomputed else ds._skeleton_colmap
    source_units = volume.PRECOMPUTED_SKELETON_UNITS if precomputed else None

    out = []
    for nid, raw in ds._fetch_skeletons(ids, v, **opts):
        nodes = schemas.normalize_skeleton(
            raw, ds, colmap=colmap, source_units=source_units
        )
        out.append((nid, nodes))

    if output == "raw":
        return {nid: nodes for nid, nodes in out}

    import navis

    neurons = []
    for nid, nodes in out:
        n = navis.TreeNeuron(nodes, id=nid, units="1 nm")
        n.name = str(nid)
        neurons.append(n)
    return navis.NeuronList(neurons)

dotprops

dotprops(x, *, k: int = 5, units: str = 'nm', version=None)

Dotprops, for NBLAST. Built from the skeleton.

units defaults to "nm", because everything connecto returns is in nanometres. NBLAST is calibrated in microns. Score it on nanometre dotprops and every pair collapses to the floor - two copies of the same cell type come back at -0.88 instead of +0.70 - which reads as "nothing is similar" rather than as an error. navis notices and logs a warning, but a log line is easy to miss.

So when the next step is NBLAST, ask for microns::

dp = ds.skeletons.dotprops(x, units="um")
navis.nblast(dp, dp)

If the dataset has an L2 cache, :meth:~connecto.backends.cave.l2.L2.dotprops gets there without building a skeleton first, and is much cheaper.

Source code in connecto/core/namespaces.py
@requires(Cap.SKELETONS)
def dotprops(self, x, *, k: int = 5, units: str = "nm", version=None):
    """Dotprops, for NBLAST. Built from the skeleton.

    ``units`` defaults to ``"nm"``, because everything connecto returns is in
    nanometres. **NBLAST is calibrated in microns.** Score it on nanometre dotprops
    and every pair collapses to the floor - two copies of the same cell type come
    back at -0.88 instead of +0.70 - which reads as "nothing is similar" rather than
    as an error. navis notices and logs a warning, but a log line is easy to miss.

    So when the next step is NBLAST, ask for microns::

        dp = ds.skeletons.dotprops(x, units="um")
        navis.nblast(dp, dp)

    If the dataset has an L2 cache, :meth:`~connecto.backends.cave.l2.L2.dotprops`
    gets there without building a skeleton first, and is much cheaper.
    """
    import navis

    if units not in ("nm", "um"):
        raise ValueError(f"`units` must be 'nm' or 'um', got {units!r}.")

    skels = self.get(x, version=version)
    if units == "um":
        skels = skels / 1000
    return navis.make_dotprops(skels, k=k)

Meshes

Meshes

Meshes(ds)

Bases: _Namespace

Meshes, as navis MeshNeurons.

Source code in connecto/core/namespaces.py
def __init__(self, ds):
    self._ds = ds

Voxels

Sparse volumes: every voxel belonging to a neuron, as an (N, 3) array.

The one namespace whose cost varies by three orders of magnitude between datasets, so it is also the one that talks about cost. hemibrain, maleCNS, MANC and fish2 are backed by DVID, which keeps a live per-body index and answers in a single request; aedes has a lookup service that does the same. FlyWire, BANC, FANC and MICrONS have a chunkedgraph, which keeps no such index — so the same question means reading dense blocks and masking them, touching thousands of voxels for every one it keeps. estimate() tells you which you are in for before you commit.

scale= never defaults to 0. A hemibrain neuron at scale 0 is 1.17 billion voxels; on the CAVE route the equivalent request is refused outright rather than left to look like a hang.

Voxels

Voxels(ds)

Bases: _Namespace

Sparse volumes: every voxel belonging to a neuron.

The one namespace whose cost varies by three orders of magnitude between datasets, so it is also the one that talks about cost. DVID and the aedes service keep a per-body index and answer in a single request; a chunkedgraph keeps none, and the same question there means reading dense blocks and masking them. estimate() says which you are in for before you commit to it.

Source code in connecto/core/namespaces.py
def __init__(self, ds):
    self._ds = ds

get

get(x, *, scale: int | None = None, output: str = 'navis', units: str = 'voxel', version=None, progress: bool = True, verbose: bool = False, **opts)

Sparse volumes for the given neurons.

Parameters

scale : int, optional Pyramid level; each level halves resolution. Defaults to something that returns a usable neuron without reading the whole brain - not 0, which for a hemibrain neuron is 1.17 billion voxels. ds.voxels.scales() lists what is available. output : "navis" | "raw" | "rle" navis gives VoxelNeurons; raw a dict of (N, 3) arrays; rle a dict of (M, 4) runs, which is ~20x smaller and is what the wire already carries. units : "voxel" | "nm" Coordinate space of raw output. navis output is always voxels plus the units metadata navis expects, so it plots and measures in nm regardless.

Source code in connecto/core/namespaces.py
@requires(Cap.VOXELS)
def get(
    self,
    x,
    *,
    scale: int | None = None,
    output: str = "navis",
    units: str = "voxel",
    version=None,
    progress: bool = True,
    verbose: bool = False,
    **opts,
):
    """Sparse volumes for the given neurons.

    Parameters
    ----------
    scale :     int, optional
                Pyramid level; each level halves resolution. Defaults to
                something that returns a usable neuron without reading the whole
                brain - **not** 0, which for a hemibrain neuron is 1.17 billion
                voxels. ``ds.voxels.scales()`` lists what is available.
    output :    "navis" | "raw" | "rle"
                ``navis`` gives ``VoxelNeuron``s; ``raw`` a dict of ``(N, 3)``
                arrays; ``rle`` a dict of ``(M, 4)`` runs, which is ~20x smaller
                and is what the wire already carries.
    units :     "voxel" | "nm"
                Coordinate space of ``raw`` output. ``navis`` output is always
                voxels *plus* the units metadata navis expects, so it plots and
                measures in nm regardless.
    """
    if units not in ("voxel", "nm"):
        raise ValueError(f"`units` must be 'voxel' or 'nm', got {units!r}.")
    if output not in ("navis", "raw", "rle"):
        raise ValueError(
            f"`output` must be 'navis', 'raw' or 'rle', got {output!r}."
        )

    from .. import voxels as _voxels

    ds = self._ds
    ds._resolve_version_arg(version)
    ids = ds.ids(x, version=version)
    if scale is None:
        scale = _voxels.default_scale(ds)

    from tqdm.auto import tqdm

    out = []
    for nid in tqdm(
        ids, desc="Voxels", disable=not progress or len(ids) < 2, leave=False
    ):
        runs, resolution, stats = _voxels.fetch(ds, int(nid), scale, **opts)
        if verbose:
            print(f"{nid}: {_voxels.rle.run_voxel_count(runs):,} voxels @ {resolution} nm | {stats}")
        out.append((int(nid), runs, resolution))

    if output == "rle":
        return {nid: runs for nid, runs, _ in out}

    if output == "raw":
        return {
            nid: (
                _voxels.to_nm(_voxels.rle.decode_runs(runs), res)
                if units == "nm"
                else _voxels.rle.decode_runs(runs)
            )
            for nid, runs, res in out
        }

    import navis

    neurons = []
    for nid, runs, res in out:
        # VoxelNeuron carries its own nm-per-voxel, so the coordinates stay in
        # the (compact, integral) voxel grid and navis still measures in nm.
        # Multiplying them out here would inflate the array and lose that.
        #
        # Units go in as a per-axis vector because these grids are routinely
        # anisotropic - aedes is 32x32x45, fish2 at scale 3 is 256x256x240 - and
        # a single scalar would be right about X and wrong about Z.
        n = navis.VoxelNeuron(
            _voxels.rle.decode_runs(runs),
            id=nid,
            units=(
                f"{res[0]} nm"
                if len(set(res)) == 1
                else [f"{r} nm" for r in res]
            ),
        )
        n.name = str(nid)
        neurons.append(n)
    return navis.NeuronList(neurons)

estimate

estimate(x, *, scale: int | None = None, version=None) -> DataFrame

What get() would cost, without doing it.

Meaningful mainly on the CAVE route, where the answer is a dense read whose size the caller controls. The indexed routes just say so.

Source code in connecto/core/namespaces.py
@requires(Cap.VOXELS)
def estimate(self, x, *, scale: int | None = None, version=None) -> pd.DataFrame:
    """What ``get()`` would cost, without doing it.

    Meaningful mainly on the CAVE route, where the answer is a dense read whose
    size the caller controls. The indexed routes just say so.
    """
    from .. import voxels as _voxels

    ds = self._ds
    # Validates the version even though the volume is read from the handle's own
    # source, so a bad `version=` raises here rather than being ignored.
    ds._resolve_version_arg(version)
    ids = ds.ids(x, version=version)
    if scale is None:
        scale = _voxels.default_scale(ds)
    return pd.DataFrame([_voxels.estimate(ds, int(i), scale) for i in ids])

scales

scales() -> tuple[int, ...]

Which scales this dataset serves.

That a scale is available says nothing about whether it is affordable - every CAVE dataset lists scale 0 and none of them can deliver a whole neuron at it. Use :meth:estimate for the cost.

Source code in connecto/core/namespaces.py
@requires(Cap.VOXELS)
def scales(self) -> tuple[int, ...]:
    """Which scales this dataset serves.

    That a scale is *available* says nothing about whether it is *affordable* -
    every CAVE dataset lists scale 0 and none of them can deliver a whole neuron
    at it. Use :meth:`estimate` for the cost.
    """
    from .. import voxels as _voxels

    return _voxels.scales(self._ds)

ROIs

ROIs

ROIs(ds)

Bases: _Namespace

Neuropils / brain regions.

Source code in connecto/core/namespaces.py
def __init__(self, ds):
    self._ds = ds

hierarchy

hierarchy()

ROI containment tree as a networkx.DiGraph.

Source code in connecto/core/namespaces.py
@requires(Cap.ROIS)
def hierarchy(self):
    """ROI containment tree as a ``networkx.DiGraph``."""
    return self._ds._fetch_roi_hierarchy()

Somas

Somas

Somas(ds)

Bases: _Namespace

Source code in connecto/core/namespaces.py
def __init__(self, ds):
    self._ds = ds

Visualisation

See the Neuroglancer guide for colouring, groups and layers.

Viz

Viz(ds)

Bases: _Namespace

Neuroglancer.

scene() hands back the raw dict; neuroglancer_url() hands back a link. Both take the same arguments - see :func:connecto.viz.construct_scene - so::

ds.viz.neuroglancer_url(ids, color_by="type")

is a link with every cell type in its own colour, and::

scene = ds.viz.scene(ids)
scene = add_skeleton_layer(scene, ds.skeletons.get(ids))
encode_url(scene, viewer="https://ngl.flywire.ai")

is the escape hatch when the canned scene is not enough.

Source code in connecto/core/namespaces.py
def __init__(self, ds):
    self._ds = ds

construct_scene

construct_scene(ds, ids=None, *, seg_colors=None, seg_groups=None, invis_segs=None, color_by=None, group_by=None, palette=None, position=None, units: str = 'nm', layout: str = 'xy-3d', image: bool = True, layers=None, viewer: str | None = None, dialect: str | None = None, version=None) -> dict

Build a neuroglancer scene for this dataset.

Parameters

ids : IDs to select. Order is meaningful: seg_colors and seg_groups, when given as plain lists, zip onto it. seg_colors : One colour for all of them, a list of colours (one per ID), or a {id: colour} dict. Anything matplotlib understands is a colour: "red", "#ff0000", (1, 0, 0). color_by : Colour by a label instead: an annotation field ("type", "class", ...) or an array of labels aligned to ids. Each distinct label gets a colour from palette. Mutually exclusive with seg_colors. seg_groups : Split the IDs across separate layers, so they can be toggled independently. A list of group names (one per ID), a {id: group} dict, or a {group: [ids]} dict. group_by : As color_by, but for groups: an annotation field or an array. invis_segs : Selected but not rendered - loaded and listed in the layer with their visibility switched off, ready to be toggled on in the viewer. position : Where to point the camera, in nanometres (see units). units : "nm" (the default, and what every other connecto method returns) or "voxel" for coordinates already in viewer space. image : Whether to include the EM image layer. Without it the segments float in a void, which is rarely what anyone wants. layers : Extra layer dicts to append verbatim.

Returns

scene : dict

Source code in connecto/viz/neuroglancer.py
def construct_scene(
    ds,
    ids=None,
    *,
    seg_colors=None,
    seg_groups=None,
    invis_segs=None,
    color_by=None,
    group_by=None,
    palette=None,
    position=None,
    units: str = "nm",
    layout: str = "xy-3d",
    image: bool = True,
    layers=None,
    viewer: str | None = None,
    dialect: str | None = None,
    version=None,
) -> dict:
    """Build a neuroglancer scene for this dataset.

    Parameters
    ----------
    ids :           IDs to select. Order is *meaningful*: ``seg_colors`` and
                    ``seg_groups``, when given as plain lists, zip onto it.
    seg_colors :    One colour for all of them, a list of colours (one per ID), or a
                    ``{id: colour}`` dict. Anything matplotlib understands is a colour:
                    ``"red"``, ``"#ff0000"``, ``(1, 0, 0)``.
    color_by :      Colour by a *label* instead: an annotation field (``"type"``,
                    ``"class"``, ...) or an array of labels aligned to ``ids``. Each
                    distinct label gets a colour from ``palette``. Mutually exclusive
                    with ``seg_colors``.
    seg_groups :    Split the IDs across separate layers, so they can be toggled
                    independently. A list of group names (one per ID), a
                    ``{id: group}`` dict, or a ``{group: [ids]}`` dict.
    group_by :      As ``color_by``, but for groups: an annotation field or an array.
    invis_segs :    Selected but *not* rendered - loaded and listed in the layer with
                    their visibility switched off, ready to be toggled on in the viewer.
    position :      Where to point the camera, in nanometres (see ``units``).
    units :         ``"nm"`` (the default, and what every other connecto method returns)
                    or ``"voxel"`` for coordinates already in viewer space.
    image :         Whether to include the EM image layer. Without it the segments float
                    in a void, which is rarely what anyone wants.
    layers :        Extra layer dicts to append verbatim.

    Returns
    -------
    scene :         dict
    """
    viewer, dialect = _viewer_and_dialect(ds, viewer, dialect)
    voxel = _voxel_size(ds)
    ids = _as_ids(ids)

    colors = _resolve_colors(ds, ids, seg_colors, color_by, palette, version)
    groups = _resolve_groups(ds, ids, seg_groups, group_by, version)
    invis = _as_ids(invis_segs)

    seg_source, img_source = _sources(ds, dialect)
    name = ds.name

    # With groups, the segments live in the group layers instead - otherwise every
    # neuron would be rendered twice, once by each layer, and the toggles would fight.
    base_segs = [] if groups else list(ids)

    seg_layer = _seg_layer(
        dialect, seg_source, name, base_segs, invis=invis, colors=colors, voxel=voxel
    )

    scene_layers = []
    if image and img_source:
        scene_layers.append({"type": "image", "source": img_source, "name": "image"})
    scene_layers.append(seg_layer)

    for group, members in (groups or {}).items():
        layer = _seg_layer(
            dialect, seg_source, group, members, invis=(), colors=colors, voxel=voxel
        )
        scene_layers.append(layer)

    scene_layers.extend(copy.deepcopy(list(layers or [])))

    scene = _base_scene(ds, dialect, voxel, layout, selected=name)
    scene["layers"] = scene_layers

    if position is not None:
        pos = _to_voxels(np.asarray(position, dtype=float).ravel(), voxel, units)
        if pos.size != 3:
            raise ValueError(f"`position` must be one x/y/z point, got {pos.size} values.")
        _set_position(scene, dialect, pos)

    return scene

add_annotation_layer

add_annotation_layer(scene: dict, coords, *, name: str | None = None, color=None, units: str = 'nm') -> dict

Add points, lines or ellipsoids as a new annotation layer.

The shape of coords picks the annotation type::

(N, 3)      points          x/y/z
(N, 2, 3)   line segments   start x/y/z, end x/y/z
(N, 4)      ellipsoids      x/y/z + radius

Coordinates are nanometres by default - so a synapse table from ds.connectivity.synapses() goes straight in - and are converted to the scene's own voxel size on the way. The scene is not modified; a new one comes back.

Source code in connecto/viz/neuroglancer.py
def add_annotation_layer(
    scene: dict, coords, *, name: str | None = None, color=None, units: str = "nm"
) -> dict:
    """Add points, lines or ellipsoids as a new annotation layer.

    The shape of ``coords`` picks the annotation type::

        (N, 3)      points          x/y/z
        (N, 2, 3)   line segments   start x/y/z, end x/y/z
        (N, 4)      ellipsoids      x/y/z + radius

    Coordinates are nanometres by default - so a synapse table from
    ``ds.connectivity.synapses()`` goes straight in - and are converted to the scene's
    own voxel size on the way. The scene is not modified; a new one comes back.
    """
    scene = copy.deepcopy(scene)
    dialect = _scene_dialect(scene)
    voxel = _scene_voxel_size(scene)

    coords = np.asarray(coords, dtype=float)
    if coords.ndim == 2 and coords.shape[1] == 3:
        pts = _to_voxels(coords, voxel, units)
        records = [
            {"type": "point", "point": p.tolist(), "id": uuid.uuid4().hex}
            for p in pts
        ]
    elif coords.ndim == 3 and coords.shape[1:] == (2, 3):
        pts = _to_voxels(coords.reshape(-1, 3), voxel, units).reshape(-1, 2, 3)
        records = [
            {
                "type": "line",
                "pointA": a.tolist(),
                "pointB": b.tolist(),
                "id": uuid.uuid4().hex,
            }
            for a, b in pts
        ]
    elif coords.ndim == 2 and coords.shape[1] == 4:
        centers = _to_voxels(coords[:, :3], voxel, units)
        # One radius in nanometres becomes three, because a voxel is not a cube: 40nm
        # deep and 4nm wide on FlyWire, so a sphere is 10x fewer voxels in z.
        radii = _to_voxels(np.repeat(coords[:, 3:4], 3, axis=1), voxel, units)
        records = [
            {
                "type": "ellipsoid",
                "center": c.tolist(),
                "radii": r.tolist(),
                "id": uuid.uuid4().hex,
            }
            for c, r in zip(centers, radii)
        ]
    else:
        raise ValueError(
            "`coords` must be (N, 3) points, (N, 2, 3) lines or (N, 4) ellipsoids "
            f"(x/y/z + radius), got shape {coords.shape}."
        )

    if not name:
        n = sum(1 for lyr in scene["layers"] if lyr.get("type") == "annotation")
        name = f"annotations{n or ''}"

    layer = {"type": "annotation", "name": name, "annotations": records}
    if dialect == "seunglab":
        layer["voxelSize"] = [float(v) for v in voxel]
        layer["annotationTags"] = []
    else:
        # The modern viewer needs to be told what space these numbers are in. Same
        # dimensions as the scene, so they land where the segments are.
        layer["source"] = {
            "url": "local://annotations",
            "transform": {"outputDimensions": scene["dimensions"]},
        }
    if color is not None:
        layer["annotationColor"] = _to_hex(color)

    scene["layers"].append(layer)
    return scene

add_skeleton_layer

add_skeleton_layer(scene: dict, x, *, name: str | None = None, color=None) -> dict

Add skeletons as line annotations - one layer per neuron.

Takes navis TreeNeuron s (a NeuronList is fine) and draws every parent-child edge as a line. Useful for showing a skeleton next to the segmentation it came from, or for putting a neuron from somewhere else - a CATMAID tracing, a transformed neuron - into the scene.

Units come off the neuron: navis carries them, so a micron-scale neuron (from ds.l2.dotprops(units="um"), say) is scaled correctly rather than landing a thousand times too close to the origin.

Source code in connecto/viz/neuroglancer.py
def add_skeleton_layer(scene: dict, x, *, name: str | None = None, color=None) -> dict:
    """Add skeletons as line annotations - one layer per neuron.

    Takes navis ``TreeNeuron``\\ s (a ``NeuronList`` is fine) and draws every
    parent-child edge as a line. Useful for showing a skeleton *next to* the
    segmentation it came from, or for putting a neuron from somewhere else - a CATMAID
    tracing, a transformed neuron - into the scene.

    Units come off the neuron: navis carries them, so a micron-scale neuron (from
    ``ds.l2.dotprops(units="um")``, say) is scaled correctly rather than landing a
    thousand times too close to the origin.
    """
    import navis

    neurons = x if isinstance(x, navis.NeuronList) else navis.NeuronList(x)

    colors = color
    if color is not None and not _is_single_color(color):
        colors = list(color)
        if len(colors) != len(neurons):
            raise ValueError(f"Got {len(colors)} colours for {len(neurons)} neurons.")

    for i, neuron in enumerate(neurons):
        if not isinstance(neuron, navis.TreeNeuron):
            raise TypeError(
                f"`add_skeleton_layer` needs skeletons (navis.TreeNeuron), got "
                f"{type(neuron).__name__}. Meshes have no edges to draw."
            )
        segments = _skeleton_segments(neuron)
        this_color = colors[i] if isinstance(colors, list) else colors
        scene = add_annotation_layer(
            scene,
            segments,
            name=name or str(neuron.name or neuron.id),
            color=this_color,
            units="nm",
        )
    return scene

build_url

build_url(ds, ids=None, *, shorten: bool = False, viewer: str | None = None, dialect: str | None = None, **kwargs) -> str

A neuroglancer URL showing the given neurons. See :func:construct_scene.

Source code in connecto/viz/neuroglancer.py
def build_url(
    ds,
    ids=None,
    *,
    shorten: bool = False,
    viewer: str | None = None,
    dialect: str | None = None,
    **kwargs,
) -> str:
    """A neuroglancer URL showing the given neurons. See :func:`construct_scene`."""
    viewer, dialect = _viewer_and_dialect(ds, viewer, dialect)
    scene = construct_scene(ds, ids, viewer=viewer, dialect=dialect, **kwargs)

    if not shorten:
        return encode_url(scene, viewer=viewer)

    if ds.backend_kind != "cave":
        raise ValueError("Only CAVE datasets can shorten URLs (via the state server).")
    state_id = ds.client.state.upload_state_json(scene)
    return ds.client.state.build_neuroglancer_url(state_id, ngl_url=viewer)

encode_url

encode_url(scene: dict, *, viewer: str = DEFAULT_VIEWER) -> str
Source code in connecto/viz/neuroglancer.py
def encode_url(scene: dict, *, viewer: str = DEFAULT_VIEWER) -> str:
    url = f"{viewer.rstrip('/')}/#!{urllib.parse.quote(json.dumps(scene))}"

    # A scene is a URL fragment, so a skeleton layer of a few thousand nodes is a few
    # thousand line annotations is a megabyte of link. Nothing rejects it: the browser
    # or the chat client simply truncates, and a truncated scene is a broken scene that
    # looks like a working one. Say so - and say what to do about it.
    if len(url) > _URL_WARN_AT:
        logger.warning(
            "This neuroglancer URL is %.1f MB. Browsers and chat clients truncate long "
            "links. Pass `shorten=True` to post the scene to the CAVE state server and "
            "get a short link back.",
            len(url) / 1e6,
        )
    return url

decode_url

decode_url(url: str) -> dict
Source code in connecto/viz/neuroglancer.py
def decode_url(url: str) -> dict:
    _, _, fragment = url.partition("#!")
    if not fragment:
        raise ValueError("Not a neuroglancer URL.")
    return json.loads(urllib.parse.unquote(fragment))

Segmentation

Present wherever the dataset declares Cap.SEGMENTATION — which includes neuPrint, whose flat precomputed:// volumes read as well as CAVE's graphene ones. The methods that need a chunkedgraph (supervoxels, update_ids, root-ID history) additionally require Cap.CHUNKEDGRAPH and raise on a flat volume rather than inventing an answer.

Coordinates are nanometres, like everywhere else in connecto; pass units="voxel" if yours are not.

Segmentation

Segmentation(ds)

Bases: _Namespace

Query the segmentation volume, and the chunkedgraph where there is one.

Source code in connecto/core/namespaces.py
def __init__(self, ds):
    self._ds = ds

locs_to_segments

locs_to_segments(locs, *, units: str = 'nm', version=None, progress: bool = True) -> ndarray

The segment ID at each xyz location. locs is Nx3, in nanometres.

On a chunkedgraph dataset this goes via supervoxels, so it honours version= and answers as of that materialization. On a flat volume there is only one answer - the volume is the version - and version= is therefore refused rather than ignored.

Source code in connecto/core/segmentation.py
@requires(Cap.SEGMENTATION)
def locs_to_segments(
    self, locs, *, units: str = "nm", version=None, progress: bool = True
) -> np.ndarray:
    """The segment ID at each xyz location. ``locs`` is Nx3, in nanometres.

    On a chunkedgraph dataset this goes via supervoxels, so it honours
    ``version=`` and answers as of that materialization. On a flat volume there
    is only one answer - the volume *is* the version - and ``version=`` is
    therefore refused rather than ignored.
    """
    from . import volume

    ds = self._ds
    if not ds.supports(Cap.CHUNKEDGRAPH):
        if version is not None:
            raise CapabilityError(
                f"{ds.label} has a flat segmentation volume, not a chunkedgraph: "
                f"its segment IDs are immutable, so there is nothing for "
                f"`version=` to select. Drop the argument."
            )
        return volume.lookup_points(ds, locs, units=units, progress=progress)

    sv = self.locs_to_supervoxels(locs, units=units, progress=progress)
    return self.supervoxels_to_roots(sv, version=version)

neuron_to_segments

neuron_to_segments(x, *, units: str = 'nm', version=None, progress: bool = True) -> DataFrame

Which segments a neuron's nodes fall into. Useful for spotting merges.

Source code in connecto/core/segmentation.py
@requires(Cap.SEGMENTATION)
def neuron_to_segments(
    self, x, *, units: str = "nm", version=None, progress: bool = True
) -> pd.DataFrame:
    """Which segments a neuron's nodes fall into. Useful for spotting merges."""
    import navis

    rows = []
    for n in navis.NeuronList(x):
        nodes = n.nodes[["x", "y", "z"]].to_numpy()
        segs = self.locs_to_segments(
            nodes, units=units, version=version, progress=progress
        )
        for seg, count in pd.Series(segs).value_counts().items():
            rows.append({"neuron": n.id, "segment": int(seg), "n_nodes": int(count)})
    return pd.DataFrame(rows)

get_voxels

get_voxels(x, *, mip: int = 0) -> ndarray

Every voxel belonging to one segment, as raw (N, 3) voxel indices.

Prefer :meth:connecto.core.namespaces.Voxels.get (ds.voxels.get) unless you specifically want this. That one takes any neuron query rather than a single ID, returns navis.VoxelNeurons that carry their own nm-per-voxel, can hand back the compact run-length form, and - where the dataset has an index behind it - is a single request instead of a dense read.

This stays because it is a different, simpler thing: one segment, one mesh-bounded cutout, no chunkedgraph needed. It is also the only route for a segment that is not a neuron the graph knows about.

Source code in connecto/core/segmentation.py
@requires(Cap.SEGMENTATION)
def get_voxels(self, x, *, mip: int = 0) -> np.ndarray:
    """Every voxel belonging to one segment, as raw ``(N, 3)`` voxel indices.

    Prefer :meth:`connecto.core.namespaces.Voxels.get` (``ds.voxels.get``) unless
    you specifically want this. That one takes any neuron query rather than a
    single ID, returns ``navis.VoxelNeuron``s that carry their own nm-per-voxel,
    can hand back the compact run-length form, and - where the dataset has an
    index behind it - is a single request instead of a dense read.

    This stays because it is a different, simpler thing: one segment, one
    mesh-bounded cutout, no chunkedgraph needed. It is also the only route for a
    segment that is not a neuron the graph knows about.
    """
    from . import volume

    return volume.get_voxels(self._ds, int(x), mip=mip)

get_segmentation_cutout

get_segmentation_cutout(bbox, *, mip: int = 0, units: str = 'nm')

Raw segmentation in a bounding box. bbox is [[x0,y0,z0],[x1,y1,z1]].

Source code in connecto/core/segmentation.py
@requires(Cap.SEGMENTATION)
def get_segmentation_cutout(self, bbox, *, mip: int = 0, units: str = "nm"):
    """Raw segmentation in a bounding box. ``bbox`` is ``[[x0,y0,z0],[x1,y1,z1]]``."""
    from . import volume

    return volume.segmentation_cutout(self._ds, bbox, mip=mip, units=units)

locs_to_supervoxels

locs_to_supervoxels(locs, *, units: str = 'nm', progress: bool = True) -> ndarray

The supervoxel ID at each xyz location.

Reads the graph source, never the spec's display volume. FlyWire's spec points at the flat v783 bucket - excellent for meshes and for neuroglancer, useless here: a flat volume hands back root IDs, and a root ID passed off as a supervoxel is a lie get_roots will happily accept, because it is idempotent on roots. It would be right at v783 and quietly wrong everywhere else.

Source code in connecto/core/segmentation.py
@requires(Cap.CHUNKEDGRAPH)
def locs_to_supervoxels(
    self, locs, *, units: str = "nm", progress: bool = True
) -> np.ndarray:
    """The supervoxel ID at each xyz location.

    Reads the *graph* source, never the spec's display volume. FlyWire's spec
    points at the flat v783 bucket - excellent for meshes and for neuroglancer,
    useless here: a flat volume hands back root IDs, and a root ID passed off as
    a supervoxel is a lie `get_roots` will happily accept, because it is
    idempotent on roots. It would be right at v783 and quietly wrong everywhere
    else.
    """
    from . import volume

    ds = self._ds
    return volume.lookup_points(
        ds, locs, units=units, source=ds._graph_source(), progress=progress
    )

update_ids

update_ids(x, *, version=None, supervoxels=None) -> DataFrame

Map outdated root IDs onto their current equivalents.

Returns old_id, new_id, confidence, changed - confidence being the fraction of the old neuron that ended up in the new one, so you can see when an ID has been split rather than merely renamed.

Source code in connecto/core/segmentation.py
@requires(Cap.CHUNKEDGRAPH)
def update_ids(self, x, *, version=None, supervoxels=None) -> pd.DataFrame:
    """Map outdated root IDs onto their current equivalents.

    Returns ``old_id, new_id, confidence, changed`` - confidence being the
    fraction of the old neuron that ended up in the new one, so you can see
    when an ID has been split rather than merely renamed.
    """
    ids = np.asarray(x, dtype="int64").ravel()
    ts = self._timestamp(version)

    rows = []
    for i, old in enumerate(ids):
        if supervoxels is not None:
            new = int(self._cg.get_root_id(int(supervoxels[i]), timestamp=ts))
            rows.append({"old_id": int(old), "new_id": new, "confidence": 1.0})
            continue
        if self._cg.is_latest_roots([int(old)], timestamp=ts)[0]:
            rows.append({"old_id": int(old), "new_id": int(old), "confidence": 1.0})
            continue
        suggested, frac = self._cg.suggest_latest_roots(
            int(old), timestamp=ts, return_fraction_overlap=True
        )
        if np.isscalar(suggested):
            new, conf = int(suggested), 1.0
        else:
            best = int(np.argmax(list(frac.values()))) if isinstance(frac, dict) else 0
            new = int(np.atleast_1d(suggested)[best])
            conf = float(
                np.atleast_1d(list(frac.values()) if isinstance(frac, dict) else frac)[best]
            )
        rows.append({"old_id": int(old), "new_id": new, "confidence": conf})

    out = pd.DataFrame(rows)
    out["changed"] = out["old_id"] != out["new_id"]
    return out

find_common_time

find_common_time(x) -> Timestamp

The latest timestamp at which all the given root IDs co-existed.

Source code in connecto/core/segmentation.py
@requires(Cap.CHUNKEDGRAPH)
def find_common_time(self, x) -> pd.Timestamp:
    """The latest timestamp at which all the given root IDs co-existed."""
    ids = np.asarray(x, dtype="int64").ravel()
    stamps = self._cg.get_root_timestamps(ids)
    return pd.Timestamp(max(stamps))

Proofreading (CAVE only)

Proofreading

Proofreading(ds)

Bases: _Namespace

Source code in connecto/core/namespaces.py
def __init__(self, ds):
    self._ds = ds

is_proofread

is_proofread(x, *, version=None) -> ndarray

Boolean mask: has each neuron been marked as proofread?

Source code in connecto/backends/cave/proofreading.py
def is_proofread(self, x, *, version=None) -> np.ndarray:
    """Boolean mask: has each neuron been marked as proofread?"""
    ds = self._ds
    table = ds._backend.proofreading_table
    if not table:
        raise CapabilityError(f"{ds.label} has no proofreading table.")

    v = ds._resolve_version_arg(version)
    ids = ds.ids(x, version=version)

    df = ds.client.materialize.query_table(
        table,
        filter_in_dict={"pt_root_id": ids.tolist()},
        **ds._mat_kwargs(v),
    )
    proofread = set(df["pt_root_id"].astype("int64"))
    return np.isin(ids, list(proofread))

edit_history

edit_history(x) -> DataFrame

Every edit that touched these neurons.

Source code in connecto/backends/cave/proofreading.py
def edit_history(self, x) -> pd.DataFrame:
    """Every edit that touched these neurons."""
    ds = self._ds
    ids = ds.ids(x)
    logs = ds.client.chunkedgraph.get_tabular_change_log(ids.tolist())
    frames = []
    for root, log in logs.items():
        log = pd.DataFrame(log)
        log.insert(0, "root_id", int(root))
        frames.append(log)
    if not frames:
        return pd.DataFrame(columns=["root_id"])
    return pd.concat(frames, ignore_index=True)

lineage_graph

lineage_graph(x, *, as_nx: bool = True)

The merge/split tree that produced this neuron.

Source code in connecto/backends/cave/proofreading.py
def lineage_graph(self, x, *, as_nx: bool = True):
    """The merge/split tree that produced this neuron."""
    return self._ds.client.chunkedgraph.get_lineage_graph(
        int(np.atleast_1d(x)[0]), as_nx_graph=as_nx
    )

L2 cache (CAVE only, and not every datastack)

Present only where the dataset declares Cap.L2CACHE. Cheap skeletons and dotprops straight from the chunkedgraph's level-2 summaries — see Morphology.

L2

L2(ds)

Bases: _Namespace

Level-2 chunk data: cheap skeletons and dotprops.

Source code in connecto/core/namespaces.py
def __init__(self, ds):
    self._ds = ds

info

info(x, *, version=None) -> DataFrame

Per-chunk table: position (nm), volume, surface area.

One row per level-2 chunk, with the id of the neuron it belongs to. This is the raw material the other methods are built from - useful on its own for cheap volume/area estimates without fetching a mesh.

Source code in connecto/backends/cave/l2.py
@requires(Cap.L2CACHE)
def info(self, x, *, version=None) -> pd.DataFrame:
    """Per-chunk table: position (nm), volume, surface area.

    One row per level-2 chunk, with the ``id`` of the neuron it belongs to. This is
    the raw material the other methods are built from - useful on its own for cheap
    volume/area estimates without fetching a mesh.
    """
    ds = self._ds
    ids = ds.ids(x, version=version)

    frames = []
    for root in ids:
        df = _l2_table(ds, int(root), attributes=["rep_coord_nm", "size_nm3", "area_nm2"])
        if not len(df):
            continue
        out = pd.DataFrame(
            {
                "id": np.int64(root),
                "l2_id": df.index.to_numpy(dtype="int64"),
                "x": df["rep_coord_nm_x"].to_numpy("float32"),
                "y": df["rep_coord_nm_y"].to_numpy("float32"),
                "z": df["rep_coord_nm_z"].to_numpy("float32"),
            }
        )
        for col in ("size_nm3", "area_nm2"):
            if col in df.columns:
                out[col] = df[col].to_numpy("float64")
        frames.append(out)

    if not frames:
        return pd.DataFrame(columns=["id", "l2_id", "x", "y", "z", "size_nm3", "area_nm2"])
    return pd.concat(frames, ignore_index=True)

graph

graph(x, *, version=None)

The level-2 chunk graph, as networkx.Graph (one per neuron).

Returns a dict keyed by root ID. This is the connectivity of the chunks themselves - the thing :meth:skeleton walks to find parents.

Source code in connecto/backends/cave/l2.py
@requires(Cap.L2CACHE)
def graph(self, x, *, version=None):
    """The level-2 chunk graph, as ``networkx.Graph`` (one per neuron).

    Returns a dict keyed by root ID. This is the connectivity of the chunks
    themselves - the thing :meth:`skeleton` walks to find parents.
    """
    import networkx as nx

    ds = self._ds
    out = {}
    for root in ds.ids(x, version=version):
        root = int(root)
        edges = ds.client.chunkedgraph.level2_chunk_graph(root)
        g = nx.Graph()
        g.add_edges_from([(int(a), int(b)) for a, b in edges])
        out[root] = g
    return out

skeleton

skeleton(x, *, version=None)

Skeletons built from the L2 chunk graph -> navis.NeuronList.

Positions are nanometres. Nodes are chunk representative coordinates, and two consequences follow that you have to know about:

Do not measure cable length on these. The path hops from one chunk's representative point to the next, which zig-zags rather than following the neurite, so it over-estimates - on FlyWire DA1_lPNs, consistently by about 2x (1.9-2.2x against the same neurons from the skeleton service). Use ds.skeletons.get() if the number matters.

Radii are estimates. They come from the chunk's volume-to-surface ratio, not from a measurement of the neurite.

What the L2 skeleton is good for is topology and shape at low cost - and, via :meth:dotprops, NBLAST.

Source code in connecto/backends/cave/l2.py
@requires(Cap.L2CACHE)
def skeleton(self, x, *, version=None):
    """Skeletons built from the L2 chunk graph -> ``navis.NeuronList``.

    Positions are nanometres. Nodes are chunk *representative coordinates*, and two
    consequences follow that you have to know about:

    **Do not measure cable length on these.** The path hops from one chunk's
    representative point to the next, which zig-zags rather than following the
    neurite, so it over-estimates - on FlyWire DA1_lPNs, consistently by about
    **2x** (1.9-2.2x against the same neurons from the skeleton service). Use
    ``ds.skeletons.get()`` if the number matters.

    **Radii are estimates.** They come from the chunk's volume-to-surface ratio, not
    from a measurement of the neurite.

    What the L2 skeleton *is* good for is topology and shape at low cost - and, via
    :meth:`dotprops`, NBLAST.
    """
    import navis

    # No version resolution here beyond `ids`: the L2 cache is keyed on the *root
    # ID*, not on a materialization. The ID already carries the state of the
    # segmentation, so there is nothing left for a version to pin.
    ds = self._ds
    neurons = []
    for root in ds.ids(x, version=version):
        root = int(root)
        n = navis.TreeNeuron(l2_skeleton(ds, root), id=root, units="1 nm")
        n.name = str(root)
        neurons.append(n)
    return navis.NeuronList(neurons)

dotprops

dotprops(x, *, units: str = 'nm', version=None)

Dotprops straight from the L2 cache -> navis.NeuronList.

No skeletonisation: rep_coord_nm is the point and pca_0 the vector, both computed server-side.

Chunks with a degenerate (all-zero) principal axis are dropped - they are too blobby to have a direction, and passing a zero vector to NBLAST would have it silently score them as if they did.

units defaults to "nm", like everything else connecto returns. NBLAST is calibrated in microns: hand it nanometre dotprops and every score collapses to the floor, which looks like "nothing matches" rather than like an error. So for NBLAST, ask for microns::

dp = ds.l2.dotprops(x, units="um")
navis.nblast(dp, dp)
Source code in connecto/backends/cave/l2.py
@requires(Cap.L2CACHE)
def dotprops(self, x, *, units: str = "nm", version=None):
    """Dotprops straight from the L2 cache -> ``navis.NeuronList``.

    No skeletonisation: ``rep_coord_nm`` is the point and ``pca_0`` the vector, both
    computed server-side.

    Chunks with a degenerate (all-zero) principal axis are **dropped** - they are
    too blobby to have a direction, and passing a zero vector to NBLAST would have
    it silently score them as if they did.

    ``units`` defaults to ``"nm"``, like everything else connecto returns. **NBLAST
    is calibrated in microns**: hand it nanometre dotprops and every score collapses
    to the floor, which looks like "nothing matches" rather than like an error. So
    for NBLAST, ask for microns::

        dp = ds.l2.dotprops(x, units="um")
        navis.nblast(dp, dp)
    """
    import navis

    if units not in _SCALE:
        raise ValueError(f"`units` must be 'nm' or 'um', got {units!r}.")
    scale = _SCALE[units]

    ds = self._ds
    out = []
    for root in ds.ids(x, version=version):
        root = int(root)
        df = _l2_table(ds, root, attributes=["rep_coord_nm", "pca"])
        pts = df[_XYZ].to_numpy("float32")
        vect = df[_PCA0].to_numpy("float32")

        keep = np.linalg.norm(vect, axis=1) > 0
        if not keep.any():
            raise CapabilityError(
                f"{ds.label}: no level-2 chunk of {root} has a principal axis, so "
                f"no dotprops can be built. Try `ds.l2.skeleton({root})`."
            )

        dp = navis.Dotprops(
            pts[keep] * scale,
            k=None,  # vectors are given, not fitted
            vect=vect[keep],
            id=root,
            units=f"1 {_UNIT_NAME[units]}",
        )
        dp.name = str(root)
        out.append(dp)
    return navis.NeuronList(out)