#!/usr/bin/env python3 """Tkinter front-end for scripts/run_multifold.py.""" from __future__ import annotations import argparse import contextlib import csv import io import json import os import sys import signal import subprocess import threading import time import shutil import re from pathlib import Path from typing import Dict, Optional from types import SimpleNamespace import numpy as np import torch import torch.nn.functional as F from sklearn.metrics import roc_auc_score, roc_curve, auc import matplotlib.pyplot as plt import tkinter as tk from tkinter import filedialog, messagebox from classes.hypertower import HyperTower from classes.backbones import list_names as list_backbones from classes import build_papila_clinical BACKBONES = [ "efficientnet_b0", "resnet50", "densenet121", "refugelike", "refuge_densenet", "refuge_efficient_b0", "refuge_efficient_b7", ] FUSION_MODES = ["fused", "image_only", "metadata_only", "vote"] EVAL_MODES = ["multiclass", "binary"] class Multifold: """Core multifold runner extracted from scripts/run_multifold.""" def __init__(self, args: argparse.Namespace) -> None: self.args = args @staticmethod def build_parser() -> argparse.ArgumentParser: ap = argparse.ArgumentParser(description="Run k-fold CV and emit per-fold logs, npy, and ROC plots.") ap.add_argument("--image-dir", default="Papila/FundusImages") ap.add_argument("--clinical-dir", default="Papila/ClinicalData") ap.add_argument("--label-col", default="Diagnosis") ap.add_argument("--cat-cols", nargs="*", default=["Gender", "Phakic/Pseudophakic"]) ap.add_argument("--backbone", type=str, default="efficientnet_b0", choices=list_backbones()) ap.add_argument("--freeze-ratio", type=float, default=0.0) ap.add_argument("--fusion-mode", default="fused", choices=["fused","image_only","metadata_only","vote"]) ap.add_argument("--epochs", type=int, default=5) ap.add_argument("--batch-size", type=int, default=8) ap.add_argument("--lr", type=float, default=1e-4) ap.add_argument("--num-classes", type=int, default=3) ap.add_argument("--n-splits", type=int, default=5) ap.add_argument("--fold-seed", type=int, default=42, help="Random seed for patient-level splits") ap.add_argument("--holdout-per-class", type=int, default=0, help="Reserve this many samples per class for a monitoring holdout (0 disables)") ap.add_argument("--holdout-seed", type=int, default=123, help="Random seed used when sampling the holdout subset") ap.add_argument("--img-crop-manifest", type=Path, default=None, help="Optional manifest for UNet cropper (enables disc-centric crops)") ap.add_argument("--img-crop-weights", type=Path, default=None, help="UNet checkpoint weights for cropping") ap.add_argument("--img-crop-normalize", choices=["none", "imagenet", "per_image"], default="per_image") ap.add_argument("--img-crop-threshold", type=float, default=0.5) ap.add_argument("--img-crop-scale", type=float, default=2.5) ap.add_argument("--img-crop-size", type=int, default=224) ap.add_argument("--img-crop-cache", type=Path, default=Path("analysis_data/hypertower_crops")) ap.add_argument("--img-crop-tta", action="store_true") ap.add_argument("--img-crop-gt", action="store_true", help="Use ground-truth masks/contours from manifest for cropping instead of UNet") ap.add_argument("--no-img-augment", dest="img_augment", action="store_false", help="Disable random image augmentations for the image tower") ap.set_defaults(img_augment=True) ap.add_argument("--img-geometry-features", action="store_true", help="Append disc/cup geometry features to the image tower (requires cropping)") ap.add_argument("--shortname", default="multi") ap.add_argument("--plot-head", default="fused", choices=["image","fused","metadata"], help="Which head to plot/aggregate.") ap.add_argument("--class-names", nargs="*", default=None) ap.add_argument("--no-se", dest="use_se", action="store_false", help="Disable SE attention in the bridge (default: enabled)") ap.set_defaults(use_se=True) ap.add_argument("--se-reduction", type=int, default=16, choices=[8,16,32], help="SE bottleneck: fusion_dim // reduction (default 16)") ap.add_argument("--se-pre-norm", dest="se_pre_norm", action="store_true", help="Enable LayerNorm on branches before the multiply (default)") ap.add_argument("--no-se-pre-norm", dest="se_pre_norm", action="store_false", help="Disable LayerNorm on branches before the multiply") ap.set_defaults(se_pre_norm=True) ap.add_argument("--se-where", choices=["bridge","tower","both","none"], default="bridge", help="Where to apply SE: bridge (default), tower, both, or none") ap.add_argument("--se-reduction-tower", type=int, default=16, choices=[8,16,32], help="SE bottleneck for tower vectors (default 16)") ap.add_argument("--se-pre-norm-tower", dest="se_pre_norm_tower", action="store_true", help="Enable LayerNorm on tower vectors before SE (default)") ap.add_argument("--no-se-pre-norm-tower", dest="se_pre_norm_tower", action="store_false", help="Disable LayerNorm on tower vectors before SE (default: enabled)") ap.set_defaults(se_pre_norm_tower=True) ap.add_argument("--eval_mode", choices=["multiclass","binary"], default="multiclass", help="Multiclass (3 classes) or binary (Healthy vs Glaucoma; drops Suspect).") ap.add_argument("--warmup-tower-epochs", type=int, default=2) ap.add_argument("--warmup-fused-epochs", type=int, default=3) ap.add_argument("--gradual-thaw", action="store_true", help="Enable gradual backbone thawing schedule (image/metadata towers)") ap.add_argument("--thaw-phase-duration", type=int, default=5, help="Epochs per thaw phase (default 5)") ap.add_argument("--thaw-ratio", type=float, default=0.33, help="Fraction of blocks to unfreeze each phase (default 0.33)") ap.add_argument("--thaw-target", choices=["image","metadata","both"], default="image", help="Which tower(s) to apply gradual thaw to (default image)") ap.add_argument("--thaw-start-epoch", type=int, default=-1, help="Epoch to start thawing (default: warmup_tower_epochs)") ap.add_argument("--initial-freeze", action="store_true", help="Before thaw start, force backbone(s) fully frozen (default off)") ap.add_argument("--early-stop", action="store_true", help="Enable early stopping") ap.add_argument("--early-metric", default=None, help="Metric key to monitor (e.g., eval_loss, auc_fused, acc_fused).") ap.add_argument("--early-mode", choices=["auto","min","max"], default="auto") ap.add_argument("--early-monitor-holdout", action="store_true", help="Monitor the holdout metric for early stopping/checkpointing (requires holdout set).") ap.add_argument("--early-patience", type=int, default=7) ap.add_argument("--early-min-delta", type=float, default=0.0) ap.add_argument("--checkpoint-best", action="store_true", help="Save best weights to disk during training") ap.add_argument("--focal-gamma", type=float, default=0.0, help="Focal loss exponent (0 disables focal loss)") ap.add_argument("--balanced-sampler", action="store_true", help="Use a class-balanced bootstrapped sampler for the training loader") ap.add_argument("--run-id", default=None, help=argparse.SUPPRESS) return ap @staticmethod def _serialize_arg(value): if isinstance(value, Path): return str(value) if isinstance(value, (list, tuple)): return [Multifold._serialize_arg(v) for v in value] return value def _export_run_settings(self, run_dir: Path) -> None: data = {key: self._serialize_arg(value) for key, value in vars(self.args).items()} try: with open(run_dir / "cli_args.json", "w", encoding="utf-8") as fh: json.dump(data, fh, indent=2) except Exception as exc: # pragma: no cover print(f"[run_multifold] Failed to write cli_args.json: {exc}") def run(self, callback=None) -> None: if callback: sink = _StreamCallback(callback) with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink): self._run_impl() else: self._run_impl() # ---- Helper functions copied from run_multifold ---------------- @staticmethod def eval_collect_logits(ht: HyperTower): device = ht.device ht.img_tower.eval(); ht.md_tower.eval() if getattr(ht, "mode", "fused") != "vote": ht.bridge.eval() y_all = [] pf, pi, pm = [], [], [] for batch in ht.test_loader: if len(batch) == 4: imgs, metas, geometry, labels = batch else: imgs, metas, labels = batch geometry = None imgs = imgs.to(device) metas = metas.to(device) labels = labels.to(device) if geometry is not None and geometry.numel() > 0: geometry = geometry.to(device) else: geometry = None if ht.mode == "vote": img_feats = ht.img_tower(imgs, geometry) md_feats = ht.md_tower(metas) out_img = ht.head_img(img_feats) out_md = ht.head_md(md_feats) out_fused = ht.vote(out_img, out_md) else: img_feats = ht.img_tower(imgs, geometry) md_feats = ht.md_tower(metas) outputs = ht.bridge(img_feats, md_feats) if isinstance(outputs, tuple): out_fused, out_img, out_md = outputs else: out_fused, out_img, out_md = outputs, None, None y_all.append(labels.detach().cpu().numpy()) pf.append(F.softmax(out_fused, dim=1).detach().cpu().numpy()) if out_img is not None: pi.append(F.softmax(out_img, dim=1).detach().cpu().numpy()) if out_md is not None: pm.append(F.softmax(out_md, dim=1).detach().cpu().numpy()) y_true = np.concatenate(y_all, axis=0) pf = np.concatenate(pf, axis=0) if pf else None pi = np.concatenate(pi, axis=0) if pi else None pm = np.concatenate(pm, axis=0) if pm else None return y_true, pf, pi, pm @staticmethod def auc_for(y, p): y = np.asarray(y) if p is None: return float("nan") if p.ndim == 1 or p.shape[1] == 1: return roc_auc_score(y, p.ravel()) if p.shape[1] == 2: return roc_auc_score(y, p[:, 1]) return roc_auc_score(y, p, multi_class="ovr", average="macro") @staticmethod def per_class_roc(y, p): if p is None: return {} K = p.shape[1] out = {} for k in range(K): y_bin = (y == k).astype(np.uint8) fpr, tpr, _ = roc_curve(y_bin, p[:, k]) out[k] = (fpr, tpr, auc(fpr, tpr) if len(fpr) > 1 else np.nan) return out @staticmethod def plot_mean_sd(per_fold_curves, out_png, class_names=None, title="Mean OVR ROC (±1 SD)"): if not per_fold_curves: return fpr_grid = np.linspace(0, 1, 501) fig = plt.figure(figsize=(10, 8)); ax = fig.add_subplot(111) ax.plot([0, 1], [0, 1], linestyle="--", linewidth=1) keys = sorted({k for d in per_fold_curves for k in d.keys()}) if class_names is not None and len(class_names) == len(keys): name_map = {k: class_names[i] for i, k in enumerate(keys)} else: name_map = {k: f"class {k}" for k in keys} for k in keys: tprs, aucs = [], [] for d in per_fold_curves: if k not in d: continue fpr, tpr, a = d[k] tprs.append(np.interp(fpr_grid, fpr, tpr)) aucs.append(a) if not tprs: continue tprs = np.vstack(tprs) mean = tprs.mean(axis=0); std = tprs.std(axis=0) auc_mean = np.nanmean(aucs); auc_std = np.nanstd(aucs) label = f"{name_map[k]} (AUC {auc_mean:.3f}±{auc_std:.3f})" ax.plot(fpr_grid, mean, linewidth=2, label=label) ax.fill_between(fpr_grid, np.maximum(mean - std, 0), np.minimum(mean + std, 1), alpha=0.15) ax.set_xlabel("False Positive Rate"); ax.set_ylabel("True Positive Rate") ax.set_title(title); ax.legend(loc="lower right"); fig.tight_layout() fig.savefig(out_png, dpi=160); plt.close(fig) @staticmethod def plot_overlays(per_fold_curves, out_png, title="Per-fold OVR ROC overlays"): if not per_fold_curves: return fig = plt.figure(figsize=(10, 8)); ax = fig.add_subplot(111) ax.plot([0, 1], [0, 1], linestyle="--", linewidth=1) for d in per_fold_curves: for _, (fpr, tpr, _) in d.items(): ax.plot(fpr, tpr, alpha=0.25, linewidth=1) ax.set_xlabel("False Positive Rate"); ax.set_ylabel("True Positive Rate") ax.set_title(title); fig.tight_layout(); fig.savefig(out_png, dpi=160); plt.close(fig) @staticmethod def plot_per_class_overlays(per_fold_curves, out_dir: Path, class_names=None, head_name: str = "fused"): if not per_fold_curves: return keys = sorted({k for d in per_fold_curves for k in d.keys()}) if class_names is not None and len(class_names) == len(keys): name_map = {k: class_names[i] for i, k in enumerate(keys)} else: name_map = {k: f"class_{k}" for k in keys} out_dir.mkdir(parents=True, exist_ok=True) for k in keys: per_fold = [] for fold_idx, d in enumerate(per_fold_curves, start=1): if k not in d: continue fpr, tpr, auc_val = d[k] per_fold.append((fold_idx, fpr, tpr, auc_val)) if not per_fold: continue fig = plt.figure(figsize=(10, 8)); ax = fig.add_subplot(111) ax.plot([0, 1], [0, 1], linestyle="--", linewidth=1, color="grey") for fold_idx, fpr, tpr, auc_val in per_fold: label = f"Fold {fold_idx} (AUC {auc_val:.3f})" ax.plot(fpr, tpr, linewidth=1.5, label=label) ax.set_xlabel("False Positive Rate"); ax.set_ylabel("True Positive Rate") ax.set_title(f"{head_name} head — {name_map[k]} ROC per fold") ax.legend(loc="lower right", frameon=True) fig.tight_layout() safe_name = name_map[k].replace(" ", "_") fig.savefig(out_dir / f"roc_{head_name}_{safe_name}_perfold.png", dpi=160) plt.close(fig) @staticmethod def move_if_exists(src: Path, dest: Path): if src.exists(): dest.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(src), str(dest)) @staticmethod def move_dir_overwrite(src: Path, dest: Path): """Move directory, replacing destination if it already exists.""" if not src.exists(): return if dest.exists(): shutil.rmtree(dest) dest.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(src), str(dest)) @staticmethod def load_holdout_roc_curves(dir_path: Path, head: str): """ Load the holdout ROC JSON for a given head from a directory like foldX_roc_curves_holdout_best, returning the per-class curve map expected by plotting helpers. """ if not dir_path.exists() or not dir_path.is_dir(): return {} head_name = {"image": "image", "metadata": "metadata"}.get(head, "fused") best_path = None best_epoch = -1 for path in dir_path.glob(f"epoch*_holdout_{head_name}.json"): m = re.match(r"epoch(\\d+)_", path.stem) if not m: continue try: epoch_idx = int(m.group(1)) except Exception: continue if epoch_idx > best_epoch: best_epoch = epoch_idx best_path = path if best_path is None: return {} try: data = json.loads(best_path.read_text()) except Exception: return {} per_class = data.get("per_class") or {} curves = {} for k, vals in per_class.items(): if not isinstance(vals, dict): continue fpr = vals.get("fpr"); tpr = vals.get("tpr"); auc_val = vals.get("auc") if fpr is None or tpr is None or auc_val is None: continue try: idx = int(k) except Exception: continue curves[idx] = (np.array(fpr, dtype=float), np.array(tpr, dtype=float), float(auc_val)) return curves def _run_impl(self) -> None: args = self.args args.num_classes = 2 if args.eval_mode == "binary" else 3 if getattr(args, "run_id", None): run_id = str(args.run_id) else: ts = time.strftime("%Y%m%d_%H%M%S") run_id = f"{args.shortname}_{ts}" if args.shortname else ts run_dir = Path("analysis_data") / args.shortname / run_id (run_dir / "plots").mkdir(parents=True, exist_ok=True) base_models_dir = Path("models") / args.shortname / run_id base_models_dir.mkdir(parents=True, exist_ok=True) self._export_run_settings(run_dir) clinical = build_papila_clinical( args.image_dir, args.clinical_dir, args.label_col, args.cat_cols, n_splits=args.n_splits, random_seed=args.fold_seed, ) holdout_df = None if args.holdout_per_class > 0: df_full = clinical.df.copy() if args.eval_mode == "binary": df_full = df_full[df_full[args.label_col].isin([0, 1])].reset_index(drop=True) rng = np.random.default_rng(args.holdout_seed) holdout_indices = [] for label, group in df_full.groupby(args.label_col): n = min(args.holdout_per_class, len(group)) if n <= 0: continue selected = rng.choice(group.index.to_numpy(), size=n, replace=False) holdout_indices.extend(selected.tolist()) if holdout_indices: holdout_indices = sorted(set(holdout_indices)) holdout_df = df_full.loc[holdout_indices].reset_index(drop=True) train_df = df_full.drop(index=holdout_indices).reset_index(drop=True) clinical.frames = [train_df.copy()] clinical.df = train_df.copy() clinical._infer_or_validate_feature_types() clinical._compute_numeric_stats() clinical._build_cat_maps() clinical._compute_feature_dim() clinical._build_kfold_indices() holdout_path = run_dir / "holdout.csv" holdout_df.to_csv(holdout_path, index=False) print(f"[run_multifold] Reserved holdout set of {len(holdout_df)} samples (saved to {holdout_path})") fold_macro_aucs = [] per_fold_ovr_curves_for_plot_head = [] holdout_per_fold_ovr_curves_for_plot_head = [] def _default_monitor(): if args.early_metric: mode = getattr(args, "early_mode", "auto") if mode == "auto": mode = "min" if "loss" in args.early_metric.lower() else "max" return args.early_metric, mode if args.fusion_mode == "image_only": return "auc_img", "max" if args.fusion_mode == "metadata_only": return "auc_md", "max" return "auc_fused", "max" monitor_name, monitor_mode = _default_monitor() fold_summaries = [] best_metric_values = [] for fold in range(args.n_splits): print(f"\n=== Fold {fold+1}/{args.n_splits} ===") if args.early_metric: early_metric = args.early_metric else: if args.fusion_mode == "image_only": early_metric = "auc_img" elif args.fusion_mode == "metadata_only": early_metric = "auc_md" else: early_metric = "auc_fused" ht_args = SimpleNamespace( image_dir=args.image_dir, clinical_dir=args.clinical_dir, label_col=args.label_col, cat_cols=args.cat_cols, batch_size=args.batch_size, epochs=args.epochs, lr=args.lr, num_classes=args.num_classes, img_augment=args.img_augment, focal_gamma=args.focal_gamma, eval_mode=args.eval_mode, fold=fold, run_dir=str(run_dir), models_dir=str((base_models_dir / f"fold{fold}").resolve()), backbone=args.backbone, freeze_ratio=args.freeze_ratio, fusion_mode=args.fusion_mode, use_se=args.use_se, se_reduction=args.se_reduction, se_pre_norm=args.se_pre_norm, se_where=args.se_where, se_reduction_tower=args.se_reduction_tower, se_pre_norm_tower=args.se_pre_norm_tower, warmup_tower_epochs=args.warmup_tower_epochs, warmup_fused_epochs=args.warmup_fused_epochs, gradual_thaw=args.gradual_thaw, thaw_phase_duration=args.thaw_phase_duration, thaw_ratio=args.thaw_ratio, thaw_target=args.thaw_target, thaw_start_epoch=args.thaw_start_epoch, initial_freeze=args.initial_freeze, bcd_prob=0.5, bcd_p0=0.20, bcd_min=0.05, bcd_max=0.30, bcd_k=0.4, bcd_metric="auc", bcd_alpha_batch=0.2, bcd_alpha_tower=0.3, bcd_explore_floor=0.15, aux_img=0.05, aux_md=0.05, aux_detach=True, ema_alpha=0.9, entropy_ema=0.7, early_stop=args.early_stop, early_metric=early_metric, early_mode=args.early_mode, early_patience=args.early_patience, early_min_delta=args.early_min_delta, checkpoint_best=args.checkpoint_best, holdout_df=holdout_df, img_crop_manifest=args.img_crop_manifest, img_crop_weights=args.img_crop_weights, img_crop_normalize=args.img_crop_normalize, img_crop_threshold=args.img_crop_threshold, img_crop_scale=args.img_crop_scale, img_crop_size=args.img_crop_size, img_crop_cache=args.img_crop_cache, img_crop_tta=args.img_crop_tta, img_crop_gt=args.img_crop_gt, ) (base_models_dir / f"fold{fold}").mkdir(parents=True, exist_ok=True) ht = HyperTower(clinical, ht_args) ht.train() epoch_log_path = run_dir / "epoch_log.csv" best_row_data = None best_value = None if epoch_log_path.exists(): try: with epoch_log_path.open("r", newline="", encoding="utf-8") as fp: reader = csv.DictReader(fp) for row in reader: val_raw = row.get(monitor_name) try: val = float(val_raw) except (TypeError, ValueError): continue if best_value is None: best_value = val best_row_data = dict(row) else: if monitor_mode == "max": if val > best_value: best_value = val best_row_data = dict(row) else: if val < best_value: best_value = val best_row_data = dict(row) except OSError as exc: # pragma: no cover print(f"[run_multifold] Warning: failed to read {epoch_log_path} ({exc}); skipping best-metric parse.") def _coerce_types(row: dict | None) -> dict | None: if row is None: return None out = {} for key, value in row.items(): if value is None or value == "": out[key] = None continue try: out[key] = float(value) if key == "epoch": out[key] = int(float(value)) except ValueError: out[key] = value return out best_row_converted = _coerce_types(best_row_data) if isinstance(best_row_converted, dict) and "epoch" in best_row_converted: try: best_epoch = int(best_row_converted["epoch"]) except Exception: best_epoch = None else: best_epoch = None if best_value is not None: best_metric_values.append(best_value) fold_summaries.append({ "fold": fold, "best_metric_value": best_value, "best_epoch": best_epoch, "monitor": monitor_name, "warmup_tower_epochs": int(getattr(ht, "warmup_tower_epochs", getattr(args, "warmup_tower_epochs", 2))), "warmup_fused_epochs": int(getattr(ht, "warmup_fused_epochs", getattr(args, "warmup_fused_epochs", 3))), "main_epochs": int(getattr(ht, "epochs", args.epochs)), "total_epochs": int(getattr(ht, "total_epochs", args.epochs)), "stats": best_row_converted, }) self.move_if_exists(run_dir / "train.log", run_dir / f"fold{fold}_train.log") self.move_if_exists(epoch_log_path, run_dir / f"fold{fold}_epoch_log.csv") roc_src = run_dir / "roc_curves" if roc_src.exists() and roc_src.is_dir(): self.move_dir_overwrite(roc_src, run_dir / f"fold{fold}_roc_curves") roc_best_src = run_dir / "roc_curves_best" if roc_best_src.exists() and roc_best_src.is_dir(): self.move_dir_overwrite(roc_best_src, run_dir / f"fold{fold}_roc_curves_best") roc_holdout_src = run_dir / "roc_curves_holdout_best" if roc_holdout_src.exists() and roc_holdout_src.is_dir(): self.move_dir_overwrite(roc_holdout_src, run_dir / f"fold{fold}_roc_curves_holdout_best") holdout_curves = self.load_holdout_roc_curves(run_dir / f"fold{fold}_roc_curves_holdout_best", args.plot_head) if holdout_curves: holdout_per_fold_ovr_curves_for_plot_head.append(holdout_curves) # Reload the recorded best checkpoint so downstream metrics/plots use the same epoch as the summary. best_snapshot = Path(ht_args.models_dir) / "model_best.pt" if best_snapshot.exists(): try: try: state = torch.load(best_snapshot, map_location=ht.device, weights_only=False) except TypeError: state = torch.load(best_snapshot, map_location=ht.device) ht._restore_from_state(state) except Exception as exc: # pragma: no cover print(f"[run_multifold] Warning: failed to reload best checkpoint for fold {fold}: {exc}") train_df, test_df = clinical.get_split_dfs(fold) y_true, p_fused, p_img, p_md = self.eval_collect_logits(ht) if args.eval_mode == "binary": keep = np.isin(y_true, [0, 1]) if keep.sum() == 0: raise RuntimeError("No binary samples left after filtering.") y_true = y_true[keep] if p_fused is not None: p_fused = p_fused[keep] if p_img is not None: p_img = p_img[keep] if p_md is not None: p_md = p_md[keep] def _slice2(p): if p is None: return None if p.ndim == 2 and p.shape[1] >= 2: return p[:, :2] return p p_fused = _slice2(p_fused) p_img = _slice2(p_img) p_md = _slice2(p_md) np.save(run_dir / f"fold{fold}_y_true.npy", y_true) if p_img is not None: np.save(run_dir / f"fold{fold}_probs_img.npy", p_img) if p_fused is not None: np.save(run_dir / f"fold{fold}_probs_fused.npy", p_fused) if p_md is not None: np.save(run_dir / f"fold{fold}_probs_md.npy", p_md) if args.plot_head == "image": p_plot = p_img elif args.plot_head == "metadata": p_plot = p_md else: p_plot = p_fused fold_auc = self.auc_for(y_true, p_plot) if p_plot is not None else float("nan") fold_macro_aucs.append(fold_auc) per_fold_ovr_curves_for_plot_head.append(self.per_class_roc(y_true, p_plot)) auc_mean = float(np.nanmean(fold_macro_aucs)) if fold_macro_aucs else float("nan") auc_std = float(np.nanstd(fold_macro_aucs)) if fold_macro_aucs else float("nan") class_names = args.class_names if args.class_names else ( ["Healthy", "Glaucoma"] if args.eval_mode == "binary" else ["Healthy","Glaucoma","Suspect"] ) self.plot_per_class_overlays( per_fold_ovr_curves_for_plot_head, out_dir=run_dir / "plots", class_names=class_names, head_name=args.plot_head, ) self.plot_mean_sd( per_fold_ovr_curves_for_plot_head, out_png=run_dir / "plots" / f"roc_{args.plot_head}_mean_ovr.png", class_names=class_names, title=f"Mean OVR ROC (±1 SD) — {args.plot_head} head" ) if holdout_per_fold_ovr_curves_for_plot_head: holdout_plots_dir = run_dir / "plots" / "holdout" holdout_plots_dir.mkdir(parents=True, exist_ok=True) self.plot_per_class_overlays( holdout_per_fold_ovr_curves_for_plot_head, out_dir=holdout_plots_dir, class_names=class_names, head_name=f"{args.plot_head}_holdout", ) self.plot_mean_sd( holdout_per_fold_ovr_curves_for_plot_head, out_png=holdout_plots_dir / f"roc_{args.plot_head}_holdout_mean_ovr.png", class_names=class_names, title=f"Holdout Mean OVR ROC (±1 SD) — {args.plot_head} head", ) if best_metric_values: best_metric_mean = float(np.mean(best_metric_values)) best_metric_std = float(np.std(best_metric_values, ddof=0)) else: best_metric_mean = None best_metric_std = None warmup_tower_used = ( fold_summaries[0].get("warmup_tower_epochs") if fold_summaries else getattr(args, "warmup_tower_epochs", None) ) warmup_fused_used = ( fold_summaries[0].get("warmup_fused_epochs") if fold_summaries else getattr(args, "warmup_fused_epochs", None) ) total_epochs_used = ( fold_summaries[0].get("total_epochs") if fold_summaries else args.epochs ) summary = { "run_id": run_id, "backbone": args.backbone, "freeze_ratio": args.freeze_ratio, "fusion_mode": args.fusion_mode, "epochs": args.epochs, "warmup_tower_epochs": warmup_tower_used, "warmup_fused_epochs": warmup_fused_used, "total_epochs": total_epochs_used, "batch_size": args.batch_size, "lr": args.lr, "num_classes": args.num_classes, "eval_mode": args.eval_mode, "n_splits": args.n_splits, "focal_gamma": args.focal_gamma, "balanced_sampler": bool(args.balanced_sampler), "se": { "enabled": bool(args.use_se), "reduction": int(args.se_reduction), "pre_norm": bool(args.se_pre_norm), }, "best_metric": monitor_name, "best_metric_mode": monitor_mode, "best_metric_mean": best_metric_mean, "best_metric_std": best_metric_std, "fold_metrics": fold_summaries, } with open(run_dir / "summary.json", "w") as jf: json.dump(summary, jf, indent=2) print(f"Summary written to {run_dir / 'summary.json'}") class _StreamCallback(io.TextIOBase): def __init__(self, callback): self.callback = callback def write(self, s): if self.callback and s: self.callback(s) return len(s) def flush(self): pass class MultifoldRunner: """Wrapper used by the GUI to execute Multifold runs.""" def run(self, cli_args: list[str], callback=None) -> None: parser = Multifold.build_parser() if callback: sink = _StreamCallback(callback) with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink): args = parser.parse_args(cli_args) else: args = parser.parse_args(cli_args) multifold = Multifold(args) multifold.run(callback) class MultifoldFrontend(tk.Tk): """Simple Tkinter GUI for configuring and launching run_multifold.py.""" def __init__(self) -> None: super().__init__() self.title("run_multifold.py") self.vars: Dict[str, tk.Variable] = {} self._configure_scale() self.geometry(self.window_geometry) self._build_form() self._build_output() self.status_var = tk.StringVar(value="Idle") status_frame = tk.Frame(self) status_frame.pack(fill="x", padx=6, pady=(0, 6)) tk.Label(status_frame, text="Status:", font=self.base_font).pack(side="left") tk.Label(status_frame, textvariable=self.status_var, anchor="w", font=self.base_font).pack(side="left", fill="x") self.force_cpu = False self.after(0, self.on_device_change) self.repo_root = Path(__file__).resolve().parents[1] self.current_process: Optional[subprocess.Popen] = None self.current_thread: Optional[threading.Thread] = None self.current_run_dir: Optional[Path] = None # ---- UI helpers ------------------------------------------------- def _configure_scale(self) -> None: try: screen_w = self.winfo_screenwidth() screen_h = self.winfo_screenheight() except Exception: screen_w, screen_h = 1920, 1080 scale = screen_w / 1920.0 scale = max(0.8, min(scale, 1.6)) base_size = max(10, int(10 * scale)) self.base_font = ("TkDefaultFont", base_size) self.bold_font = ("TkDefaultFont", max(base_size, 11), "bold") self.entry_font = ("TkDefaultFont", max(9, base_size)) self.button_font = ("TkDefaultFont", max(9, base_size - 1)) self.mono_font = ("Courier", max(9, base_size)) self.text_height = max(18, int(24 * scale)) width = int(720 * scale) height = int(860 * scale) self.window_geometry = f"{width}x{height}" try: self.tk.call("tk", "scaling", scale) except Exception: pass def _entry(self, parent: tk.Widget, label: str, default: str = "") -> tk.Entry: frame = tk.Frame(parent) frame.pack(fill="x", padx=4, pady=2) tk.Label(frame, text=label, width=22, anchor="w", font=self.base_font).pack(side="left") var = self.vars.get(label) if not isinstance(var, tk.StringVar): var = tk.StringVar(value=default) entry = tk.Entry(frame, textvariable=var, font=self.entry_font) entry.pack(side="left", fill="x", expand=True) self.vars[label] = var return entry def _browse_entry(self, parent: tk.Widget, label: str, default: str = "", is_dir: bool = True) -> None: entry = self._entry(parent, label, default) def choose() -> None: path = filedialog.askdirectory() if is_dir else filedialog.askopenfilename() if path: entry.delete(0, tk.END) entry.insert(0, path) tk.Button(entry.master, text="Browse", command=choose, font=self.button_font).pack(side="left", padx=4) def _checkbox(self, parent: tk.Widget, label: str, default: bool = False) -> None: var = self.vars.get(label) if not isinstance(var, tk.BooleanVar): var = tk.BooleanVar(value=default) tk.Checkbutton(parent, text=label, variable=var, font=self.base_font).pack(anchor="w", padx=6) self.vars[label] = var def _option_menu(self, parent: tk.Widget, label: str, options: list[str], default: str) -> None: frame = tk.Frame(parent) frame.pack(fill="x", padx=4, pady=2) tk.Label(frame, text=label, width=22, anchor="w", font=self.base_font).pack(side="left") var = self.vars.get(label) if not isinstance(var, tk.StringVar): var = tk.StringVar(value=default) menu = tk.OptionMenu(frame, var, *options) menu.configure(font=self.base_font) menu["menu"].configure(font=self.base_font) menu.pack(side="left", fill="x", expand=True) self.vars[label] = var def _on_eval_mode_change(self, *_args) -> None: mode_var = self.vars.get("Evaluation mode") num_var = self.vars.get("Number of classes") if isinstance(mode_var, tk.StringVar) and isinstance(num_var, tk.StringVar): num_var.set("2" if mode_var.get() == "binary" else "3") def _build_popup_section(self, title: str, builder, parent: Optional[tk.Widget] = None) -> None: container = parent if parent is not None else self frame = tk.Frame(container) frame.pack(fill="x", padx=6, pady=2) tk.Button(frame, text=title, command=lambda: self._open_popup(title, builder), font=self.button_font).pack(anchor="w") def _open_popup(self, title: str, builder) -> None: win = tk.Toplevel(self) win.title(title) win.transient(self) content = tk.Frame(win, padx=8, pady=8) content.pack(fill="both", expand=True) builder(content) tk.Button(content, text="Close", command=win.destroy, font=self.button_font).pack(pady=(8, 0)) def _build_crop_settings(self, parent: tk.Widget) -> None: self._browse_entry(parent, "Cropping mask manifest", "manifest.csv", is_dir=False) self._browse_entry(parent, "Cropping weights (optional)", "", is_dir=False) self._option_menu(parent, "Crop normalization", ["none", "imagenet", "per_image"], "per_image") self._entry(parent, "Crop threshold", "0.5") self._entry(parent, "Crop scale", "2.5") self._entry(parent, "Crop size", "224") self._entry(parent, "Crop cache directory", "analysis_data/hypertower_crops") self._checkbox(parent, "Use ground truth masks", True) self._checkbox(parent, "Use crop TTA", False) self._checkbox(parent, "Append geometry features", False) def _build_se_settings(self, parent: tk.Widget) -> None: self._checkbox(parent, "Enable SE", True) self._entry(parent, "SE reduction (bridge)", "16") self._checkbox(parent, "SE pre-norm (bridge)", True) self._option_menu(parent, "SE location", ["bridge", "tower", "both", "none"], "bridge") self._entry(parent, "SE reduction (tower)", "16") self._checkbox(parent, "SE pre-norm (tower)", True) def _build_warmup_settings(self, parent: tk.Widget) -> None: self._entry(parent, "Tower warmup epochs", "2") self._entry(parent, "Fused warmup epochs", "3") self._checkbox(parent, "Enable gradual thaw", False) self._entry(parent, "Thaw phase duration", "5") self._entry(parent, "Thaw ratio", "0.33") self._option_menu(parent, "Thaw target", ["image", "metadata", "both"], "image") self._entry(parent, "Thaw start epoch", "-1") self._checkbox(parent, "Initial freeze before thaw", False) def _build_early_stop_settings(self, parent: tk.Widget) -> None: self._entry(parent, "Early metric", "") self._option_menu(parent, "Early mode", ["auto", "min", "max"], "auto") self._entry(parent, "Early patience", "7") self._entry(parent, "Early min delta", "0.0") self._checkbox(parent, "Monitor holdout for early stop", False) self._checkbox(parent, "Save best checkpoint", False) # ---- Layout ----------------------------------------------------- def _build_form(self) -> None: form = tk.Frame(self) form.pack(fill="both", expand=False) self._entry(form, "Run name", "gui_run") self._browse_entry(form, "Fundus image directory", "Papila/FundusImages") self._browse_entry(form, "Clinical data directory", "Papila/ClinicalData") self._entry(form, "Diagnosis column", "Diagnosis") self._entry(form, "Categorical columns", "Gender,Phakic/Pseudophakic") self._option_menu(form, "Backbone architecture", BACKBONES, BACKBONES[0]) self._option_menu(form, "Fusion mode", FUSION_MODES, "image_only") self._option_menu(form, "Evaluation mode", EVAL_MODES, "binary") self._entry(form, "Epochs", "40") self._entry(form, "Batch size", "8") self._entry(form, "Learning rate", "5e-5") self._entry(form, "Number of classes", "2") self._entry(form, "Number of folds", "5") self._entry(form, "Fold seed", "42") self._entry(form, "Holdout per class", "8") self._entry(form, "Holdout seed", "123") eval_var = self.vars.get("Evaluation mode") if isinstance(eval_var, tk.StringVar): eval_var.trace_add("write", self._on_eval_mode_change) self._on_eval_mode_change() tk.Label(form, text="Options", font=self.bold_font).pack(anchor="w", padx=6, pady=(8, 0)) self._checkbox(form, "Disable image augmentation", False) self._checkbox(form, "Use balanced sampler", False) self._checkbox(form, "Enable early stopping", False) self._checkbox(form, "Use image cropping", True) self._checkbox(form, "Use focal loss", False) self._entry(form, "Focal gamma", "0.0") dummy = tk.Frame(self) self._build_crop_settings(dummy) self._build_se_settings(dummy) self._build_warmup_settings(dummy) self._build_early_stop_settings(dummy) dummy.destroy() self._build_popup_section("Cropping Settings", self._build_crop_settings, parent=form) self._build_popup_section("Squeeze-and-Excitation Settings", self._build_se_settings, parent=form) self._build_popup_section("Warmup / Thaw Settings", self._build_warmup_settings, parent=form) self._build_popup_section("Early Stop Settings", self._build_early_stop_settings, parent=form) device_frame = tk.Frame(form) device_frame.pack(fill="x", pady=4, padx=6) tk.Label(device_frame, text="Compute Device", font=self.bold_font).pack(anchor="w") self.device_var = tk.StringVar(value="gpu") tk.Radiobutton(device_frame, text="GPU", variable=self.device_var, value="gpu", command=self.on_device_change, font=self.base_font).pack(anchor="w") tk.Radiobutton(device_frame, text="CPU", variable=self.device_var, value="cpu", command=self.on_device_change, font=self.base_font).pack(anchor="w") btn_frame = tk.Frame(form) btn_frame.pack(fill="x", pady=8) self.run_button = tk.Button(btn_frame, text="Run", command=self.run_command, font=self.button_font) self.run_button.pack(side="left", padx=4) self.stop_button = tk.Button(btn_frame, text="Stop", command=self.stop_command, font=self.button_font, state="disabled") self.stop_button.pack(side="left", padx=4) tk.Button(btn_frame, text="Preview Command", command=self.preview_command, font=self.button_font).pack(side="left", padx=4) tk.Button(btn_frame, text="Export Settings", command=self.export_settings, font=self.button_font).pack(side="right", padx=4) tk.Button(btn_frame, text="Import Settings", command=self.import_settings, font=self.button_font).pack(side="right", padx=4) def _build_output(self) -> None: tk.Label(self, text="Command / Output", font=self.bold_font).pack(anchor="w", padx=6) self.output = tk.Text(self, height=self.text_height, font=self.mono_font) self.output.pack(fill="both", expand=True, padx=6, pady=(0, 6)) self.output.configure(state="disabled") # ---- Run management helpers ------------------------------------ def _compute_run_id_and_dir(self) -> tuple[str, Path]: shortname = "" short_var = self.vars.get("Run name") if isinstance(short_var, tk.StringVar): shortname = short_var.get().strip() timestamp = time.strftime("%Y%m%d_%H%M%S") run_id = f"{shortname}_{timestamp}" if shortname else timestamp base = Path("analysis_data") if shortname: base = base / shortname run_dir = base / run_id return run_id, run_dir def _auto_export_settings(self, run_dir: Path) -> None: data = {} for key, var in self.vars.items(): try: data[key] = var.get() except Exception: pass try: run_dir.mkdir(parents=True, exist_ok=True) with open(run_dir / "gui_settings.json", "w", encoding="utf-8") as fh: json.dump(data, fh, indent=2) self.append_output(f"[GUI] Settings saved to {run_dir / 'gui_settings.json'}\n") except Exception as exc: # pragma: no cover self.append_output(f"[GUI] Failed to save GUI settings: {exc}\n") def _compose_command(self, include_run_id: bool = False) -> tuple[list[str], Optional[str], Optional[Path]]: cli_args = self.build_cli_args() run_id = None run_dir = None if include_run_id: run_id, run_dir = self._compute_run_id_and_dir() cli_args = cli_args + ["--run-id", run_id] cmd = [sys.executable, "-u", "scripts/run_multifold.py", *cli_args] return cmd, run_id, run_dir def _on_process_finished(self, exit_code: Optional[int], error: Optional[Exception]) -> None: self.current_process = None self.current_thread = None self.current_run_dir = None self.stop_button.config(state="disabled") self.run_button.config(state="normal") if error is not None: self.append_output(f"\n[GUI] Error: {error}\n") self.set_status("Error") return if exit_code is None: self.append_output("\nProcess finished.\n") self.set_status("Finished") return self.append_output(f"\nProcess finished with exit code {exit_code}\n") self.set_status("Finished (exit 0)" if exit_code == 0 else f"Finished (exit {exit_code})") def _force_terminate_if_running(self) -> None: proc = self.current_process if proc is None or proc.poll() is not None: return self.append_output("[GUI] Process still running after interrupt; terminating...\n") try: proc.terminate() except Exception as exc: # pragma: no cover self.append_output(f"[GUI] Failed to terminate process: {exc}\n") self.after(4000, self._kill_process) def _kill_process(self) -> None: proc = self.current_process if proc is None or proc.poll() is not None: return self.append_output("[GUI] Forcing process kill.\n") try: proc.kill() except Exception as exc: # pragma: no cover self.append_output(f"[GUI] Failed to kill process: {exc}\n") def stop_command(self) -> None: proc = self.current_process if proc is None or proc.poll() is not None: self.stop_button.config(state="disabled") return self.append_output("\n[GUI] Sending interrupt signal...\n") self.set_status("Stopping...") try: if os.name == "nt": ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", signal.SIGINT) proc.send_signal(ctrl_break) else: proc.send_signal(signal.SIGINT) except Exception as exc: # pragma: no cover self.append_output(f"[GUI] Failed to send interrupt: {exc}\n") self.stop_button.config(state="disabled") self.after(4000, self._force_terminate_if_running) # ---- Output helpers --------------------------------------------- def append_output(self, text: str) -> None: self.after(0, self._append_output, text) def _append_output(self, text: str) -> None: self.output.configure(state="normal") self.output.insert(tk.END, text) self.output.see(tk.END) self.output.configure(state="disabled") def set_status(self, text: str) -> None: self.after(0, self.status_var.set, text) def preview_command(self) -> None: cmd_list, run_id, run_dir = self._compose_command(include_run_id=True) cmd = " ".join(cmd_list) self.output.configure(state="normal") self.output.delete("1.0", tk.END) self.output.insert(tk.END, cmd + "\n") if run_dir is not None: self.output.insert(tk.END, f"# output directory: {run_dir}\n") if run_id is not None: self.output.insert(tk.END, f"# run id: {run_id}\n") self.output.configure(state="disabled") # ---- Command execution ------------------------------------------ def run_command(self) -> None: if self.current_process and self.current_process.poll() is None: messagebox.showwarning("Run in progress", "A run is already in progress.") return cmd_list, run_id, run_dir = self._compose_command(include_run_id=True) self.output.configure(state="normal") self.output.delete("1.0", tk.END) self.output.insert(tk.END, "Running: " + " ".join(cmd_list) + "\n\n") if run_dir is not None: self.output.insert(tk.END, f"# output directory: {run_dir}\n\n") self.output.configure(state="disabled") self.set_status("Running") self.run_button.config(state="disabled") self.stop_button.config(state="normal") env = os.environ.copy() if self.force_cpu: env["CUDA_VISIBLE_DEVICES"] = "-1" self.current_run_dir = run_dir if run_dir is not None: self._auto_export_settings(run_dir) creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0 def worker() -> None: exit_code: Optional[int] = None error: Optional[Exception] = None try: proc = subprocess.Popen( cmd_list, cwd=self.repo_root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True, env=env, creationflags=creationflags, ) self.current_process = proc assert proc.stdout is not None for line in proc.stdout: if not line: break self.append_output(line) proc.stdout.close() exit_code = proc.wait() except Exception as exc: # pragma: no cover error = exc finally: self.after(0, lambda: self._on_process_finished(exit_code, error)) self.current_thread = threading.Thread(target=worker, daemon=True) self.current_thread.start() # ---- Command builder -------------------------------------------- def build_cli_args(self) -> list[str]: args_list: list[str] = [] def add(flag: str, var_name: str, allow_empty: bool = False) -> None: var = self.vars.get(var_name) if isinstance(var, tk.StringVar): value = var.get().strip() if value or allow_empty: args_list.extend([flag, value]) add("--shortname", "Run name") add("--image-dir", "Fundus image directory") add("--clinical-dir", "Clinical data directory") add("--label-col", "Diagnosis column") cats = self.vars["Categorical columns"].get().strip() if cats: args_list.extend(["--cat-cols", *[c.strip() for c in cats.split(",") if c.strip()]]) add("--backbone", "Backbone architecture") add("--fusion-mode", "Fusion mode") add("--epochs", "Epochs") add("--batch-size", "Batch size") add("--lr", "Learning rate") add("--num-classes", "Number of classes") add("--n-splits", "Number of folds") add("--fold-seed", "Fold seed") add("--holdout-per-class", "Holdout per class") add("--holdout-seed", "Holdout seed") eval_mode = self.vars["Evaluation mode"].get() if eval_mode: args_list.extend(["--eval_mode", eval_mode]) use_crop_var = self.vars.get("Use image cropping") if isinstance(use_crop_var, tk.BooleanVar) and use_crop_var.get(): add("--img-crop-manifest", "Cropping mask manifest") weights = self.vars["Cropping weights (optional)"].get().strip() if weights: args_list.extend(["--img-crop-weights", weights]) norm_var = self.vars.get("Crop normalization") if isinstance(norm_var, tk.StringVar): norm = norm_var.get().strip() if norm: args_list.extend(["--img-crop-normalize", norm]) add("--img-crop-threshold", "Crop threshold") add("--img-crop-cache", "Crop cache directory") add("--img-crop-scale", "Crop scale") add("--img-crop-size", "Crop size") if self.vars["Use ground truth masks"].get(): args_list.append("--img-crop-gt") if self.vars["Use crop TTA"].get(): args_list.append("--img-crop-tta") if self.vars["Append geometry features"].get(): args_list.append("--img-geometry-features") if self.vars["Disable image augmentation"].get(): args_list.append("--no-img-augment") if self.vars["Use balanced sampler"].get(): args_list.append("--balanced-sampler") if self.vars["Enable early stopping"].get(): args_list.append("--early-stop") if self.vars.get("Use focal loss") and self.vars["Use focal loss"].get(): focal_gamma = self.vars["Focal gamma"].get().strip() if focal_gamma: args_list.extend(["--focal-gamma", focal_gamma]) if not self.vars["Enable SE"].get(): args_list.append("--no-se") else: add("--se-reduction", "SE reduction (bridge)") if not self.vars["SE pre-norm (bridge)"].get(): args_list.append("--no-se-pre-norm") se_where = self.vars["SE location"].get().strip() if se_where: args_list.extend(["--se-where", se_where]) add("--se-reduction-tower", "SE reduction (tower)") if not self.vars["SE pre-norm (tower)"].get(): args_list.append("--no-se-pre-norm-tower") add("--warmup-tower-epochs", "Tower warmup epochs") add("--warmup-fused-epochs", "Fused warmup epochs") if self.vars["Enable gradual thaw"].get(): args_list.append("--gradual-thaw") add("--thaw-phase-duration", "Thaw phase duration") add("--thaw-ratio", "Thaw ratio") thaw_target = self.vars["Thaw target"].get().strip() if thaw_target: args_list.extend(["--thaw-target", thaw_target]) add("--thaw-start-epoch", "Thaw start epoch") if self.vars["Initial freeze before thaw"].get(): args_list.append("--initial-freeze") early_metric = self.vars["Early metric"].get().strip() if early_metric: args_list.extend(["--early-metric", early_metric]) early_mode = self.vars["Early mode"].get().strip() if early_mode: args_list.extend(["--early-mode", early_mode]) add("--early-patience", "Early patience") add("--early-min-delta", "Early min delta") if self.vars["Monitor holdout for early stop"].get(): args_list.append("--early-monitor-holdout") if self.vars["Save best checkpoint"].get(): args_list.append("--checkpoint-best") return args_list def build_command(self) -> list[str]: return [sys.executable, "-u", "scripts/run_multifold.py", *self.build_cli_args()] def on_device_change(self) -> None: choice = getattr(self, "device_var", None) if choice is None: return choice = self.device_var.get() if choice == "cpu": self.force_cpu = True self.append_output("CPU selected. Forcing CPU usage.\n") self.set_status("CPU selected") else: if not torch.cuda.is_available(): self.append_output("GPU selected but CUDA is not available. Falling back to CPU.\n") self.device_var.set("cpu") self.force_cpu = True self.set_status("GPU unavailable; CPU selected") return try: x = torch.rand((2048,), device="cuda") y = torch.rand((2048,), device="cuda") _ = (x * y).sum().item() self.append_output("GPU support confirmed.\n") self.force_cpu = False self.set_status("GPU selected") except Exception as exc: self.append_output(f"GPU self-test failed ({exc}). Falling back to CPU.\n") self.device_var.set("cpu") self.force_cpu = True self.set_status("GPU test failed; CPU selected") # ---- Settings import / export ---------------------------------- def export_settings(self) -> None: path = filedialog.asksaveasfilename( title="Export Settings", defaultextension=".json", filetypes=[("JSON", "*.json"), ("All files", "*.*")], ) if not path: return data = {} for key, var in self.vars.items(): try: data[key] = var.get() except Exception: pass try: with open(path, "w", encoding="utf-8") as fh: json.dump(data, fh, indent=2) self.set_status(f"Settings exported to {path}") except Exception as exc: # pragma: no cover messagebox.showerror("Export failed", str(exc)) self.set_status("Export failed") def import_settings(self) -> None: path = filedialog.askopenfilename( title="Import Settings", filetypes=[("JSON", "*.json"), ("All files", "*.*")], ) if not path: return try: with open(path, "r", encoding="utf-8") as fh: data = json.load(fh) except Exception as exc: messagebox.showerror("Import failed", str(exc)) self.set_status("Import failed") return for key, value in data.items(): var = self.vars.get(key) if var is None: continue try: if isinstance(var, tk.BooleanVar): var.set(bool(value)) else: var.set(str(value)) except Exception: continue self.set_status(f"Settings imported from {path}") self.on_device_change() def launch_frontend() -> None: app = MultifoldFrontend() app.mainloop() if __name__ == "__main__": launch_frontend()