Source code for swcstudio.core.auto_typing.runner

"""Public entry points for the auto-typing engine.

The v12 QC-label-flag path drives every prediction:

* Stage 1: cell-type detector (sklearn ensemble, 49 whole-cell
  features) decides pyramidal vs interneuron with a soft handoff.
* Stage 2: per-subtree axon/basal/apical classifier (sklearn
  ensemble), propagated to all branches in the same primary subtree.
* Stage 2b: GraphSAGE GNN over the branch graph re-decides
  apical-vs-basal for pyramidal dendrite branches.
* Stage 3: topology refinement plus conservative Branch3 rescue.
* QC/flag: runtime QC metadata and learned per-cell bad-label flag.

The core model files are required. ``is_available`` returns False if any
of the Stage 1, Stage 2, GNN, Branch3, or QC-gate files are missing.

Public surface:

* ``run_file(path, opts, ...)`` returns ``FileResult``
* ``run_batch(folder, opts, ...)`` returns ``BatchResult``
* ``is_available()`` returns ``(bool, reason)``
* ``backend_status()`` returns a diagnostic dict
"""
from __future__ import annotations

import zipfile
from pathlib import Path
from typing import Any, Callable

from swcstudio.core.model_paths import (
    diagnostic_search_report,
    resolve_model_path,
)
from swcstudio.core.reporting import (
    auto_typing_log_path_for_file,
    format_auto_typing_report_text,
    operation_output_dir_for_folder,
    operation_output_path_for_file,
    operation_report_path_for_file,
    operation_report_path_for_folder,
    timestamp_slug,
    write_text_report,
)

from .types import BatchOptions, BatchResult, FileResult


# ---------------------------------------------------------------------------
# Availability / diagnostics
# ---------------------------------------------------------------------------


[docs] def is_available(*, model_dir: str | None = None) -> tuple[bool, str]: """Return ``(ok, reason)`` describing whether the auto-typing engine can run right now. The engine requires the v12 model files (Stage 1 sklearn pickle, Stage 2 sklearn pickle, Stage 2b GNN checkpoint, Branch3 rescue checkpoint, and QC gate) plus torch and torch_geometric — they are required dependencies of the package, so a normal install satisfies them. When something is missing this returns ``(False, reason)`` with a search-path diagnostic so the GUI / CLI can fail fast with a clear message. """ s1 = resolve_model_path("stage1", override=model_dir) s2 = resolve_model_path("stage2", override=model_dir) gnn = resolve_model_path("gnn", override=model_dir) branch3 = resolve_model_path("branch3", override=model_dir) qc_gate = resolve_model_path("qc_gate", override=model_dir) missing = [] if s1 is None: missing.append("Stage 1 (cell_type_classifier.pkl)") if s2 is None: missing.append("Stage 2 (branch_classifier.pkl)") if gnn is None: missing.append("Stage 2b GNN (gnn_apical_basal.pt)") if branch3 is None: missing.append("Branch3 rescue head (gnn_branch3_rescue.pt)") if qc_gate is None: missing.append("QC gate (qc_gate.pkl)") if missing: return False, ( "Auto-typing is missing required model files: " + ", ".join(missing) + ".\n" + diagnostic_search_report(override=model_dir) ) try: import joblib # noqa: F401 import sklearn # noqa: F401 import torch # noqa: F401 import torch_geometric # noqa: F401 import xgboost # noqa: F401 except Exception as exc: # noqa: BLE001 return False, ( "Auto-typing could not import one of its required ML packages " f"(import failed: {exc.__class__.__name__}: {exc}). " "Repair the active environment with " "`python -m pip install --upgrade --force-reinstall swcstudio`, " "then run `swcstudio doctor`." ) return True, "available"
[docs] def backend_status(*, model_dir: str | None = None) -> dict[str, Any]: """Structured status report. Used by the CLI ``models status`` command and the GUI to surface the resolved model paths and torch availability. """ s1 = resolve_model_path("stage1", override=model_dir) s2 = resolve_model_path("stage2", override=model_dir) gnn = resolve_model_path("gnn", override=model_dir) branch3 = resolve_model_path("branch3", override=model_dir) qc_gate = resolve_model_path("qc_gate", override=model_dir) flag_pyr = resolve_model_path("flag_pyramidal", override=model_dir) flag_int = resolve_model_path("flag_interneuron", override=model_dir) flag_all = resolve_model_path("flag_all", override=model_dir) torch_ok = True torch_msg = "torch + torch_geometric available" try: import torch # noqa: F401 import torch_geometric # noqa: F401 except Exception as exc: # noqa: BLE001 torch_ok = False torch_msg = f"torch / torch_geometric unavailable: {exc.__class__.__name__}" return { "stage1_path": str(s1) if s1 else None, "stage2_path": str(s2) if s2 else None, "gnn_path": str(gnn) if gnn else None, "branch3_path": str(branch3) if branch3 else None, "qc_gate_path": str(qc_gate) if qc_gate else None, "flag_pyramidal_path": str(flag_pyr) if flag_pyr else None, "flag_interneuron_path": str(flag_int) if flag_int else None, "flag_all_path": str(flag_all) if flag_all else None, "stage1_ok": s1 is not None, "stage2_ok": s2 is not None, "gnn_ok": gnn is not None and torch_ok, "branch3_ok": branch3 is not None and torch_ok, "qc_gate_ok": qc_gate is not None, "flag_pyramidal_ok": flag_pyr is not None, "flag_interneuron_ok": flag_int is not None, "flag_all_ok": flag_all is not None, "torch_ok": torch_ok, "torch_message": torch_msg, "search_diagnostic": diagnostic_search_report(override=model_dir), }
# --------------------------------------------------------------------------- # Internal: load GNN once and reuse across files in a batch # --------------------------------------------------------------------------- _GNN_CACHE: dict[str, Any] = {"path": None, "state": None} _BRANCH3_CACHE: dict[str, Any] = {"path": None, "state": None} _QC_GATE_CACHE: dict[str, Any] = {"path": None, "state": None} def _load_gnn_state(model_dir: str | None) -> Any: """Load the Stage 2b GNN checkpoint. Raises if it cannot be loaded — the GNN is a required pipeline stage, not optional. """ gnn_path = resolve_model_path("gnn", override=model_dir) if gnn_path is None: raise FileNotFoundError( "Stage 2b GNN checkpoint (gnn_apical_basal.pt) not found.\n" + diagnostic_search_report(override=model_dir) ) cache_key = str(gnn_path.resolve()) if _GNN_CACHE.get("path") == cache_key and _GNN_CACHE.get("state") is not None: return _GNN_CACHE["state"] from .gnn_inference import load_gnn # noqa: PLC0415 state = load_gnn(gnn_path) _GNN_CACHE["path"] = cache_key _GNN_CACHE["state"] = state return state def _load_branch3_state(model_dir: str | None) -> Any: branch3_path = resolve_model_path("branch3", override=model_dir) if branch3_path is None: raise FileNotFoundError( "Branch3 rescue checkpoint (gnn_branch3_rescue.pt) not found.\n" + diagnostic_search_report(override=model_dir) ) cache_key = str(branch3_path.resolve()) if _BRANCH3_CACHE.get("path") == cache_key and _BRANCH3_CACHE.get("state") is not None: return _BRANCH3_CACHE["state"] from .gnn_branch3_inference import load_branch3 # noqa: PLC0415 state = load_branch3(branch3_path) _BRANCH3_CACHE["path"] = cache_key _BRANCH3_CACHE["state"] = state return state def _load_qc_gate(model_dir: str | None) -> Any | None: qc_path = resolve_model_path("qc_gate", override=model_dir) if qc_path is None: return None cache_key = str(qc_path.resolve()) if _QC_GATE_CACHE.get("path") == cache_key and _QC_GATE_CACHE.get("state") is not None: return _QC_GATE_CACHE["state"] from .qc_input import QCGate # noqa: PLC0415 gate = QCGate.load(qc_path) _QC_GATE_CACHE["path"] = cache_key _QC_GATE_CACHE["state"] = gate return gate def _public_qc_result(raw: Any) -> dict[str, Any] | None: """Return the user-facing QC payload without bulky internals.""" if raw is None: return None if hasattr(raw, "to_dict"): raw = raw.to_dict() if not isinstance(raw, dict): return {"passed": False, "reasons": [f"qc_error:unexpected_result:{type(raw).__name__}"]} out = dict(raw) out.pop("feature_vector", None) return out def _qc_failure_reasons(qc_result: dict[str, Any] | None) -> list[str]: """Return non-empty QC reasons when a file should not be auto-labeled.""" if not qc_result: return [] passed = qc_result.get("passed") if passed is None or bool(passed): return [] raw_reasons = qc_result.get("reasons") or [] reasons = [str(reason) for reason in raw_reasons if str(reason).strip()] return reasons or ["qc_failed"] # --------------------------------------------------------------------------- # SWC parse / write / diff helpers (shared with rest of swcstudio) # --------------------------------------------------------------------------- def _parse_swc(path: Path) -> tuple[list[str], list[dict[str, Any]]]: headers: list[str] = [] rows: list[dict[str, Any]] = [] with path.open("r", encoding="utf-8", errors="ignore") as fh: for line in fh: s = line.strip() if not s: continue if s.startswith("#"): headers.append(line.rstrip("\n")) continue parts = s.split() if len(parts) < 7: continue try: rid = int(float(parts[0])) rtype = int(float(parts[1])) x = float(parts[2]) y = float(parts[3]) z = float(parts[4]) radius = float(parts[5]) parent = int(float(parts[6])) except Exception: continue rows.append( { "id": rid, "type": rtype, "x": x, "y": y, "z": z, "radius": radius, "parent": parent, } ) return headers, rows def _write_swc( path: Path, headers: list[str], rows: list[dict[str, Any]], types: list[int], radii: list[float], ) -> None: with path.open("w", encoding="utf-8") as fh: for h in headers: fh.write(f"{h}\n") for i, row in enumerate(rows): fh.write( f"{int(row['id'])} {int(types[i])} " f"{float(row['x']):.10g} {float(row['y']):.10g} {float(row['z']):.10g} " f"{float(radii[i]):.10g} {int(row['parent'])}\n" ) def _check_auto_label_eligibility(rows: list[dict[str, Any]]) -> str | None: """Fast pre-flight check on parsed SWC rows. Auto-label assumes the file describes one cell. The only case that produces garbage labels and warrants a hard skip is *multi-soma* files (multiple disconnected type-1 components in a single SWC) — those need to be split into one cell per file first. Soma-less and soma-only files are accepted: the engine's proxy-root fallback picks the largest-radius root when there's no explicit soma, and a soma-only file simply produces an all-soma labeling, which is correct. Returns: ``None`` when the file is eligible, or a short human-readable reason when it is not. """ if not rows: return "no valid SWC rows" soma_indices = [i for i, r in enumerate(rows) if int(r["type"]) == 1] if len(soma_indices) <= 1: return None # 0 somas (proxy-root path) or 1 soma — both fine # Multi-soma detection. Two soma nodes are part of the same soma # only if they are directly connected (parent <-> child) or # transitively connected through soma-only edges. Build that # connectivity over soma nodes, count components. id_to_idx = {int(r["id"]): i for i, r in enumerate(rows)} soma_set = set(soma_indices) parent_idx_of = [id_to_idx.get(int(r["parent"])) for r in rows] # Adjacency restricted to soma-soma edges. soma_adj: dict[int, set[int]] = {i: set() for i in soma_indices} for child in soma_indices: p = parent_idx_of[child] if p in soma_set: soma_adj[child].add(p) soma_adj[p].add(child) seen: set[int] = set() components = 0 for start in soma_indices: if start in seen: continue # BFS over soma-restricted adjacency. stack = [start] while stack: cur = stack.pop() if cur in seen: continue seen.add(cur) stack.extend(n for n in soma_adj[cur] if n not in seen) components += 1 if components > 1: return ( f"multi-soma file ({components} disconnected soma groups); " "split with `batch split` before auto-labeling" ) return None def _build_change_details( file_name: str, rows: list[dict[str, Any]], orig_types: list[int], new_types: list[int], orig_radii: list[float], new_radii: list[float], ) -> list[str]: out: list[str] = [] type_changes = sum(1 for old, new in zip(orig_types, new_types) if int(old) != int(new)) radius_changes = sum(1 for old, new in zip(orig_radii, new_radii) if float(old) != float(new)) if type_changes <= 0 and radius_changes <= 0: return out out.append(f"[{file_name}]") if type_changes > 0: out.append("type_changes:") for row, old_t, new_t in zip(rows, orig_types, new_types): if int(old_t) != int(new_t): out.append( f" node_id={int(row['id'])}: old_type={int(old_t)} -> new_type={int(new_t)}" ) if radius_changes > 0: out.append("radius_changes:") for row, old_r, new_r in zip(rows, orig_radii, new_radii): if float(old_r) != float(new_r): out.append( f" node_id={int(row['id'])}: " f"old_radius={float(old_r):.10g} -> new_radius={float(new_r):.10g}" ) out.append("") return out # --------------------------------------------------------------------------- # Conversion: parsed SWC rows <-> pipeline SWCNode list # --------------------------------------------------------------------------- def _rows_to_swc_nodes(rows: list[dict[str, Any]]) -> list: from .features import SWCNode # noqa: PLC0415 return [ SWCNode( id=int(row["id"]), type=int(row["type"]), x=float(row["x"]), y=float(row["y"]), z=float(row["z"]), radius=float(row["radius"]), parent=int(row["parent"]), ) for row in rows ] def _normalize_cell_type(raw: str | None) -> str | None: value = str(raw or "").strip().lower() if value in {"", "unknown", "auto", "stage1", "none"}: return None if value in {"pyramidal", "interneuron"}: return value raise ValueError("cell_type must be one of: unknown, pyramidal, interneuron") def _normalize_flag_feature_mode(raw: str | None) -> str: value = str(raw or "compact").strip().lower().replace("-", "_") if value in {"", "default", "compact", "simple", "auto", "baseline", "complex"}: return "compact" raise ValueError("flag_feature_mode must be compact/simple") def _compact_flag_model_for_cell_type(model_dir: str | None, cell_type: str | None) -> Path | None: if cell_type == "pyramidal": return ( resolve_model_path("flag_pyramidal", override=model_dir, auto_download=False) or resolve_model_path("flag_all", override=model_dir, auto_download=False) ) if cell_type == "interneuron": return ( resolve_model_path("flag_interneuron", override=model_dir, auto_download=False) or resolve_model_path("flag_all", override=model_dir, auto_download=False) ) return resolve_model_path("flag_all", override=model_dir, auto_download=False) def _flag_model_for_cell_type( model_dir: str | None, cell_type: str | None, feature_mode: str | None, ) -> tuple[Path | None, str]: _normalize_flag_feature_mode(feature_mode) return _compact_flag_model_for_cell_type(model_dir, cell_type), "compact" def _score_flag_for_pipeline_result( *, in_path: Path, rows: list[dict[str, Any]], opts: BatchOptions, model_dir: str | None, stage1_path: Path, stage2_path: Path, gnn_state: Any | None, pipeline_result: Any, cell_type: str | None, stage1_conf: float | None, use_subtree_stage2: bool, ) -> dict[str, Any] | None: if not bool(getattr(opts, "flag_enabled", True)) or pipeline_result is None: return None requested_mode = _normalize_flag_feature_mode(getattr(opts, "flag_feature_mode", "compact")) flag_path, actual_mode = _flag_model_for_cell_type(model_dir, cell_type, requested_mode) if flag_path is None: return None try: from .flagging import build_feature_row, score_flag # noqa: PLC0415 from .pipeline import run_pipeline_on_nodes # noqa: PLC0415 nodes_for_flag = _rows_to_swc_nodes(rows) base_result = None if cell_type == "pyramidal": base_result = run_pipeline_on_nodes( nodes_for_flag, file_path="", stage1_model=stage1_path, stage2_model=stage2_path, gnn_state=gnn_state, branch3_state=None, use_subtree_stage2=use_subtree_stage2, override_cell_type=_normalize_cell_type(getattr(opts, "cell_type", None)), ) feature_row = build_feature_row( file_name=in_path.name, nodes=nodes_for_flag, labels=list(pipeline_result.node_labels), confidences=[float(c) for c in pipeline_result.node_confidences], stage1_cell_type=str(cell_type or ""), stage1_confidence=float(stage1_conf if stage1_conf is not None else 0.0), base_labels=list(base_result.node_labels) if base_result is not None else None, base_confidences=( [float(c) for c in base_result.node_confidences] if base_result is not None else None ), ) out = score_flag( flag_model_path=flag_path, feature_row=feature_row, cell_type_for_filter=str(cell_type or ""), strictness=float(getattr(opts, "flag_strictness", 0.5)), ) out["requested_feature_mode"] = requested_mode out["actual_feature_mode"] = actual_mode return out except Exception as exc: # noqa: BLE001 return { "enabled": True, "flagged": False, "requested_feature_mode": requested_mode, "actual_feature_mode": actual_mode, "error": f"{exc.__class__.__name__}: {exc}", } def _apply_pipeline_to_rows( rows: list[dict[str, Any]], *, opts: BatchOptions, stage1_path: Path, stage2_path: Path, gnn_state: Any | None, branch3_state: Any | None, use_subtree_stage2: bool, ) -> tuple[list[int], list[float], int, int, Any | None]: """Run the pipeline on parsed SWC rows. Returns ``(types, radii, type_changes, radius_changes, pipeline_result)``. The ``opts`` flags map onto the pipeline like this: opts.soma - soma is detected by Stage 1 and assigned via the proxy-soma rule. The flag controls whether we *write* the predicted soma label (default True). opts.axon, opts.basal, opts.apic - per-cell-type label gates. When the pipeline predicts an axon for an interneuron and the user has disabled axon, the original label is kept. opts.rad - radius cleanup. The pipeline does not modify radii itself; we apply the simple "copy parent radius if zero" rule when the flag is set. """ from .pipeline import run_pipeline_on_nodes # noqa: PLC0415 nodes = _rows_to_swc_nodes(rows) if not nodes: return ( [int(r["type"]) for r in rows], [float(r["radius"]) for r in rows], 0, 0, None, ) cell_type_override = _normalize_cell_type(getattr(opts, "cell_type", None)) result = run_pipeline_on_nodes( nodes, file_path="", stage1_model=stage1_path, stage2_model=stage2_path, gnn_state=gnn_state, branch3_state=branch3_state, use_subtree_stage2=use_subtree_stage2, override_cell_type=cell_type_override, ) pipeline_types = list(result.node_labels) orig_types = [int(r["type"]) for r in rows] final_types: list[int] = [] enabled_neurites: set[int] = set() if opts.axon: enabled_neurites.add(2) if opts.basal: enabled_neurites.add(3) if opts.apic: enabled_neurites.add(4) for old_t, new_t in zip(orig_types, pipeline_types): new_t = int(new_t) if new_t == 1: final_types.append(1 if opts.soma else int(old_t)) elif new_t in {2, 3, 4}: if not enabled_neurites: final_types.append(int(old_t)) elif new_t in enabled_neurites: final_types.append(new_t) else: final_types.append(int(old_t)) else: final_types.append(int(old_t)) radii_orig = [float(r["radius"]) for r in rows] radii = list(radii_orig) if opts.rad: id_to_idx = {int(r["id"]): i for i, r in enumerate(rows)} order: list[int] = [] seen: set[int] = set() roots = [ i for i, r in enumerate(rows) if int(r["parent"]) == -1 or int(r["parent"]) not in id_to_idx ] queue = list(roots) while queue: idx = queue.pop(0) if idx in seen: continue seen.add(idx) order.append(idx) for j, r in enumerate(rows): if id_to_idx.get(int(r["parent"])) == idx and j not in seen: queue.append(j) for idx in order: pidx = id_to_idx.get(int(rows[idx]["parent"])) if pidx is None: continue if radii[idx] <= 0 and radii[pidx] > 0: radii[idx] = radii[pidx] type_changes = sum( 1 for old, new in zip(orig_types, final_types) if int(old) != int(new) ) radius_changes = sum( 1 for old, new in zip(radii_orig, radii) if float(old) != float(new) ) return final_types, radii, type_changes, radius_changes, result # --------------------------------------------------------------------------- # Public API # ---------------------------------------------------------------------------
[docs] def run_file( file_path: str, opts: BatchOptions, *, output_path: str | None = None, write_output: bool = True, write_log: bool = True, model_dir: str | None = None, use_subtree_stage2: bool = True, ) -> FileResult: """Run the auto-typing engine on one SWC file.""" ok, reason = is_available(model_dir=model_dir) if not ok: raise FileNotFoundError(reason) stage1_path = resolve_model_path("stage1", override=model_dir) stage2_path = resolve_model_path("stage2", override=model_dir) gnn_state = _load_gnn_state(model_dir) branch3_state = _load_branch3_state(model_dir) qc_gate = _load_qc_gate(model_dir) in_path = Path(file_path) headers, rows = _parse_swc(in_path) ineligible = _check_auto_label_eligibility(rows) if ineligible is not None: raise ValueError(f"{in_path.name}: {ineligible}") qc_result = None if qc_gate is not None: try: qc_result = _public_qc_result(qc_gate.evaluate(in_path)) except Exception as exc: # noqa: BLE001 qc_result = {"passed": False, "reasons": [f"qc_error:{exc}"], "path": str(in_path)} qc_reasons = _qc_failure_reasons(qc_result) if qc_reasons: raise ValueError(f"{in_path.name}: QC rejected; " + "; ".join(qc_reasons)) orig_types = [int(r["type"]) for r in rows] orig_radii = [float(r["radius"]) for r in rows] types, radii, type_changes, radius_changes, pipeline_result = _apply_pipeline_to_rows( rows, opts=opts, stage1_path=stage1_path, stage2_path=stage2_path, gnn_state=gnn_state, branch3_state=branch3_state, use_subtree_stage2=use_subtree_stage2, ) cell_type = getattr(getattr(pipeline_result, "stage1", None), "cell_type", None) stage1_conf = getattr(getattr(pipeline_result, "stage1", None), "confidence", None) cell_type_source = "user" if _normalize_cell_type(getattr(opts, "cell_type", None)) else "stage1" flag_result = _score_flag_for_pipeline_result( in_path=in_path, rows=rows, opts=opts, model_dir=model_dir, stage1_path=stage1_path, stage2_path=stage2_path, gnn_state=gnn_state, pipeline_result=pipeline_result, cell_type=cell_type, stage1_conf=stage1_conf, use_subtree_stage2=use_subtree_stage2, ) out_path: Path | None = None run_timestamp = timestamp_slug() if write_output: out_path = ( Path(output_path) if output_path else operation_output_path_for_file(in_path, "auto_typing", timestamp=run_timestamp) ) _write_swc(out_path, headers, rows, types, radii) out_counts = { 1: sum(1 for t in types if int(t) == 1), 2: sum(1 for t in types if int(t) == 2), 3: sum(1 for t in types if int(t) == 3), 4: sum(1 for t in types if int(t) == 4), } change_details = _build_change_details( in_path.name, rows, orig_types, types, orig_radii, radii, ) log_path: str | None = None if write_log: log_target = ( auto_typing_log_path_for_file(in_path) if output_path else operation_report_path_for_file(in_path, "auto_typing", timestamp=run_timestamp) ) payload = { "folder": str(in_path.parent), "out_dir": str(out_path.parent if out_path is not None else in_path.parent), "zip_path": None, "files_total": 1, "files_processed": 1, "files_failed": 0, "files_qc_failed": 0, "total_nodes": len(rows), "total_type_changes": type_changes, "total_radius_changes": radius_changes, "files_flagged": int(bool(flag_result and flag_result.get("flagged"))), "failures": [], "per_file": [ f"{in_path.name}: nodes={len(rows)}, type_changes={type_changes}, " f"radius_changes={radius_changes}, cell_type={cell_type or 'unknown'} " f"({cell_type_source}), flag={bool(flag_result and flag_result.get('flagged'))}, " f"out_types(soma/axon/basal/apic)={out_counts[1]}/{out_counts[2]}/" f"{out_counts[3]}/{out_counts[4]}" ], "change_details": change_details, } log_path = write_text_report(log_target, format_auto_typing_report_text(payload)) return FileResult( input_file=str(in_path), output_file=str(out_path) if out_path is not None else None, nodes_total=len(rows), type_changes=type_changes, radius_changes=radius_changes, out_type_counts=out_counts, cell_type=cell_type, cell_type_source=cell_type_source, stage1_confidence=float(stage1_conf) if stage1_conf is not None else None, qc_result=qc_result, flag_result=flag_result, failures=[], change_details=change_details, log_path=log_path, headers=headers, rows=rows, types=types, radii=radii, )
[docs] def run_batch( folder: str, opts: BatchOptions, *, model_dir: str | None = None, use_subtree_stage2: bool = True, progress_callback: Callable[[int, int, str], None] | None = None, ) -> BatchResult: """Run the auto-typing engine on every ``.swc`` file in ``folder``. ``progress_callback`` is invoked once per file *before* processing that file: ``progress_callback(index, total, current_filename)``. Use it to drive a GUI progress bar without blocking the engine. Exceptions raised inside the callback are propagated to the caller. """ ok, reason = is_available(model_dir=model_dir) if not ok: raise FileNotFoundError(reason) stage1_path = resolve_model_path("stage1", override=model_dir) stage2_path = resolve_model_path("stage2", override=model_dir) gnn_state = _load_gnn_state(model_dir) branch3_state = _load_branch3_state(model_dir) qc_gate = _load_qc_gate(model_dir) in_dir = Path(folder) swc_files = sorted([ p for p in in_dir.iterdir() if p.is_file() and p.suffix.lower() == ".swc" ]) run_timestamp = timestamp_slug() out_dir = operation_output_dir_for_folder( in_dir, "batch_auto_typing", timestamp=run_timestamp, ) failures: list[str] = [] per_file: list[str] = [] change_details: list[str] = [] processed = 0 total_nodes = 0 total_type_changes = 0 total_radius_changes = 0 total_files = len(swc_files) files_flagged = 0 files_qc_failed = 0 for idx, swc_path in enumerate(swc_files): if progress_callback is not None: progress_callback(idx, total_files, swc_path.name) try: headers, rows = _parse_swc(swc_path) ineligible = _check_auto_label_eligibility(rows) if ineligible is not None: failures.append(f"{swc_path.name}: skipped — {ineligible}") continue qc_result = None if qc_gate is not None: try: qc_result = _public_qc_result(qc_gate.evaluate(swc_path)) except Exception as exc: # noqa: BLE001 qc_result = {"passed": False, "reasons": [f"qc_error:{exc}"], "path": str(swc_path)} qc_reasons = _qc_failure_reasons(qc_result) if qc_reasons: files_qc_failed += 1 per_file.append( f"{swc_path.name}: QC rejected; skipped auto-labeling; " f"reasons={'; '.join(qc_reasons)}" ) continue orig_types = [int(r["type"]) for r in rows] orig_radii = [float(r["radius"]) for r in rows] types, radii, type_changes, radius_changes, pipeline_result = _apply_pipeline_to_rows( rows, opts=opts, stage1_path=stage1_path, stage2_path=stage2_path, gnn_state=gnn_state, branch3_state=branch3_state, use_subtree_stage2=use_subtree_stage2, ) out_path = operation_output_path_for_file( swc_path, "batch_auto_typing", output_dir=out_dir, timestamp=run_timestamp, ) _write_swc(out_path, headers, rows, types, radii) processed += 1 total_nodes += len(rows) total_type_changes += type_changes total_radius_changes += radius_changes cell_type = getattr(getattr(pipeline_result, "stage1", None), "cell_type", None) stage1_conf = getattr(getattr(pipeline_result, "stage1", None), "confidence", None) cell_type_source = "user" if _normalize_cell_type(getattr(opts, "cell_type", None)) else "stage1" flag_result = _score_flag_for_pipeline_result( in_path=swc_path, rows=rows, opts=opts, model_dir=model_dir, stage1_path=stage1_path, stage2_path=stage2_path, gnn_state=gnn_state, pipeline_result=pipeline_result, cell_type=cell_type, stage1_conf=stage1_conf, use_subtree_stage2=use_subtree_stage2, ) if bool(flag_result and flag_result.get("flagged")): files_flagged += 1 out_counts = { 1: sum(1 for t in types if int(t) == 1), 2: sum(1 for t in types if int(t) == 2), 3: sum(1 for t in types if int(t) == 3), 4: sum(1 for t in types if int(t) == 4), } per_file.append( f"{swc_path.name}: nodes={len(rows)}, type_changes={type_changes}, " f"radius_changes={radius_changes}, cell_type={cell_type or 'unknown'} " f"({cell_type_source}), qc_pass={None if qc_result is None else qc_result.get('passed')}, " f"flag={bool(flag_result and flag_result.get('flagged'))}, " f"out_types(soma/axon/basal/apic)={out_counts[1]}/{out_counts[2]}/" f"{out_counts[3]}/{out_counts[4]}" ) change_details.extend( _build_change_details( swc_path.name, rows, orig_types, types, orig_radii, radii, ) ) except Exception as e: # noqa: BLE001 failures.append(f"{swc_path.name}: {e}") zip_path: str | None = None if opts.zip_output and processed > 0: zip_target = in_dir / f"{out_dir.name}.zip" with zipfile.ZipFile(zip_target, "w", compression=zipfile.ZIP_DEFLATED) as zf: for f in sorted(out_dir.glob("*.swc")): zf.write(f, arcname=f"{out_dir.name}/{f.name}") zip_path = str(zip_target) payload = { "folder": str(in_dir), "out_dir": str(out_dir), "zip_path": zip_path, "files_total": len(swc_files), "files_processed": processed, "files_failed": len(failures), "files_qc_failed": files_qc_failed, "total_nodes": total_nodes, "total_type_changes": total_type_changes, "total_radius_changes": total_radius_changes, "files_flagged": files_flagged, "failures": failures, "per_file": per_file, "change_details": change_details, } log_path = write_text_report( operation_report_path_for_folder( in_dir, "batch_auto_typing", output_dir=out_dir, timestamp=run_timestamp, ), format_auto_typing_report_text(payload), ) return BatchResult( folder=str(in_dir), out_dir=str(out_dir), zip_path=zip_path, files_total=len(swc_files), files_processed=processed, files_failed=len(failures), files_qc_failed=files_qc_failed, total_nodes=total_nodes, total_type_changes=total_type_changes, total_radius_changes=total_radius_changes, files_flagged=files_flagged, failures=failures, per_file=per_file, log_path=log_path, )
__all__ = [ "is_available", "backend_status", "run_file", "run_batch", ]