Skip to content
Merged
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
8 changes: 7 additions & 1 deletion TPTBox/core/np_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,13 @@ def np_volume(arr: UINTARRAY, include_zero: bool = False) -> dict[int, int]:
"""
# np.bincount wins decisively when there are many labels (e.g. connected-component maps);
# cc3d statistics is faster for the few-label case typical of anatomical segmentations.
counts = np.bincount(arr.ravel()) if int(arr.max()) > 256 else cc3dstatistics(arr, use_crop=not include_zero)["voxel_counts"]
if int(arr.max()) > 256:
counts = np.bincount(arr.ravel())
else:
try:
counts = cc3dstatistics(arr, use_crop=not include_zero)["voxel_counts"]
except ValueError:
counts = np.bincount(arr.ravel())
if include_zero:
return {idx: i for idx, i in enumerate(counts) if i > 0}
return {idx: i for idx, i in enumerate(counts) if i > 0 and idx != 0}
Expand Down
7 changes: 4 additions & 3 deletions TPTBox/segmentation/VibeSeg/inference_nnunet.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ def run_inference_on_file(
_key_ResEnc: str = "__nnUNet*ResEnc",
fail_on_missing_memory=False,
_cpu_chunks: int | None = None,
no_squash=False,
logger=logger,
) -> tuple[Image_Reference, np.ndarray | None]:
"""Load a VibeSeg model and run inference on the supplied NIfTI images.
Expand Down Expand Up @@ -344,9 +345,9 @@ def run_inference_on_file(
if orientation is not None:
logger.print("orientation", orientation, f"from {input_nii[0].orientation}") if verbose else None
input_nii = [i.reorient(orientation) for i in input_nii]

logger.print("squash to fit float16") if verbose else None
input_nii = [squash_so_it_fits_in_float16(i) for i in input_nii]
if not no_squash:
logger.print("squash to fit float16") if verbose else None
input_nii = [squash_so_it_fits_in_float16(i) for i in input_nii]

if zoom is not None:
logger.print("rescale", f"{zoom=} from {input_nii[0].zoom}") if verbose else None
Expand Down
2 changes: 1 addition & 1 deletion TPTBox/segmentation/VibeSeg/vibeseg.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@

defaults = {
100: {"memory_base": 5500, "memory_factor": 25},
12: {"memory_base": 7000, "memory_factor": 200},
12: {"memory_base": 7000, "memory_factor": 200, "no_squash": True},
}


Expand Down
2 changes: 1 addition & 1 deletion examples/nako/stitching_T2w.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
logger = Print_Logger()
arg_parser = argparse.ArgumentParser()
arg_parser.add_argument("-i", "--inputfolder", help="input folder (where the rawdata folder is located)", required=True)
arg_parser.add_argument("-p", "--outparant", help="input folder (where the rawdata folder is located)", default="rawdata_stitched")
arg_parser.add_argument("-p", "--outparant", help="input folder (where the rawdata folder is located)", default="rawdata-stitched")
arg_parser.add_argument("-s", "--sleep", type=float, default=0, help="sleep after each save")
arg_parser.add_argument("-r", "--rawdata", type=str, default="rawdata", help="the rawdata folder to be searched")
args = arg_parser.parse_args()
Expand Down
89 changes: 85 additions & 4 deletions gui/snapshot_reviewer/review_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,17 @@
PREFETCH_COUNT = 20 # how many upcoming snapshots to warm in the background
CACHE_MAX_SIZE = 250 # how many decoded pixmaps to keep buffered (LRU)

# Cap on how many entries the review queue holds after filtering. 0 = no cap.
# Keeps the QListWidget snappy on very large datasets.
MAX_QUEUE_ENTRIES = 0

# Default derivatives folder scanned for snapshots (JPG/PNG). CLI: --parent-dir.
DEFAULT_SNAPSHOT_PARENT = "derivatives-VIBESeg-12-points-snp"

# Image fam-keys auto-checked in the Slicer launch dialog (pressing L).
# Overridable via the ⚙ Settings dialog; multiple entries allowed.
SLICER_AUTO_SELECT_KEYS: list[str] = ["ct"]

STYLESHEET = f"""
QMainWindow, QWidget {{
background: {BG};
Expand Down Expand Up @@ -797,6 +805,8 @@ def __init__(
prefetch_count: int,
cache_max_size: int,
buttons_per_row: int,
slicer_auto_select_keys: list[str] | None = None,
max_queue_entries: int = 0,
parent: QWidget | None = None,
):
super().__init__(parent)
Expand Down Expand Up @@ -838,6 +848,16 @@ def __init__(
self.btn_row_spin.setRange(1, 12)
self.btn_row_spin.setValue(int(buttons_per_row))
form.addRow("Buttons per row (restart):", self.btn_row_spin)

self.max_queue_spin = QSpinBox()
self.max_queue_spin.setRange(0, 1_000_000)
self.max_queue_spin.setValue(int(max_queue_entries))
self.max_queue_spin.setSpecialValueText("unlimited")
form.addRow("Max entries in review queue (0 = ∞):", self.max_queue_spin)

self.slicer_keys_edit = QLineEdit(", ".join(slicer_auto_select_keys or []))
self.slicer_keys_edit.setPlaceholderText("ct, mri, t1 (comma or space separated)")
form.addRow("Slicer auto-selected image keys [L]:", self.slicer_keys_edit)
layout.addLayout(form)

buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
Expand All @@ -849,12 +869,16 @@ def values(self) -> dict:
"""Return the edited settings as a keyword-mapping dict."""
deriv_lines = [ln.strip() for ln in self.deriv_edit.toPlainText().splitlines()]
deriv = [ln for ln in deriv_lines if ln]
raw_keys = self.slicer_keys_edit.text().replace(",", " ").split()
slicer_keys = [k.strip() for k in raw_keys if k.strip()]
return {
"slicer_exe": self.slicer_edit.text().strip(),
"derivatives_search": deriv,
"prefetch_count": int(self.prefetch_spin.value()),
"cache_max_size": int(self.cache_spin.value()),
"buttons_per_row": int(self.btn_row_spin.value()),
"slicer_auto_select_keys": slicer_keys,
"max_queue_entries": int(self.max_queue_spin.value()),
}


Expand All @@ -874,6 +898,7 @@ def __init__(
prefetch_count: int | None = None,
cache_max_size: int | None = None,
buttons_per_row: int | None = None,
max_queue_entries: int | None = None,
):
super().__init__()
self.dataset_path = dataset_path
Expand Down Expand Up @@ -915,12 +940,31 @@ def __init__(
else (self.log.get_setting("ui.buttons_per_row", str(BUTTONS_PER_ROW)) or BUTTONS_PER_ROW)
),
)
self.max_queue_entries: int = max(
0,
int(
max_queue_entries
if max_queue_entries is not None
else (self.log.get_setting("ui.max_queue_entries", str(MAX_QUEUE_ENTRIES)) or MAX_QUEUE_ENTRIES)
),
)
stored_keys = self.log.get_setting("slicer.auto_select_keys", None)
if stored_keys:
try:
self.slicer_auto_select_keys: list[str] = [s for s in json.loads(stored_keys) if isinstance(s, str)]
except Exception:
self.slicer_auto_select_keys = list(SLICER_AUTO_SELECT_KEYS)
else:
self.slicer_auto_select_keys = list(SLICER_AUTO_SELECT_KEYS)

# Persist the resolved values so they are stable across restarts.
self.log.set_setting("slicer.exe", self.slicer_exe)
self.log.set_setting("slicer.auto_select_keys", json.dumps(self.slicer_auto_select_keys))
self.log.set_setting("derivatives.search", json.dumps(self.derivatives_search))
self.log.set_setting("image.prefetch_count", str(self.prefetch_count))
self.log.set_setting("image.cache_max_size", str(self.cache_max_size))
self.log.set_setting("ui.buttons_per_row", str(self.buttons_per_row))
self.log.set_setting("ui.max_queue_entries", str(self.max_queue_entries))

self.all_snapshots: list[Path] = []
self.queue: list[Path] = []
Expand Down Expand Up @@ -1226,6 +1270,9 @@ def _apply_filter(self):
# Keep any temporarily injected path even if it would be filtered out
if self._injected_path and self._injected_path not in filtered:
filtered.insert(0, self._injected_path)
# Cap queue length so the QListWidget stays fast on huge datasets.
if self.max_queue_entries > 0 and len(filtered) > self.max_queue_entries:
filtered = filtered[: self.max_queue_entries]
self.queue = filtered
if self.current_idx >= len(self.queue):
self.current_idx = max(0, len(self.queue) - 1)
Expand Down Expand Up @@ -1316,6 +1363,8 @@ def _open_settings(self):
self.prefetch_count,
self.cache_max_size,
self.buttons_per_row,
self.slicer_auto_select_keys,
self.max_queue_entries,
self,
)
if dlg.exec() != QDialog.DialogCode.Accepted:
Expand All @@ -1325,15 +1374,20 @@ def _open_settings(self):
self.slicer_exe = v["slicer_exe"]
self.derivatives_search = list(v["derivatives_search"]) or list(_DERIVATIVES_SEARCH)
old_prefetch, old_cache, old_bpr = self.prefetch_count, self.cache_max_size, self.buttons_per_row
old_max_queue = self.max_queue_entries
self.prefetch_count = v["prefetch_count"]
self.cache_max_size = v["cache_max_size"]
self.buttons_per_row = v["buttons_per_row"]
self.max_queue_entries = int(v["max_queue_entries"])
self.slicer_auto_select_keys = list(v["slicer_auto_select_keys"])

self.log.set_setting("slicer.exe", self.slicer_exe)
self.log.set_setting("derivatives.search", json.dumps(self.derivatives_search))
self.log.set_setting("image.prefetch_count", str(self.prefetch_count))
self.log.set_setting("image.cache_max_size", str(self.cache_max_size))
self.log.set_setting("ui.buttons_per_row", str(self.buttons_per_row))
self.log.set_setting("ui.max_queue_entries", str(self.max_queue_entries))
self.log.set_setting("slicer.auto_select_keys", json.dumps(self.slicer_auto_select_keys))

# Reset the cached BIDS_Global_info so the new derivatives list applies.
if hasattr(self, "_slicer_bgi"):
Expand All @@ -1348,6 +1402,9 @@ def _open_settings(self):
note += " Buttons-per-row change takes effect after a restart."
if self.prefetch_count != old_prefetch:
note += " Prefetch tuned."
if self.max_queue_entries != old_max_queue:
self._apply_filter()
note += " Queue cap applied."
self.status_bar.showMessage(note, 5000)

def _refresh_queue_list(self):
Expand Down Expand Up @@ -1530,6 +1587,7 @@ def _mark(self, verdict_key: str):
if p is None or verdict_key not in VERDICT_META:
return
reason = self.reason_edit.text().strip()
marked_idx = self.current_idx
self._next()
QApplication.processEvents()
if verdict_key == "good":
Expand All @@ -1541,16 +1599,25 @@ def _mark(self, verdict_key: str):
actual_vk = actual["verdict"] if actual else verdict_key
label = VERDICT_META.get(actual_vk, (verdict_key,))[0]
self.status_bar.showMessage(f"{label}: {p.name}", 3000)
self._post_mark()
self._post_mark(marked_idx=marked_idx, marked_vk=actual_vk)

def _post_mark(self):
def _post_mark(self, marked_idx: int | None = None, marked_vk: str | None = None):
# Warm the next 10 snapshots in the background so stepping forward
# (or jumping back into recently-seen territory) doesn't stall on I/O.
self._prefetch_upcoming()
self._update_stats()
self._update_verdict_log()
self._refresh_queue_list()
self._show_current()
# Update just the single row that changed instead of rebuilding the
# entire QListWidget — at 10k entries a full rebuild on every keypress
# is what makes the UI feel sluggish.
if marked_idx is not None and 0 <= marked_idx < self.queue_list.count():
item = self.queue_list.item(marked_idx)
if item is not None:
meta = VERDICT_META.get(marked_vk) if marked_vk else None
if meta is not None:
item.setForeground(QColor(meta[1]))
else:
self._refresh_queue_list()

def _open_in_slicer(self):
"""Open the current snapshot's BIDS family in 3D Slicer."""
Expand Down Expand Up @@ -1588,6 +1655,19 @@ def _on_overwrite(jpg_path: Path, path_str: str, _fam: dict[str, list[BIDS_FILE]
for k1, k2, coord in p2.items():
p[k1, k2] = coord
p.save(f)
# out = _fam["ct"][0].get_changed_path(
# "nii.gz",
# "msk",
# parent="derivatives-final-points",
# info={"seg": "treg", "mod": None},
# )
# logger.on_debug("unlink", out, out.exists())
# out.unlink(missing_ok=True)
# for t in _fam.get("msk_seg-treg", []):
# logger.on_debug(t)
# if t.parent == "derivatives-final-points":
# logger.on_debug("unlink", t.file["nii.gz"])
# t.file["nii.gz"].unlink(missing_ok=True)

dlg = SlicerLaunchDialog(
p,
Expand All @@ -1597,6 +1677,7 @@ def _on_overwrite(jpg_path: Path, path_str: str, _fam: dict[str, list[BIDS_FILE]
parent=self,
slicer_exe=self.slicer_exe,
derivatives_search=self.derivatives_search,
auto_select_keys=self.slicer_auto_select_keys,
)
dlg.exec()

Expand Down
8 changes: 5 additions & 3 deletions gui/snapshot_reviewer/slicer_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ def __init__(
parent: QWidget | None = None,
slicer_exe: str | None = None,
derivatives_search: list[str] | None = None,
auto_select_keys: list[str] | None = None,
):
super().__init__(parent)
self.setWindowTitle("Open in Slicer")
Expand All @@ -387,6 +388,7 @@ def __init__(
self._watchers: list[SlicerFileWatcher] = []
self._slicer_exe = slicer_exe or SLICER_EXE
self._derivatives_search = list(derivatives_search or _DERIVATIVES_SEARCH)
self._auto_select_keys = [k for k in (auto_select_keys or ["ct"]) if k]

# Build fam
self._fam = build_fam_for_snapshot(snp_path, dataset_path, bgi, self._derivatives_search)
Expand Down Expand Up @@ -419,7 +421,7 @@ def _build_ui(self):
for key, bf in self._images:
p = bf.file.get("nii.gz") or bf.file.get("nii", "")
cb = QCheckBox(f"{key}\n {Path(str(p)).name}")
cb.setChecked(True)
cb.setChecked(key in self._auto_select_keys)
img_v.addWidget(cb)
self._img_checks.append((cb, bf))
else:
Expand All @@ -435,7 +437,7 @@ def _build_ui(self):
for key, bf in self._masks:
p = bf.file.get("nii.gz") or bf.file.get("nii", "")
cb = QCheckBox(f"{key}\n {Path(str(p)).name}")
cb.setChecked(False)
cb.setChecked(key in self._auto_select_keys)
msk_v.addWidget(cb)
self._msk_checks.append((cb, bf))
else:
Expand All @@ -450,7 +452,7 @@ def _build_ui(self):
for key, bf in self._markups:
p = bf.file.get("json", "")
cb = QCheckBox(f"{key}\n {Path(str(p)).name}")
cb.setChecked(False)
cb.setChecked(key in self._auto_select_keys)
mrk_v.addWidget(cb)
self._mrk_checks.append((cb, bf))
else:
Expand Down
Loading