Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion scripts/catmaid_skeleton_batch_import/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,38 @@ The request schema is deliberately small:
"stack": {
"dimension": [17797, 30801, 10826],
"resolution_nm": [350, 350, 1000]
}
},
"annotations": ["{stem}"]
}
```

`source.external_skeleton_id_map_path` may name a local CSV with exactly the
columns `member_path,external_skeleton_id`. The mapping enriches the output
manifest but never chooses CATMAID IDs.

### Annotations

`annotations` is an optional list of templates. Each template is rendered once
per SWC member and the result becomes a CATMAID annotation on that member's
neuron, so Neuroglancer can search for it as a segment property. The only
placeholder is `{stem}`, the filename without its extension, verbatim:
`191805/0000001.swc` renders `{stem}` as `0000001` and `swc:{stem}` as
`swc:0000001`.

Every template must contain `{stem}`, so each rendered name belongs to exactly
one neuron; a constant template would create one duplicate annotation row per
neuron. Format specifications, conversions, other placeholders, empty
templates, and repeated templates are rejected. A rendered name longer than 255
characters or equal to CATMAID's stable-join annotation `stable` aborts
planning. When two members render the same name, for example `a/0000001.swc`
and `b/0000001.swc`, planning aborts and the error names both members. Such
archives imported cleanly before `annotations` existed; rename the files or
split the archive to import them with annotations.

The manifest gains an `annotations` column holding a JSON array of the rendered
names. Manifests written before this column existed cannot be combined with new
ones.

Coordinates and non-sentinel radii use the same declared physical unit. An SWC
radius of `-1` remains CATMAID's unknown-radius sentinel. Voxel and anisotropic
source coordinates are not supported.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,11 @@ $STAGING_BASE/completed/batch_00001/manifest.tsv.gz

The manifest maps each source SWC member to CATMAID neuron and skeleton IDs.
The direct bulk load intentionally skips API-level side effects such as import
log rows, default `Import` annotations, and provenance rows.
log rows and provenance rows. Neuron annotations come only from the request's
`annotations` templates (see the README). `examples/fmost_191805_request.json`
uses `["{stem}"]`, so the neuron imported from `191805/0000001.swc` carries the
annotation `0000001`, which Neuroglancer exposes as a searchable segment
property.
By default, the loader leaves database triggers enabled because the normal
`catmaid_user` role cannot set `session_replication_role`. A privileged DB role
can opt into trigger disabling with `LOAD_DISABLE_TRIGGERS=true`, which also
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
"stack": {
"dimension": [17797, 30801, 10826],
"resolution_nm": [350, 350, 1000]
}
},
"annotations": ["{stem}"]
}
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ def scan_archive(
crc32=info.CRC,
display_name=PurePosixPath(info.filename).stem[:255],
cable_length_nm=cable_length_nm,
annotations=request.annotation_names(info.filename),
)
)
except InvalidInputError:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
import csv
import gzip
import io
import json
from pathlib import Path
from typing import Iterable, Sequence

from .domain import PlannedBatch
from .domain import PlannedBatch, iter_member_concept_ids
from .errors import ImmutableStateError
from .util import atomic_write_bytes, deterministic_gzip_bytes, sha256_file

Expand All @@ -21,6 +22,7 @@
"node_count",
"name",
"batch_index",
"annotations",
)


Expand Down Expand Up @@ -117,20 +119,18 @@ def manifest_rows(
batch: PlannedBatch,
concept_ids: Sequence[int],
) -> list[dict[str, object]]:
if len(concept_ids) != batch.skeleton_count * 3:
raise ValueError("Concept ID count does not match batch skeleton count")
rows: list[dict[str, object]] = []
for index, member in enumerate(batch.members):
neuron_id, skeleton_id, _link_id = concept_ids[index * 3 : index * 3 + 3]
for member, concepts in iter_member_concept_ids(batch, concept_ids):
rows.append(
{
"member_path": member.member_path,
"external_skeleton_id": member.external_skeleton_id or "",
"neuron_id": neuron_id,
"skeleton_id": skeleton_id,
"neuron_id": concepts.neuron_id,
"skeleton_id": concepts.skeleton_id,
"node_count": member.node_count,
"name": member.display_name,
"batch_index": batch.index,
"annotations": json.dumps(list(member.annotations)),
}
)
return rows
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
import json
import math
import os
import string
from dataclasses import fields
from pathlib import Path
from typing import Any

from .domain import ImportSettings, IngestionRequest
from .domain import ANNOTATION_PLACEHOLDER, ImportSettings, IngestionRequest
from .errors import InvalidInputError


Expand Down Expand Up @@ -43,8 +44,14 @@
}


def _expect_keys(value: dict[str, Any], expected: set[str], label: str) -> None:
unknown = set(value) - expected
def _expect_keys(
value: dict[str, Any],
expected: set[str],
label: str,
*,
optional: frozenset[str] = frozenset(),
) -> None:
unknown = set(value) - expected - optional
missing = expected - set(value)
if unknown:
raise InvalidInputError(
Expand Down Expand Up @@ -81,6 +88,37 @@ def _positive_number_tuple(value: Any, label: str) -> tuple[float, float, float]
return tuple(result) # type: ignore[return-value]


def _check_annotation_template(template: str) -> None:
label = f"request.annotations template {template!r}"
try:
parsed = list(string.Formatter().parse(template))
except ValueError as exc:
raise InvalidInputError(f"{label} is malformed: {exc}") from exc
uses_stem = False
for _literal, field_name, format_spec, conversion in parsed:
if field_name is None:
continue
if field_name != ANNOTATION_PLACEHOLDER or format_spec or conversion:
raise InvalidInputError(f"{label} may only use {{stem}}")
uses_stem = True
if not uses_stem:
raise InvalidInputError(f"{label} must contain {{stem}}")


def _annotation_templates(value: Any) -> tuple[str, ...]:
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
raise InvalidInputError("request.annotations must be an array of strings")
if len(set(value)) != len(value):
raise InvalidInputError("request.annotations must not repeat a template")
for template in value:
if not template:
raise InvalidInputError(
"request.annotations must not contain an empty template"
)
_check_annotation_template(template)
return tuple(value)


def load_request(path: Path) -> IngestionRequest:
try:
with path.open("r", encoding="utf-8") as source:
Expand All @@ -90,7 +128,12 @@ def load_request(path: Path) -> IngestionRequest:

if not isinstance(raw, dict):
raise InvalidInputError("request must be a JSON object")
_expect_keys(raw, {"schema_version", "source", "stack"}, "request")
_expect_keys(
raw,
{"schema_version", "source", "stack"},
"request",
optional=frozenset({"annotations"}),
)
if raw["schema_version"] != 1:
raise InvalidInputError("request.schema_version must be 1")
if not isinstance(raw["source"], dict):
Expand Down Expand Up @@ -142,6 +185,7 @@ def load_request(path: Path) -> IngestionRequest:
raw["stack"]["resolution_nm"], "request.stack.resolution_nm"
),
external_skeleton_id_map_path=mapping_path,
annotations=_annotation_templates(raw.get("annotations", [])),
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,20 +316,20 @@ def allocate_project_ids(connection: Any | None = None) -> dict[str, int]:


def allocate_batch_ids(
skeleton_count: int,
concept_count: int,
node_count: int,
connection: Any | None = None,
) -> dict[str, tuple[int, ...]]:
"""Reserve exact concept and location IDs for one whole-skeleton batch."""

if isinstance(skeleton_count, bool) or skeleton_count <= 0:
raise ValueError("skeleton_count must be positive")
if isinstance(concept_count, bool) or concept_count <= 0:
raise ValueError("concept_count must be positive")
if isinstance(node_count, bool) or node_count <= 0:
raise ValueError("node_count must be positive")
connection = _default_connection(connection)
return {
"concept_ids": allocate_sequence_ids(
"concept_id_seq", skeleton_count * 3, connection
"concept_id_seq", concept_count, connection
),
"location_ids": allocate_sequence_ids(
"location_id_seq", node_count, connection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any
from pathlib import Path, PurePosixPath
from typing import Any, Iterator, Sequence


ANNOTATION_PLACEHOLDER = "stem"


@dataclass(frozen=True)
Expand All @@ -15,6 +18,7 @@ class IngestionRequest:
dimension: tuple[int, int, int]
resolution_nm: tuple[float, float, float]
external_skeleton_id_map_path: Path | None = None
annotations: tuple[str, ...] = ()

@property
def scale_nm(self) -> float:
Expand All @@ -27,6 +31,10 @@ def max_nm(self) -> tuple[float, float, float]:
for dimension, resolution in zip(self.dimension, self.resolution_nm)
) # type: ignore[return-value]

def annotation_names(self, member_path: str) -> tuple[str, ...]:
values = {ANNOTATION_PLACEHOLDER: PurePosixPath(member_path).stem}
return tuple(template.format_map(values) for template in self.annotations)

def to_dict(self) -> dict[str, Any]:
source: dict[str, Any] = {
"archive_path": str(self.archive_path),
Expand All @@ -36,14 +44,17 @@ def to_dict(self) -> dict[str, Any]:
source["external_skeleton_id_map_path"] = str(
self.external_skeleton_id_map_path
)
return {
result: dict[str, Any] = {
"schema_version": self.schema_version,
"source": source,
"stack": {
"dimension": list(self.dimension),
"resolution_nm": list(self.resolution_nm),
},
}
if self.annotations:
result["annotations"] = list(self.annotations)
return result


@dataclass(frozen=True)
Expand Down Expand Up @@ -108,6 +119,12 @@ class PlannedMember:
cable_length_nm: float
external_skeleton_id: str | None = None
batch_index: int | None = None
annotations: tuple[str, ...] = ()

@property
def concept_count(self) -> int:
"""Neuron, skeleton, model_of link, plus an instance and link per annotation."""
return 3 + 2 * len(self.annotations)

def to_dict(self) -> dict[str, Any]:
result: dict[str, Any] = {
Expand All @@ -122,6 +139,8 @@ def to_dict(self) -> dict[str, Any]:
result["external_skeleton_id"] = self.external_skeleton_id
if self.batch_index is not None:
result["batch_index"] = self.batch_index
if self.annotations:
result["annotations"] = list(self.annotations)
return result

@classmethod
Expand All @@ -143,6 +162,7 @@ def from_dict(cls, value: dict[str, Any]) -> "PlannedMember":
if value.get("batch_index") is not None
else None
),
annotations=tuple(str(name) for name in value.get("annotations", ())),
)


Expand All @@ -159,6 +179,14 @@ def node_count(self) -> int:
def skeleton_count(self) -> int:
return len(self.members)

@property
def annotation_count(self) -> int:
return sum(len(member.annotations) for member in self.members)

@property
def concept_count(self) -> int:
return sum(member.concept_count for member in self.members)

def to_summary_dict(self) -> dict[str, Any]:
return {
"index": self.index,
Expand All @@ -168,3 +196,49 @@ def to_summary_dict(self) -> dict[str, Any]:
"node_count": self.node_count,
"status": "planned",
}


@dataclass(frozen=True)
class AnnotationConceptIds:
name: str
annotation_id: int
link_id: int


@dataclass(frozen=True)
class MemberConceptIds:
neuron_id: int
skeleton_id: int
model_of_link_id: int
annotations: tuple[AnnotationConceptIds, ...]


def iter_member_concept_ids(
batch: PlannedBatch, concept_ids: Sequence[int]
) -> Iterator[tuple[PlannedMember, MemberConceptIds]]:
if len(concept_ids) != batch.concept_count:
raise ValueError(
f"batch {batch.index}: expected {batch.concept_count} concept IDs, "
f"found {len(concept_ids)}"
)
offset = 0
for member in batch.members:
neuron_id, skeleton_id, model_of_link_id, *annotation_ids = concept_ids[
offset : offset + member.concept_count
]
offset += member.concept_count
yield member, MemberConceptIds(
neuron_id=int(neuron_id),
skeleton_id=int(skeleton_id),
model_of_link_id=int(model_of_link_id),
annotations=tuple(
AnnotationConceptIds(
name=name,
annotation_id=int(annotation_id),
link_id=int(link_id),
)
for name, annotation_id, link_id in zip(
member.annotations, annotation_ids[0::2], annotation_ids[1::2]
)
),
)
Loading