RDS777 commited on
Commit
a2ffdfd
·
verified ·
1 Parent(s): 171eb8a

Upload 33 files

Browse files
app.py CHANGED
@@ -1065,6 +1065,12 @@ def startup() -> None:
1065
  TRAINER.note("anciens jobs planifiés : " + (", ".join(done) if done else "aucun à suspendre"))
1066
  except Exception as exc: # noqa: BLE001
1067
  TRAINER.note(f"anciens jobs planifiés illisibles : {exc}")
 
 
 
 
 
 
1068
  AUTONOMY.start()
1069
  resumed = False
1070
  try:
@@ -1084,7 +1090,11 @@ async def lifespan(_app: FastAPI):
1084
  """Le thread d'arrière-plan (mise à jour, jobs, entraînement automatique) suit la durée de vie du serveur."""
1085
  async with DISCOVERY.serving():
1086
  threading.Thread(target=startup, name="unique-startup", daemon=True).start()
1087
- yield
 
 
 
 
1088
 
1089
 
1090
  def sync_sky() -> dict:
 
1065
  TRAINER.note("anciens jobs planifiés : " + (", ".join(done) if done else "aucun à suspendre"))
1066
  except Exception as exc: # noqa: BLE001
1067
  TRAINER.note(f"anciens jobs planifiés illisibles : {exc}")
1068
+ if os.environ.get("UNIQUE_VESUVIUS_AUTORUN", "0") == "1":
1069
+ try:
1070
+ supervisor = getattr(VESUVIUS, "supervisor", None)
1071
+ if supervisor is not None:supervisor.start()
1072
+ except Exception as exc:
1073
+ TRAINER.note("Vesuvius différé : " + type(exc).__name__ + ": " + str(exc)[:200])
1074
  AUTONOMY.start()
1075
  resumed = False
1076
  try:
 
1090
  """Le thread d'arrière-plan (mise à jour, jobs, entraînement automatique) suit la durée de vie du serveur."""
1091
  async with DISCOVERY.serving():
1092
  threading.Thread(target=startup, name="unique-startup", daemon=True).start()
1093
+ try:
1094
+ yield
1095
+ finally:
1096
+ supervisor = getattr(VESUVIUS, "supervisor", None)
1097
+ if supervisor is not None:supervisor.stop()
1098
 
1099
 
1100
  def sync_sky() -> dict:
config/vesuvius-model-references.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "checked_date": "2026-11-13",
3
+ "runtime_adapter": "tiny_hybrid_3d_2d_v1_experimental_only",
4
+ "references": [
5
+ {
6
+ "id": "scrollprize/ink_9um",
7
+ "url": "https://huggingface.co/scrollprize/ink_9um",
8
+ "license_declared": "MIT",
9
+ "representation": "isotropic surface volume around 9 micrometres",
10
+ "architecture": "local 3D stem plus 2D U-Net",
11
+ "official_code": "https://github.com/ScrollPrize/villa",
12
+ "integration_status": "not_loaded_by_local_experimental_adapter",
13
+ "requirements": "Use upstream normalization, depth windows, architecture, patch size, checkpoints and training-region manifest; do not reuse local halo=3 blindly."
14
+ },
15
+ {
16
+ "id": "scrollprize/ink_canonical_2um",
17
+ "url": "https://huggingface.co/scrollprize/ink_canonical_2um",
18
+ "license_declared": "MIT",
19
+ "representation": "CT around 2 micrometres",
20
+ "architecture": "3D ResNet-152 with 3D decoder",
21
+ "checkpoint_bytes_observed": 1549556213,
22
+ "official_code": "https://github.com/ScrollPrize/villa/tree/main/ink-detection/optimized_inference",
23
+ "integration_status": "not_loaded_by_local_experimental_adapter",
24
+ "requirements": "Pin upstream code and weight hash, validate coordinate registration and safe conversion, measure A10G memory and held-out quality before activating."
25
+ }
26
+ ],
27
+ "dataset_license_note": "Underlying CT data and derived annotations may have CC BY-NC 4.0 terms. The MIT code license does not relicense them."
28
+ }
config/vesuvius_calibrations.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema": 1,
3
+ "volumes": {
4
+ "s3://vesuvius-challenge-open-data/PHercParis4/volumes/20260411134726-2.400um-0.2m-78keV-masked.zarr": {
5
+ "base_voxel_um": 2.4,
6
+ "reference": "https://github.com/ScrollPrize/villa/blob/main/scrollprize.org/docs/07_tutorial5.md",
7
+ "checked_date": "2026-11-13",
8
+ "competition_eligibility": "not_asserted"
9
+ }
10
+ }
11
+ }
docs/COMPETITION_ENGINE.md ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # UNIQUE / Vesuvius 4.15 — moteur de recherche contrôlé
2
+
3
+ ## Résultat livré et limites
4
+
5
+ Cette version renforce le moteur 4.14, dont l'application a été relue sur TARGET_SPACE avant modification. Le code original du laboratoire est distribué sous MIT (`src/LICENSE`). Les scans, annotations et poids tiers conservent leurs licences propres. Le code n'utilise aucune API payante ; l'hébergement de la carte GPU reste celui du Space.
6
+
7
+ Ce n'est pas une méthode déjà validée pour gagner le concours : aucun score d'encre réel, gain A10G, déchiffrement ou résultat papyrologique n'est revendiqué. Le petit modèle local reste expérimental. Les poids officiels ne sont pas compatibles par simple renommage ; les deux références vérifiées sont décrites dans `config/vesuvius-model-references.json`. Un adaptateur officiel doit respecter ses couches, sa normalisation, sa représentation et son champ réceptif, puis être validé avant activation. L'export reste `submission_ready=false`.
8
+
9
+ ## Architecture et patrons
10
+
11
+ | Besoin | Patron et code | Contrat |
12
+ |---|---|---|
13
+ | VRAM | acteur GPU préemptible, `CudaBudget`, `adaptive.py` | marge de 1 Gio puis 65 % de la mémoire libre restante ; mesure du pic réel |
14
+ | Inférence | tuilage avec halo et recadrage des zones valides | rayon 3 validé pour le modèle local uniquement ; aucune moyenne de bords faux |
15
+ | Manque mémoire | réduction du batch puis subdivision des tuiles | arrêt au minimum ou au circuit breaker ; aucun retry infini |
16
+ | Reprise | manifeste de tuiles + sortie memmap ; checkpoints modèle/AdamW safetensors | sorties écrites avant progression validée ; identité des entrées vérifiée |
17
+ | Streaming | ROI S3 bornée, cache exact, calibration NGFF | lecture anonyme ; aucun changement d'axes ou d'unités silencieux |
18
+ | Surface | normales physiques et volumes conditionnés, `surface_volume.py` | contextes hors CT masqués et halo de validité érodé |
19
+ | Géométrie | triangles structurés, contrôles UV, `spatial.py` | pas d'interpolation à travers les trous du masque |
20
+ | Qualité | splits spatiaux immuables, seuil sur calibration, contrôles | les scores sur le test ne pilotent pas l'optimisation |
21
+ | Boucle autonome | DAG durable mono-superviseur, backoff, quarantaine | pas de modification des annotations ni des seuils d'acceptation après échec |
22
+
23
+ ```mermaid
24
+ flowchart TD
25
+ S["ROI CT et surfaces annotées"] --> C["Calibration et provenance"]
26
+ C --> T["Entraînement checkpointé"]
27
+ C --> V["Régions tenues à part"]
28
+ T --> I["Inférence adaptative"]
29
+ V --> I
30
+ I --> Q["Contrôles scientifiques"]
31
+ Q -->|réussite| E["Export de recherche"]
32
+ Q -->|échec| R["Quarantaine documentée"]
33
+ I -->|OOM ou préemption| P["Reprise bornée"]
34
+ P --> I
35
+ ```
36
+
37
+ Le serveur garde l'interface et l'arbitrage. Les calculs restent dans des sous-processus à durée bornée. Aucun fichier `agents/`, `engine/` ou `mouh/service.py` n'est remplacé. Le bootstrap ne charge ni poids ni scan. La supervision n'est activée qu'à la demande ou avec son interrupteur explicite.
38
+
39
+ ## GPU, débit et reprise
40
+
41
+ `predict` accepte désormais CPU ou CUDA. Sur CUDA, les données des tuiles sont placées en mémoire épinglée et copiées sans blocage ; l'inférence utilise bf16 lorsque supporté et retourne des sorties float32. La taille initiale reste prudente. Un OOM pendant une inférence réduit d'abord le batch, puis découpe les rectangles. Une réservation insuffisante pour le modèle lui-même ne se corrige pas en mentant sur la taille des images : elle finit en échec documenté.
42
+
43
+ Les blocs conservent trois voxels de contexte XY pour ce petit réseau (deux convolutions 3D puis une convolution 2D). Le noyau central est recopié exactement ; les vrais bords du volume gardent le padding du modèle. Ce halo ne peut pas être réutilisé aveuglément avec un ResNet ou U-Net officiel. Les volumes de surface gardent toute leur profondeur ; on ne moyenne pas des blocs Z indépendants qui changeraient le sens du modèle.
44
+
45
+ Chaque tuile écrite est flushée avant mise à jour du manifeste. Le nom du manifeste dérive de l'empreinte source, des poids et des paramètres. Une reprise après arrêt brutal retrouve automatiquement ce manifeste. Les microcycles enregistrent modèle, moments AdamW, compteur global et seed ; la boucle reprend seulement les étapes restantes. La reprise CPU est testée bit à bit. Cela ne démontre pas une identité bit à bit entre GPU ou versions différentes.
46
+
47
+ Les mesures comprennent secondes, pixels effectivement traités, tuiles validées, retries OOM et pics CUDA. Le budget est une estimation initiale, pas un profileur magique. Le benchmark CPU joint est une observation synthétique ponctuelle, pas un gain A10G garanti. La saturation matérielle et les tailles optimales doivent être mesurées sur la carte du Space. Le découpage, les snapshots et le démarrage des workers ont aussi un coût ; une petite ROI peut être plus rapide sans subdivision.
48
+
49
+ ## Fidélité et géométrie
50
+
51
+ La calibration lit axes, unités et transformations NGFF. Un ordre autre que z,y,x, une transformation inconnue ou un pas incompatible est refusé. Certains volumes publics n'indiquent que des facteurs de pyramide, sans unités : le volume Paris 4 d'exemple est alors rapproché d'une entrée documentée du catalogue local de calibrations. Sans preuve de calibration, l'état reste `verified=false` et le verrou automatique de validation refuse d'approuver le résultat.
52
+
53
+ Ce rapprochement documentaire ne remplace pas une calibration instrumentale. Les alias de volumes doivent être consolidés avant une campagne : les verrous de recouvrement identifient actuellement les scans par URI canonique, pas par une connaissance universelle de tous leurs miroirs.
54
+
55
+ Les charts sont calculés en unités physiques, les XYZ originaux restent en double précision. Les deux triangles de chaque quad sont vérifiés : dégénérescences, orientations et bords UV croisés. Le rendu parcourt les triangles structurés valides ; il ne construit plus une enveloppe convexe qui remplirait les trous. Le rapport limite la distorsion relative maximale à 5 %. Une surface intrinsèquement non développable peut donc être refusée plutôt que déformée arbitrairement.
56
+
57
+ Les grilles de sortie respectent le rapport d'aspect et déclarent séparément les pas X et Y. L'archive conserve XYZ/UV originaux en NPZ, le rendu brut, les TIFF de coordonnées, le masque et la méthode d'interpolation. La conversion TIFXYZ float32 mesure son erreur de quantification et refuse une erreur supérieure à 0,1 µm. Cela apporte une perte bornée et traçable ; « zéro perte » ne serait pas une promesse honnête pour une interpolation et un changement de précision.
58
+
59
+ ## Volumes de surface et vérité terrain
60
+
61
+ La détection d'encre ne doit pas être évaluée comme une projection arbitraire d'un cube CT. L'action `surface` échantillonne 17 couches autour du mesh le long de ses normales, en tenant compte du pas physique. Les points hors ROI sont invalidés ; les pixels influencés par du contexte absent sont exclus de la loss et des métriques.
62
+
63
+ Les rôles `train`, `calibration`, `validation`, `test` et `negative` sont persistés. Les régions de rôles différents ne se recouvrent pas. Les empreintes des annotations et entrées sont figées lors du lancement des tâches ; un artefact modifié est refusé. Une surface estimée par simple gradient n'est pas une surface fournie/annotée et ne franchit pas le verrou de qualité.
64
+
65
+ Les métriques sont précision, rappel, Dice, IoU, taux de faux positifs, Brier, ECE et F1 de contour à un pixel. La fonction `surface_metrics` calcule aussi Chamfer symétrique, Hausdorff maximal et percentile 95 en µm contre une surface de référence fournie dans le même repère ; sans cette référence, aucune précision de segmentation 3D n’est revendiquée. Les cas sans positifs ne gagnent pas artificiellement un Dice de 1. Le seuil est choisi sur calibration seulement ; une calibration sans point satisfaisant la précision minimale s'abstient. Un rapport de validation doit porter sur les mêmes poids que la calibration et le contrôle négatif.
66
+
67
+ Le stress test utilise trois niveaux de bruit gaussien simulé sur l'intensité normalisée, avec seed fixé. Il ne prétend pas modéliser la physique du scanner. Les seuils par défaut du verrou sont précision ≥0,95, Dice ≥0,60 et faux positifs ≤0,01, avec contrôles négatifs et sous bruit réussis. Ce sont des choix de recherche configurables dans le code, pas des critères officiels du concours.
68
+
69
+ Même un verrou réussi conserve `legibility_certified=false` et `competition_eligibility_certified=false`. La lisibilité lettre par lettre et la validation des organisateurs ne se déduisent pas de Dice, surtout lorsqu'il est calculé sur des pseudo-labels.
70
+
71
+ ## Supervision continue, configuration
72
+
73
+ Après upload et redémarrage, ouvrir `/gradio/` puis **Laboratoire Vesuvius 3D → Validation et supervision**. La clé d'administration protège le démarrage et l'arrêt. Le superviseur fonctionne tant que le processus du Space est en ligne ; il ne garantit pas l'uptime de l'hébergeur et aucun essai de 24 heures n'a été fait ici.
74
+
75
+ Le plan JSON est sous `/data/chunks/vesuvius/competition-plan.json`. Il contient au plus 256 tâches avec `name`, `action` et `payload`. Une valeur `@nom` référence le champ `path` d'un résultat antérieur. Le DAG est validé, les cycles et références inconnues refusés. Les tâches sont identifiées par empreinte ; un plan déjà traité ne relance pas tout. Un seul superviseur écrit cette file ; ce n'est pas une file distribuée multi-réplicas. L'état est gardé en mémoire et écrit lors des transitions, pour éviter de relire des centaines d'objets du stockage à chaque seconde.
76
+
77
+ Pour construire le plan supervisé complet à partir de vos vrais fichiers :
78
+
79
+ ```bash
80
+ python -m src.plan --regions /data/chunks/vesuvius/regions.json --device cuda --steps 8
81
+ ```
82
+
83
+ `regions.json` doit fournir quatre entrées `train`, `calibration`, `validation`, `negative`. Chacune contient : `roi` avec `uri`, `level`, `origin`, `shape`, `voxel_um` ; `surface_xyz`, chemin absolu d'une surface .npy H×W×3 sous `/data/meshes/vesuvius/` ; `labels`, annotations binaires .npy H×W sous `/data/chunks/vesuvius/`. Les régions doivent être disjointes, les labels négatifs nuls et documentés comme absence d'encre. Le programme ne crée pas de fausse vérité terrain pour rendre l'exemple exécutable.
84
+
85
+ Ce constructeur produit 19 tâches : chargements, charts, volumes de surface, microcycle supervisé, prédictions disjointes, calibration, validation et export conditionné par la validation. Les poids du plan sont ceux du modèle local expérimental ; il faut de vraies données annotées pour les entraîner. Les sources pré-entraînées officielles ci-dessous nécessitent leur adaptateur validé propre.
86
+
87
+ Activer ensuite la file dans l'onglet. Pour la reprise automatique au redémarrage, définir la variable du Space :
88
+
89
+ ```text
90
+ UNIQUE_VESUVIUS_AUTORUN=1
91
+ ```
92
+
93
+ La valeur par défaut est 0. Arrêter la supervision avant de changer de plan. Les nouvelles tâches sont prises en compte au prochain démarrage de la supervision ; les entrées déjà réussies restent dédupliquées. Après épuisement du plan, le superviseur reste en attente sans fabriquer de nouvelles régions, scans ou entraînements.
94
+
95
+ ## Auto-correction encadrée
96
+
97
+ - Réseau : backoff de 30 secondes jusqu'à 15 minutes, cinq tentatives au plus.
98
+ - Préemption : reprise du checkpoint de tuile ou de l'optimiseur, avec étapes restantes.
99
+ - OOM d'inférence : batch puis blocs plus petits ; OOM du modèle ou d'entraînement : revue opérateur plutôt qu'une modification silencieuse du problème.
100
+ - Géométrie : un second calcul local jusqu'à 60 itérations, sans relâcher la tolérance. Un second échec est mis en quarantaine.
101
+ - Calibration/qualité : abstention et quarantaine, puis blocage des dépendances aval. Aucun export automatique d'un mauvais candidat, aucune retouche de labels ou de critères pour le faire passer.
102
+
103
+ Les rapports de jobs, checkpoint, splits et décisions restent sur `/data`. La publication des jeux de données et runs requise pour certains prix n'est pas réalisée automatiquement. Aucun message, submission, push de poids ou upload de données n'est envoyé par la boucle.
104
+
105
+ ## Conditions pour réellement viser un prix
106
+
107
+ 1. Choisir le prix et un volume réellement éligible dans les règles actuelles.
108
+ 2. Constituer des surfaces et annotations fiables, avec un découpage spatial versionné et un test final verrouillé.
109
+ 3. Intégrer et mesurer un baseline officiel adapté au pas et à la représentation ; vérifier son manifeste d'entraînement pour éviter la fuite de données.
110
+ 4. Mesurer Dice, précision, faux positifs, stabilité, coût par surface et erreur géométrique sur de vraies données tenues à part. Comparer les variantes avec le même protocole.
111
+ 5. Valider les colonnes complètes dans VC3D, puis obtenir une lecture indépendante. Un patch en 2048 pixels n'est pas une colonne entière ni une nouvelle résolution CT.
112
+
113
+ Cette version réalise l'infrastructure de contrôle et de mesure. Elle ne fournit pas les annotations scientifiques manquantes, ne charge pas par erreur un gros checkpoint officiel dans le petit réseau et ne garantit pas une victoire.
114
+
115
+ ## Sources vérifiées le 13 novembre 2026
116
+
117
+ - https://scrollprize.org/prizes
118
+ - https://scrollprize.org/from_ash_to_text
119
+ - https://huggingface.co/scrollprize/ink_9um
120
+ - https://huggingface.co/scrollprize/ink_canonical_2um
121
+ - https://github.com/ScrollPrize/villa/tree/main/ink-detection/optimized_inference
122
+ - https://huggingface.co/buckets/scrollprize/datasets/tree/ink_9um
123
+
124
+ Le code MIT des modèles ne relicense pas les scans et annotations, dont les termes CC BY-NC 4.0 doivent être respectés. La fenêtre 64 reste une borne de ce moteur local, pas une règle universelle du concours.
docs/VALIDATION_COMPETITION_4.15.0.json ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "release": "4.15.0",
3
+ "date": "2026-11-13",
4
+ "tests": {
5
+ "laboratory_and_competition": {
6
+ "tests": 52,
7
+ "errors": 0,
8
+ "failures": 0,
9
+ "skipped": 0
10
+ },
11
+ "unique_regression": {
12
+ "tests": 109,
13
+ "errors": 0,
14
+ "failures": 0,
15
+ "skipped": 0
16
+ }
17
+ },
18
+ "regression_accounting": "109 UNIQUE + 22 original Lab + 30 new competition cases = 161",
19
+ "live_s3": {
20
+ "ok": true,
21
+ "geometry": {
22
+ "verified": true,
23
+ "source": "curated_catalogue_plus_NGFF",
24
+ "scale_zyx_um": [
25
+ 2.4,
26
+ 2.4,
27
+ 2.4
28
+ ],
29
+ "translation_zyx_um": [
30
+ 0.0,
31
+ 0.0,
32
+ 0.0
33
+ ],
34
+ "axes": "zyx",
35
+ "calibration_reference": "https://github.com/ScrollPrize/villa/blob/main/scrollprize.org/docs/07_tutorial5.md"
36
+ },
37
+ "shape": [
38
+ 8,
39
+ 8,
40
+ 8
41
+ ],
42
+ "minimum": 33.0,
43
+ "maximum": 134.0,
44
+ "seconds": 7.000806077000107
45
+ },
46
+ "cpu_microbenchmark": {
47
+ "data": "synthetic_not_CT_validation",
48
+ "device": "cpu",
49
+ "shape": [
50
+ 17,
51
+ 64,
52
+ 64
53
+ ],
54
+ "full_forward_seconds": 0.12432532000002539,
55
+ "tiled_forward_and_checkpoint_seconds": 0.044506014000035066,
56
+ "max_absolute_difference": 2.9802322387695312e-08,
57
+ "gpu_benchmark_performed": false,
58
+ "speedup_claimed": false
59
+ },
60
+ "proved_in_tests": [
61
+ "Halo tiled inference matches full CPU inference within 1e-6",
62
+ "AdamW/model checkpoint restart reproduces uninterrupted CPU weights exactly",
63
+ "OOM subdivision and hard tile restart tested without GPU using controlled failures",
64
+ "Surface-conditioned sampling, metric geometry and hole masks tested",
65
+ "Persistent DAG abstains and quarantines rather than exporting failed scientific results",
66
+ "Gradio tab and isolated ASGI configuration remain operational"
67
+ ],
68
+ "not_proved": [
69
+ "A10G performance, CUDA OOM behavior and bf16 numerical quality",
70
+ "24-hour uptime or real bucket mount persistence after reboot",
71
+ "Scientific accuracy on real annotated ink/segmentation data",
72
+ "Official pretrained-model adapter integration, VC3D full roundtrip, full-column assembly or prize eligibility"
73
+ ],
74
+ "model": "tiny_hybrid_3d_2d_v1 remains experimental; official models referenced, not falsely loaded",
75
+ "code_license": "MIT for original laboratory add-on only",
76
+ "dependencies": {
77
+ "torch": "2.6.0+cpu",
78
+ "numpy": "2.5.4",
79
+ "scipy": "1.18.1",
80
+ "zarr": "2.18.7",
81
+ "s3fs": "2025.12.0",
82
+ "tifffile": "2026.11.8",
83
+ "gradio": "5.50.0",
84
+ "safetensors": "0.8.0"
85
+ }
86
+ }
mouh/learning/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "version": "v4.14.0",
3
  "files": {
4
  "Dockerfile": "f68259170ec47222",
5
  "README.md": "ed433a59c0e4fb6d",
@@ -8,7 +8,7 @@
8
  "agents/contract.py": "925f5ac350a90ea0",
9
  "agents/core.py": "24e49461b79764ba",
10
  "agents/mission.py": "9b760c2378774b7b",
11
- "app.py": "bad902c73a24d67d",
12
  "config.json": "e75d8b151c4de3d1",
13
  "engine/__init__.py": "9ed43b8c6346ad0e",
14
  "engine/exam.py": "b9d7eeac20ff9a12",
@@ -86,7 +86,7 @@
86
  "requirements-dev.txt": "9a03d0d2ca94a0d2",
87
  "requirements.txt": "f6e0d34c6f292b9a",
88
  "unique_ext/README.md": "0f0be41551e6bacb",
89
- "unique_ext/__init__.py": "179aa19888358397",
90
  "unique_ext/backends/__init__.py": "e3b0c44298fc1c14",
91
  "unique_ext/backends/hf.py": "506d29e81c127e1e",
92
  "unique_ext/contracts.py": "4ce5632e921dfcdc",
@@ -140,16 +140,28 @@
140
  "agents/discovery_capabilities.py": "41a76670ed0553c1",
141
  "config/spaces_curated.json": "9a9f54dceab9cfbf",
142
  "src/__init__.py": "ef77ad4265bda616",
143
- "src/controller.py": "b4c9586d5de615d3",
144
- "src/exporter.py": "a6fdfcce4e1135f5",
145
- "src/loader.py": "1d7ecba8594d015a",
146
  "src/scanner.py": "3746e1d337810a54",
147
  "src/storage.py": "e5feb00026c0dbc5",
148
- "src/trainer.py": "3c0abcbc27d70514",
149
- "src/ui.py": "2784fe94110157a8",
150
- "src/unwrapper.py": "6f556fa3fb15fd9c",
151
- "src/worker.py": "7104681fcf9e81cf",
152
- "requirements-vesuvius.txt": "61384af45af4799c"
 
 
 
 
 
 
 
 
 
 
 
 
153
  },
154
  "removed": [
155
  "mouh/learning/autopilot.py",
 
1
  {
2
+ "version": "v4.15.0",
3
  "files": {
4
  "Dockerfile": "f68259170ec47222",
5
  "README.md": "ed433a59c0e4fb6d",
 
8
  "agents/contract.py": "925f5ac350a90ea0",
9
  "agents/core.py": "24e49461b79764ba",
10
  "agents/mission.py": "9b760c2378774b7b",
11
+ "app.py": "311b8e107fb63bd2",
12
  "config.json": "e75d8b151c4de3d1",
13
  "engine/__init__.py": "9ed43b8c6346ad0e",
14
  "engine/exam.py": "b9d7eeac20ff9a12",
 
86
  "requirements-dev.txt": "9a03d0d2ca94a0d2",
87
  "requirements.txt": "f6e0d34c6f292b9a",
88
  "unique_ext/README.md": "0f0be41551e6bacb",
89
+ "unique_ext/__init__.py": "6f3eef0b6cca349d",
90
  "unique_ext/backends/__init__.py": "e3b0c44298fc1c14",
91
  "unique_ext/backends/hf.py": "506d29e81c127e1e",
92
  "unique_ext/contracts.py": "4ce5632e921dfcdc",
 
140
  "agents/discovery_capabilities.py": "41a76670ed0553c1",
141
  "config/spaces_curated.json": "9a9f54dceab9cfbf",
142
  "src/__init__.py": "ef77ad4265bda616",
143
+ "src/controller.py": "e375e2f5fd343405",
144
+ "src/exporter.py": "88a070c32d8306c5",
145
+ "src/loader.py": "149035fcfd4195c8",
146
  "src/scanner.py": "3746e1d337810a54",
147
  "src/storage.py": "e5feb00026c0dbc5",
148
+ "src/trainer.py": "aee2727502717613",
149
+ "src/ui.py": "2c606d7d21b14bd5",
150
+ "src/unwrapper.py": "3cedb5393c2f92b0",
151
+ "src/worker.py": "312d592395ffaed3",
152
+ "requirements-vesuvius.txt": "61384af45af4799c",
153
+ "src/adaptive.py": "f36d10a60d2a9994",
154
+ "src/autonomous.py": "040868e24c0823f0",
155
+ "src/checkpoints.py": "dcbe06825be82e85",
156
+ "src/datasets.py": "9dd36ecbbf94b43f",
157
+ "src/evaluation.py": "72abb128f2cfcc74",
158
+ "src/plan.py": "649b2b1f7badb85f",
159
+ "src/quality.py": "908378d962326762",
160
+ "src/spatial.py": "3d16f0c0c217bbec",
161
+ "src/surface_volume.py": "609a74ee150ba37e",
162
+ "src/LICENSE": "7d74d6738b4cf764",
163
+ "config/vesuvius_calibrations.json": "79a3a00fdc7a99f0",
164
+ "config/vesuvius-model-references.json": "74edaa225ac99387"
165
  },
166
  "removed": [
167
  "mouh/learning/autopilot.py",
src/LICENSE ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 UNIQUE contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ This license covers this original laboratory add-on, not third-party code,
24
+ model weights, annotations, or CT datasets. Those retain their own licenses.
src/adaptive.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tuilage exact XY avec halo, réduction sur OOM et reprise durable par tuile."""
2
+ from __future__ import annotations
3
+ from contextlib import nullcontext
4
+ import gc
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import time
9
+
10
+
11
+ class Preempted(RuntimeError):
12
+ def __init__(self, resume):
13
+ super().__init__('calcul préempté après checkpoint')
14
+ self.resume=resume
15
+
16
+
17
+ class CudaBudget:
18
+ def __init__(self, device='cpu', reserve_bytes=1024**3, fraction=.65):
19
+ self.device=device;self.reserve=reserve_bytes;self.fraction=fraction
20
+ self.started=None;self.report={}
21
+
22
+ def __enter__(self):
23
+ import torch
24
+ if self.device=='cuda':
25
+ if not torch.cuda.is_available():raise RuntimeError('CUDA indisponible')
26
+ free,total=torch.cuda.mem_get_info()
27
+ self.report={'free_before':free,'total':total,'budget_bytes':max(0,int((free-self.reserve)*self.fraction))}
28
+ if self.report['budget_bytes']<64*1024**2:raise RuntimeError('VRAM réservée insuffisante')
29
+ torch.cuda.reset_peak_memory_stats()
30
+ self.started=time.monotonic();return self
31
+
32
+ def batch_size(self, depth, side):
33
+ # Majorant initial prudent, remplacé par les observations de pic dans le rapport.
34
+ per_tile=max(1,depth*side*side*4*64)
35
+ return 1 if self.device=='cpu' else max(1,min(16,self.report['budget_bytes']//per_tile))
36
+
37
+ def __exit__(self,*exc):
38
+ import torch
39
+ if self.device=='cuda':
40
+ self.report.update(peak_allocated=torch.cuda.max_memory_allocated(),peak_reserved=torch.cuda.max_memory_reserved())
41
+ self.report['seconds']=time.monotonic()-self.started
42
+
43
+
44
+ def tiled_predict(volume, infer, storage, identity, *, side=32, halo=3, batch=1,
45
+ resume=None, cancelled=lambda:False, oom_type=MemoryError):
46
+ """infer reçoit B×D×H×W ; il rend B×H×W. Pas de moyenne de bords inexacts."""
47
+ import numpy as np
48
+ if volume.ndim!=3 or not 8<=int(side)<=64 or halo!=3 or batch<1:
49
+ raise ValueError('tuilage XY de 8 à 64, halo 3 requis par le modèle local')
50
+ h,w=volume.shape[1:]
51
+ side=int(side)
52
+ if side>58 and max(h,w)>64:side=58
53
+ key=hashlib.sha256(json.dumps(identity,sort_keys=True).encode()).hexdigest()[:24]
54
+ name=key+'.tiles.json';out_path=storage.path('chunks',key+'.tiles.npy')
55
+ state_path=storage.path('chunks',name)
56
+ state=None
57
+ if resume or state_path.exists():
58
+ if resume and storage.checked(resume,'chunks')!=state_path:raise ValueError('reprise d’une autre expérience')
59
+ state=json.loads(state_path.read_text())
60
+ if state['identity']!=identity:raise ValueError('identité du checkpoint incompatible')
61
+ if state:
62
+ out=np.lib.format.open_memmap(out_path,mode='r+')
63
+ if out.shape!=(h,w) or out.dtype!=np.float32:raise ValueError('sortie de reprise incompatible')
64
+ else:
65
+ out=np.lib.format.open_memmap(out_path,mode='w+',dtype='float32',shape=(h,w));out[:]=np.nan;out.flush()
66
+ state={'identity':identity,'pending':[[y,min(y+side,h),x,min(x+side,w)]
67
+ for y in range(0,h,side) for x in range(0,w,side)],'committed':0,'oom_retries':0,
68
+ 'output':str(out_path),'batch':int(batch)}
69
+ storage.json('chunks',name,state)
70
+ before=time.monotonic()
71
+ remaining_pixels=sum((r[1]-r[0])*(r[3]-r[2]) for r in state['pending'])
72
+ while state['pending']:
73
+ if cancelled():raise Preempted(str(state_path))
74
+ rect=state['pending'][0]
75
+ y0,y1,x0,x1=rect
76
+ a,b,c,d=max(0,y0-halo),min(h,y1+halo),max(0,x0-halo),min(w,x1+halo)
77
+ # Regroupe seulement les formes identiques : aucun padding artificiel aux bords du volume.
78
+ selected=[];patches=[];bounds=[]
79
+ for r in state['pending'][:state['batch']]:
80
+ u,v,s,t=r;aa,bb,cc,dd=max(0,u-halo),min(h,v+halo),max(0,s-halo),min(w,t+halo)
81
+ if (bb-aa,dd-cc)!=(b-a,d-c):break
82
+ selected.append(r);patches.append(np.asarray(volume[:,aa:bb,cc:dd],dtype='float32'));bounds.append((aa,cc))
83
+ try:
84
+ predictions=np.asarray(infer(np.stack(patches)))
85
+ if predictions.shape!=(len(selected),b-a,d-c) or not np.isfinite(predictions).all():
86
+ raise ValueError('sortie de modèle incohérente ou non finie')
87
+ except oom_type:
88
+ state['oom_retries']+=1
89
+ if state['oom_retries']>64:raise RuntimeError('circuit breaker OOM')
90
+ gc.collect()
91
+ try:
92
+ import torch
93
+ if torch.cuda.is_available():torch.cuda.empty_cache()
94
+ except ImportError:pass
95
+ if state['batch']>1:state['batch']=max(1,state['batch']//2)
96
+ elif max(y1-y0,x1-x0)>8:
97
+ if y1-y0>=x1-x0:
98
+ m=(y0+y1)//2;children=[[y0,m,x0,x1],[m,y1,x0,x1]]
99
+ else:
100
+ m=(x0+x1)//2;children=[[y0,y1,x0,m],[y0,y1,m,x1]]
101
+ state['pending'][0:1]=children
102
+ else:raise RuntimeError('OOM même à la taille minimale') from None
103
+ storage.json('chunks',name,state);continue
104
+ for r,p,(aa,cc) in zip(selected,predictions,bounds):
105
+ u,v,s,t=r;out[u:v,s:t]=p[u-aa:v-aa,s-cc:t-cc]
106
+ out.flush() # La progression n'est validée qu'après écriture de la sortie.
107
+ state['pending']=state['pending'][len(selected):];state['committed']+=len(selected)
108
+ storage.json('chunks',name,state)
109
+ state['seconds_this_run']=time.monotonic()-before
110
+ state['pixels_per_second']=remaining_pixels/max(state['seconds_this_run'],1e-9)
111
+ state['pixels_this_run']=remaining_pixels
112
+ storage.json('chunks',name,state)
113
+ return np.asarray(out),{**state,'resume':str(state_path)}
src/autonomous.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File persistante mono-superviseur ; reprises bornées, aucune réécriture de vérité terrain."""
2
+ import hashlib
3
+ from copy import deepcopy
4
+ import json
5
+ from pathlib import Path
6
+ import threading
7
+ import time
8
+
9
+ from .controller import Busy
10
+
11
+
12
+ class Queue:
13
+ def __init__(self,storage):
14
+ self.storage=storage;self._cache=None;self._lock=threading.RLock()
15
+
16
+ def enqueue(self,tasks):
17
+ if not isinstance(tasks,list) or not 1<=len(tasks)<=256:raise ValueError('plan de 1 à 256 tâches requis')
18
+ names={}
19
+ for task in tasks:
20
+ if not isinstance(task,dict) or task.get('action') not in ('load','scan','unwrap','surface','train','predict','evaluate','export'):
21
+ raise ValueError('action de plan invalide')
22
+ if not isinstance(task.get('payload',{}),dict):raise ValueError('payload requis')
23
+ if task.get('payload',{}).get('role')=='test':raise ValueError('pas d’optimisation automatique sur le jeu test')
24
+ name=task.get('name')
25
+ if name:
26
+ if not isinstance(name,str) or name in names:raise ValueError('nom de tâche invalide ou dupliqué')
27
+ names[name]=task
28
+ canonical={};visiting=set()
29
+ def normalize(task):
30
+ token=id(task)
31
+ if token in canonical:return canonical[token]
32
+ if token in visiting:raise ValueError('cycle dans le plan')
33
+ visiting.add(token)
34
+ def replace(v):
35
+ if isinstance(v,str) and v.startswith('@'):
36
+ if v[1:] in names:
37
+ key,_=normalize(names[v[1:]]);return '@'+key
38
+ if not self.storage.path('chunks','queue_'+v[1:]+'.json').exists():
39
+ raise ValueError('référence de plan inconnue : '+v)
40
+ return v
41
+ if isinstance(v,dict):return {k:replace(x) for k,x in v.items()}
42
+ if isinstance(v,list):return [replace(x) for x in v]
43
+ return v
44
+ transformed={**task,'payload':replace(task.get('payload',{}))}
45
+ key=hashlib.sha256(json.dumps(transformed,sort_keys=True,allow_nan=False).encode()).hexdigest()[:24]
46
+ visiting.remove(token);canonical[token]=(key,transformed);return key,transformed
47
+ normalized=[normalize(task) for task in tasks];ids=[]
48
+ for key,task in normalized:
49
+ path=self.storage.path('chunks','queue_'+key+'.json')
50
+ if not path.exists():self.save({'id':key,'task':task,'state':'queued','attempts':0,'due':0.,'events':[]})
51
+ ids.append(key)
52
+ return ids
53
+
54
+ def records(self):
55
+ with self._lock:
56
+ if self._cache is None:
57
+ self._cache={r['id']:r for r in [json.loads(p.read_text()) for p in sorted(self.storage.folder('chunks').glob('queue_*.json'))]}
58
+ return deepcopy(list(self._cache.values()))
59
+
60
+ def save(self,record):
61
+ with self._lock:
62
+ if self._cache is None:self.records()
63
+ self.storage.json('chunks','queue_'+record['id']+'.json',record)
64
+ self._cache[record['id']]=deepcopy(record)
65
+
66
+ def recover(self):
67
+ for record in self.records():
68
+ if record['state']=='running':
69
+ record['state']='queued';record['due']=time.time()+30
70
+ record['events'].append({'at':time.time(),'decision':'recovered_after_restart'})
71
+ self.save(record)
72
+
73
+ def file_hash(self,path):
74
+ for kind in self.storage.KINDS:
75
+ try:
76
+ candidate=self.storage.checked(path,kind)
77
+ if candidate.is_file():return self.storage.digest(candidate,kind)
78
+ except ValueError:pass
79
+ raise ValueError('artefact hors registre ou absent')
80
+
81
+ def freeze_inputs(self,record,payload):
82
+ hashes={}
83
+ def visit(value):
84
+ if isinstance(value,str) and value.startswith('/'):
85
+ hashes[value]=self.file_hash(value)
86
+ elif isinstance(value,dict):
87
+ for v in value.values():visit(v)
88
+ elif isinstance(value,list):
89
+ for v in value:visit(v)
90
+ visit(payload)
91
+ for path,digest in record.get('input_sha256',{}).items():
92
+ if self.file_hash(path)!=digest:raise ValueError('entrée modifiée après lancement')
93
+ record.setdefault('input_sha256',{}).update(hashes)
94
+ self.save(record)
95
+
96
+ def resolve(self,task,records):
97
+ # Référence exacte @id : champ path du résultat d'une tâche déjà réussie.
98
+ def value(v):
99
+ if isinstance(v,str) and v.startswith('@'):
100
+ dependency=records.get(v[1:])
101
+ if dependency and dependency['state']=='quarantined':raise ValueError('dépendance en quarantaine')
102
+ if not dependency or dependency['state']!='done':raise LookupError('dépendance non prête')
103
+ path=dependency['result']['path']
104
+ if dependency.get('output_sha256') and self.file_hash(path)!=dependency['output_sha256']:
105
+ raise ValueError('artefact de dépendance modifié')
106
+ return path
107
+ if isinstance(v,dict):return {k:value(x) for k,x in v.items()}
108
+ if isinstance(v,list):return [value(x) for x in v]
109
+ return v
110
+ return value(task['payload'])
111
+
112
+ def finish(self,record,state,now=None):
113
+ now=time.time() if now is None else now
114
+ code=state.get('code','unknown')
115
+ if state['status']=='done':
116
+ record.update(state='done',result=state['result'])
117
+ artifact=state['result'].get('path')
118
+ if artifact and Path(artifact).is_file():
119
+ record['output_sha256']=self.file_hash(artifact)
120
+ if (state['result'].get('report',{}).get('acceptance',{}).get('automatic_gate_passed') is False
121
+ or state['result'].get('report',{}).get('status')=='abstain'):
122
+ record['state']='quarantined';code='quality_gate_failed'
123
+ else:
124
+ resume=state.get('resume')
125
+ if code=='preempted' and resume:
126
+ action=record['task']['action']
127
+ if action=='train' and isinstance(resume,dict):record['task']['payload'].update(resume)
128
+ if action=='predict' and isinstance(resume,str):record['task']['payload']['resume']=resume
129
+ if code=='oom':
130
+ payload=record['task']['payload']
131
+ if record['task']['action']=='predict':payload['tile']=max(8,int(payload.get('tile',32))//2)
132
+ else:code='training_requires_operator_memory_review'
133
+ if code=='geometry' and record['task']['action']=='unwrap' and record['attempts']<2:
134
+ record['task']['payload']['iterations']=60
135
+ code='geometry_refinement'
136
+ retry=code in ('network','timeout','preempted','oom','geometry_refinement') and record['attempts']<5
137
+ record.update(state='queued' if retry else 'quarantined',due=now+min(900,30*2**max(0,record['attempts']-1)))
138
+ record['events']=(record.get('events',[])+[{'at':now,'decision':record['state'],'code':code,
139
+ 'error':state.get('error','')[:500]}])[-32:]
140
+ self.save(record)
141
+
142
+
143
+ class Supervisor:
144
+ def __init__(self,controller):
145
+ self.controller=controller;self.queue=Queue(controller.storage)
146
+ self.stop_event=threading.Event();self.thread=None;self.error=''
147
+
148
+ def start(self,plan_path=None):
149
+ if self.thread and self.thread.is_alive():return 'déjà actif'
150
+ self.controller.storage.initialize()
151
+ path=self.controller.storage.checked(plan_path or self.controller.storage.path('chunks','competition-plan.json'),'chunks')
152
+ if path.stat().st_size>1024**2:raise ValueError('plan trop volumineux')
153
+ plan=json.loads(path.read_text());self.queue.enqueue(plan['tasks']);self.queue.recover()
154
+ self.stop_event.clear();self.thread=threading.Thread(target=self._loop,daemon=True,name='vesuvius-supervisor');self.thread.start()
155
+ return 'supervision active ; plan borné, reprises et quarantaines persistées'
156
+
157
+ def stop(self):self.stop_event.set();return self.controller.stop()
158
+
159
+ def status(self):
160
+ records=self.queue.records()
161
+ return {'running':bool(self.thread and self.thread.is_alive()),'error':self.error,
162
+ 'counts':{s:sum(r['state']==s for r in records) for s in ('queued','running','done','quarantined')}}
163
+
164
+ def _loop(self):
165
+ while not self.stop_event.wait(1):
166
+ try:
167
+ if self.controller.status()['status']=='running':continue
168
+ records={r['id']:r for r in self.queue.records()}
169
+ for record in records.values():
170
+ if record['state']!='queued' or record['due']>time.time():continue
171
+ try:payload=self.queue.resolve(record['task'],records)
172
+ except LookupError:continue
173
+ except (ValueError,OSError,KeyError) as exc:
174
+ record.update(state='quarantined',error=str(exc));self.queue.save(record);continue
175
+ try:self.queue.freeze_inputs(record,payload)
176
+ except (ValueError,OSError) as exc:
177
+ record.update(state='quarantined',error=str(exc));self.queue.save(record);continue
178
+ payload['_queue_id']=record['id']
179
+ try:self.controller.start(record['task']['action'],payload)
180
+ except Busy:continue # GPU prioritaire occupé : attente, pas un échec de science.
181
+ record.update(state='running',attempts=record['attempts']+1);self.queue.save(record)
182
+ while self.controller.status()['status']=='running':
183
+ if self.stop_event.wait(.2):self.controller.stop()
184
+ self.queue.finish(record,self.controller.status());break
185
+ except Exception as exc:
186
+ self.error=type(exc).__name__+': '+str(exc)[:500]
187
+ self.stop_event.wait(30)
src/checkpoints.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Snapshots sûrs du modèle et de l'optimiseur, sans pickle exécutable."""
2
+ import json
3
+ import uuid
4
+
5
+
6
+ def save(storage, model, optimizer, report):
7
+ from safetensors.torch import save_file
8
+ name=uuid.uuid4().hex;path=storage.path('weights',name+'.safetensors')
9
+ save_file({k:v.detach().cpu().contiguous() for k,v in model.state_dict().items()},str(path))
10
+ state=optimizer.state_dict();flat={}
11
+ for index,entry in state['state'].items():
12
+ for key,value in entry.items():
13
+ if hasattr(value,'detach'):flat[f'{index}.{key}']=value.detach().cpu().contiguous()
14
+ else:raise ValueError('état optimiseur non tensoriel non pris en charge')
15
+ opt=storage.path('weights',name+'.optimizer.safetensors')
16
+ save_file(flat,str(opt))
17
+ result={**report,'optimizer_param_groups':state['param_groups'],
18
+ 'weights_sha256':storage.digest(path,'weights'),'optimizer_sha256':storage.digest(opt,'weights')}
19
+ storage.json('weights',name+'.json',result)
20
+ return str(path),result
21
+
22
+
23
+ def load(storage,path,model,optimizer=None,device='cpu'):
24
+ from safetensors.torch import load_file
25
+ path=storage.checked(path,'weights')
26
+ if path.stat().st_size>16*1024**2:raise ValueError('checkpoint trop volumineux')
27
+ metadata=json.loads(path.with_suffix('.json').read_text())
28
+ if metadata.get('weights_sha256') and storage.digest(path,'weights')!=metadata['weights_sha256']:
29
+ raise ValueError('poids modifiés après checkpoint')
30
+ model.load_state_dict(load_file(str(path),device=device))
31
+ if optimizer is not None and metadata.get('optimizer_param_groups') is not None:
32
+ opt=path.with_name(path.stem+'.optimizer.safetensors')
33
+ if storage.digest(opt,'weights')!=metadata['optimizer_sha256']:raise ValueError('optimiseur modifié')
34
+ state={}
35
+ for key,value in load_file(str(opt),device=device).items():
36
+ index,field=key.split('.',1);state.setdefault(int(index),{})[field]=value
37
+ optimizer.load_state_dict({'state':state,'param_groups':metadata['optimizer_param_groups']})
38
+ return metadata
src/controller.py CHANGED
@@ -13,6 +13,10 @@ import uuid
13
  from .storage import Storage
14
 
15
 
 
 
 
 
16
  class Controller:
17
  def __init__(self, storage=None, busy=lambda:None, preempt=lambda:False,
18
  gpu_context=nullcontext, concurrent_uses=lambda:0):
@@ -27,13 +31,13 @@ class Controller:
27
  with self.lock:return dict(self.state)
28
 
29
  def start(self, action, payload):
30
- if action not in ('load','scan','unwrap','train','predict','export'):
31
  raise ValueError('action inconnue')
32
  with self.lock:
33
- if self.state['status']=='running':raise RuntimeError('un job du laboratoire est déjà actif')
34
  self.storage.initialize()
35
- gpu=action=='train' and payload.get('device')=='cuda'
36
- if gpu and self.busy():raise RuntimeError('GPU prioritaire occupé par UNIQUE : '+str(self.busy()))
37
  job=uuid.uuid4().hex
38
  request=self.storage.json('chunks',job+'.request.json',{'action':action,'payload':payload})
39
  self.gpu_active=gpu;self._cancel=False
@@ -64,21 +68,27 @@ class Controller:
64
  deadline=time.monotonic()+(45 if gpu else 120)
65
  while self.proc.poll() is None:
66
  if self._cancel or time.monotonic()>deadline or (gpu and self.preempt()):
 
67
  self.proc.terminate()
68
  try:self.proc.wait(timeout=3)
69
  except subprocess.TimeoutExpired:self.proc.kill();self.proc.wait()
70
- raise RuntimeError('job annulé, préempté par UNIQUE ou budget épuisé')
 
 
 
 
71
  time.sleep(0.2)
72
  output=request.with_name(request.stem+'.result.json')
73
  if output.is_file() and output.stat().st_size<1024**2:
74
  result=json.loads(output.read_text())
75
- else:result={'ok':False,'error':'worker indisponible ; voir '+str(log)}
76
  except Exception as exc:result={'ok':False,'error':type(exc).__name__+': '+str(exc)[:1000]}
77
  finally:
78
  with self.lock:
79
  self.gpu_active=False;self.proc=None
80
  self.state.update(status='done' if result.get('ok') else 'error',gpu=False,
81
- result=result.get('result'),error=result.get('error',''))
 
82
 
83
  def stop(self):
84
  self._cancel=True
 
13
  from .storage import Storage
14
 
15
 
16
+ class Busy(RuntimeError):
17
+ pass
18
+
19
+
20
  class Controller:
21
  def __init__(self, storage=None, busy=lambda:None, preempt=lambda:False,
22
  gpu_context=nullcontext, concurrent_uses=lambda:0):
 
31
  with self.lock:return dict(self.state)
32
 
33
  def start(self, action, payload):
34
+ if action not in ('load','scan','unwrap','surface','train','predict','evaluate','export'):
35
  raise ValueError('action inconnue')
36
  with self.lock:
37
+ if self.state['status']=='running':raise Busy('un job du laboratoire est déjà actif')
38
  self.storage.initialize()
39
+ gpu=action in ('train','predict') and payload.get('device')=='cuda'
40
+ if gpu and self.busy():raise Busy('GPU prioritaire occupé par UNIQUE : '+str(self.busy()))
41
  job=uuid.uuid4().hex
42
  request=self.storage.json('chunks',job+'.request.json',{'action':action,'payload':payload})
43
  self.gpu_active=gpu;self._cancel=False
 
68
  deadline=time.monotonic()+(45 if gpu else 120)
69
  while self.proc.poll() is None:
70
  if self._cancel or time.monotonic()>deadline or (gpu and self.preempt()):
71
+ request.with_suffix('.cancel').write_text('cancel')
72
  self.proc.terminate()
73
  try:self.proc.wait(timeout=3)
74
  except subprocess.TimeoutExpired:self.proc.kill();self.proc.wait()
75
+ output=request.with_name(request.stem+'.result.json')
76
+ if output.is_file():result=json.loads(output.read_text())
77
+ else:result={'ok':False,'error':'job annulé, préempté ou budget épuisé',
78
+ 'code':'timeout' if time.monotonic()>deadline else 'preempted'}
79
+ break
80
  time.sleep(0.2)
81
  output=request.with_name(request.stem+'.result.json')
82
  if output.is_file() and output.stat().st_size<1024**2:
83
  result=json.loads(output.read_text())
84
+ elif result.get('code') is None:result={'ok':False,'error':'worker indisponible ; voir '+str(log),'code':'unknown'}
85
  except Exception as exc:result={'ok':False,'error':type(exc).__name__+': '+str(exc)[:1000]}
86
  finally:
87
  with self.lock:
88
  self.gpu_active=False;self.proc=None
89
  self.state.update(status='done' if result.get('ok') else 'error',gpu=False,
90
+ result=result.get('result'),error=result.get('error',''),
91
+ code=result.get('code'),resume=result.get('resume'))
92
 
93
  def stop(self):
94
  self._cancel=True
src/datasets.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Registre immuable des rôles spatiaux ; validation et calibration séparées."""
2
+ import json
3
+
4
+
5
+ def register(storage,region,role):
6
+ from .trainer import overlaps
7
+ if role not in ('train','calibration','validation','test','negative'):raise ValueError('rôle de région invalide')
8
+ path=storage.path('chunks','dataset-splits.json')
9
+ records=json.loads(path.read_text()) if path.exists() else []
10
+ for item in records:
11
+ if item['role']!=role and overlaps(region,item['region']):
12
+ raise ValueError('recouvrement de régions entre '+role+' et '+item['role'])
13
+ record={'region':region,'role':role}
14
+ if record not in records:
15
+ records.append(record);storage.json('chunks',path.name,records)
16
+ return record
src/evaluation.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Évaluation des vraies annotations ; controles négatifs et perturbations synthétiques."""
2
+ import json
3
+ import uuid
4
+
5
+
6
+ def evaluate(storage,prediction,labels,chunk,negative_prediction=None,negative_labels=None,role='validation',threshold=.5,calibration_report=None):
7
+ import numpy as np
8
+ import torch
9
+ from .loader import read_chunk
10
+ from .trainer import build_model,overlaps
11
+ from .checkpoints import load
12
+ from .quality import ink_metrics,robustness,acceptance,choose_threshold
13
+ from .datasets import register
14
+ def read_prediction(path):
15
+ path=storage.checked(path,'chunks')
16
+ with np.load(path,allow_pickle=False) as f:p=f['prediction'];m=json.loads(str(f['metadata']))
17
+ if m['kind']!='ink_probability_unvalidated':raise ValueError('une reconstruction ne mesure pas de l’encre')
18
+ if any(overlaps(m['request'],r) for r in m['training']['training_regions']):raise ValueError('recouvrement entraînement/évaluation')
19
+ return p,m
20
+ p,meta=read_prediction(prediction)
21
+ if storage.digest(meta['checkpoint'],'weights')!=meta['checkpoint_sha256']:
22
+ raise ValueError('poids différents de la prédiction évaluée')
23
+ lab=storage.checked(labels,'chunks')
24
+ if lab.stat().st_size>1024**2:raise ValueError('annotations trop volumineuses')
25
+ y=np.load(lab,allow_pickle=False)
26
+ calibration_sha=None
27
+ if calibration_report:
28
+ cp=storage.checked(calibration_report,'submissions');calibration=json.loads(cp.read_text())
29
+ if calibration.get('role')!='calibration' or calibration.get('status')!='calibrated':
30
+ raise ValueError('calibration exploitable requise')
31
+ if calibration['weights_sha256']!=meta['checkpoint_sha256'] or overlaps(calibration['source'],meta['request']):
32
+ raise ValueError('modèle différent ou recouvrement calibration/validation')
33
+ threshold=float(calibration['threshold']);calibration_sha=storage.digest(cp,'submissions')
34
+ register(storage,meta['request'],role)
35
+ if role=='calibration':
36
+ result={'role':role,'source':meta['request'],'weights_sha256':meta['checkpoint_sha256'],
37
+ 'labels_sha256':storage.digest(lab,'chunks'),**choose_threshold(p,y,role=role,valid=np.asarray(meta['valid_mask'],dtype=bool) if meta.get('valid_mask') is not None else None)}
38
+ else:
39
+ volume,source=read_chunk(storage,chunk)
40
+ if source['request']!=meta['request']:raise ValueError('chunk de validation différent de la prédiction')
41
+ model=build_model();training=load(storage,meta['checkpoint'],model);model.eval();torch.set_num_threads(1)
42
+ lo,hi=training['normalization']['low'],training['normalization']['high']
43
+ normalized=np.clip((volume.astype('float32')-lo)/max(hi-lo,1e-6),0,1).astype('float32')
44
+ def infer(x):
45
+ with torch.inference_mode():return model(torch.from_numpy(x[None,None])).sigmoid()[0,0].numpy()
46
+ valid=np.asarray(meta['valid_mask'],dtype=bool) if meta.get('valid_mask') is not None else None
47
+ noise=robustness(normalized,infer,y,threshold,valid=valid)
48
+ noise_ok=all((r['metrics']['precision'] or 0)>=.95 and (r['metrics']['dice'] or 0)>=.6 for r in noise['reports'])
49
+ negative=None;negative_ok=False
50
+ if negative_prediction and negative_labels:
51
+ npred,nmeta=read_prediction(negative_prediction)
52
+ if nmeta['checkpoint_sha256']!=meta['checkpoint_sha256']:raise ValueError('contrôle négatif calculé avec un autre modèle')
53
+ if overlaps(meta['request'],nmeta['request']):raise ValueError('contrôle négatif indépendant requis')
54
+ npath=storage.checked(negative_labels,'chunks')
55
+ if npath.stat().st_size>1024**2:raise ValueError('annotations négatives trop volumineuses')
56
+ ny=np.load(npath,allow_pickle=False)
57
+ if np.any(ny!=0):raise ValueError('le contrôle négatif doit être annoté sans encre')
58
+ register(storage,nmeta['request'],'negative')
59
+ negative=ink_metrics(npred,ny,threshold,np.asarray(nmeta['valid_mask'],dtype=bool) if nmeta.get('valid_mask') is not None else None)
60
+ negative_ok=negative['false_positive_rate'] is not None and negative['false_positive_rate']<=.01
61
+ result={'role':role,'source':meta['request'],'geometry_verified':bool(meta.get('geometry') and meta['geometry'].get('verified')),'disjoint':True,'metrics':ink_metrics(p,y,threshold,valid),
62
+ 'surface_conditioned':meta.get('input_representation')=='surface_conditioned',
63
+ 'provided_surface':meta.get('surface_kind')=='provided_surface',
64
+ 'threshold':threshold,'calibration_sha256':calibration_sha,'noise':noise,'noise_control_passed':noise_ok,
65
+ 'negative':negative,'negative_control_passed':negative_ok,
66
+ 'weights_sha256':meta['checkpoint_sha256'],'labels_sha256':storage.digest(lab,'chunks')}
67
+ result['acceptance']=acceptance(result)
68
+ path=storage.json('submissions','evaluation_'+uuid.uuid4().hex+'.json',result)
69
+ return {'path':str(path),'report':result}
src/exporter.py CHANGED
@@ -5,9 +5,9 @@ import uuid
5
  import zipfile
6
 
7
 
8
- def export(storage, mesh_path, prediction_path=None, pixels=1024):
9
  import numpy as np
10
- from scipy.interpolate import LinearNDInterpolator
11
  from scipy.ndimage import map_coordinates
12
  from PIL import Image,ImageDraw
13
  import tifffile
@@ -16,10 +16,10 @@ def export(storage, mesh_path, prediction_path=None, pixels=1024):
16
  if not 64<=int(pixels)<=2048:raise ValueError('résolution de 64 à 2048 pixels par axe')
17
  path=storage.checked(mesh_path,'meshes')
18
  with np.load(path,allow_pickle=False) as data:
19
- xyz=data['xyz'];uv=data['uv'];meta=json.loads(str(data['metadata']))
20
  if xyz.shape[:2]!=uv.shape[:2] or max(xyz.shape[:2])>64 or meta['quality']['folds']:
21
  raise ValueError('chart invalide')
22
- if meta['quality']['max_relative_stretch']>0.15:raise ValueError('distorsion supérieure à 15 % : réviser la surface')
23
  volume,chunk_meta=read_chunk(storage,meta['chunk'])
24
  if chunk_meta['request']!=meta['request']:raise ValueError('provenance du chunk modifiée')
25
  coordinates=xyz[...,::-1]-np.array(meta['request']['origin'])
@@ -36,49 +36,69 @@ def export(storage, mesh_path, prediction_path=None, pixels=1024):
36
  if any(overlaps(meta['request'],r) for r in prediction_meta['training']['training_regions']):
37
  raise ValueError('entraînement et export se recouvrent')
38
  if not np.isfinite(predicted).all():raise ValueError('prédiction non finie')
 
 
 
 
 
 
39
  values=predicted
40
- low=uv.reshape(-1,2).min(0);high=uv.reshape(-1,2).max(0)
41
- if np.min(high-low)<=1e-8:raise ValueError('chart sans étendue')
42
- # Grille UV régulière ; la carte XYZ est rééchantillonnée pour conserver la correspondance.
43
- n=int(pixels);xs=np.linspace(low[0],high[0],n);ys=np.linspace(low[1],high[1],n)
44
- gx,gy=np.meshgrid(xs,ys)
45
- interpolator=LinearNDInterpolator(uv.reshape(-1,2),np.c_[xyz.reshape(-1,3),values.ravel()])
46
- mapped=interpolator(gx,gy);valid=np.isfinite(mapped).all(axis=-1)
47
  if valid.sum()==0:raise ValueError('paramétrisation vide')
 
 
 
48
  raw=np.where(valid,mapped[...,3],0)
49
  lo,hi=np.percentile(raw[valid],[1,99])
50
  normalized=np.where(valid,np.clip((raw-lo)/max(float(hi-lo),1e-6),0,1),0)
51
  name='patch_'+uuid.uuid4().hex
52
  folder=storage.path('submissions',name);folder.mkdir()
53
  mesh=folder/(name+'.tifxyz');mesh.mkdir()
 
 
 
54
  for i,axis in enumerate('xyz'):
55
  tifffile.imwrite(mesh/(axis+'.tif'),np.where(valid,mapped[...,i],-1).astype('float32'))
56
  Image.fromarray((valid*255).astype('uint8')).save(mesh/'mask.png')
57
  tifffile.imwrite(mesh/'mask.tif',(valid*255).astype('uint8'))
58
  tifffile.imwrite(folder/(name+'.raw.tif'),raw.astype('float32'))
59
  image=Image.fromarray((normalized*65535).astype('uint16'));image.save(folder/(name+'.png'))
60
- voxel_um=meta['request']['voxel_um'];um_per_pixel=float((high[0]-low[0])*voxel_um/(n-1))
61
- possible_mm=float((high[0]-low[0])*voxel_um/1000)
 
62
  bar_mm=float(10.0 if possible_mm>=12 else max(0.001,possible_mm/4))
63
  bar_pixels=max(1,int(round(bar_mm*1000/um_per_pixel)))
64
  preview=Image.fromarray((normalized*255).astype('uint8')).convert('RGB')
65
- canvas=Image.new('RGB',(n,n+48),'black');canvas.paste(preview,(0,0));draw=ImageDraw.Draw(canvas)
66
- draw.line((8,n+12,8+bar_pixels,n+12),fill='white',width=3)
67
- draw.text((8,n+22),f'{bar_mm:g} mm',fill='white');canvas.save(folder/(name+'.scale.png'))
 
 
 
 
 
 
 
 
68
  report={'schema':'unique-vesuvius-research/1','created_at':time.time(),'submission_ready':False,
69
  'reasons':['local_patch_not_full_column','held_out_and_papyrological_validation_missing',
70
  'VC3D_roundtrip_not_certified'],
71
  'source':meta['request'],'mesh':name+'.tifxyz','coordinate_units':'selected_level_voxels',
72
- 'axes':'xyz','shape':[n,n],'uv_bounds':[low.tolist(),high.tolist()],
73
- 'micrometres_per_pixel_x':um_per_pixel,
 
74
  'scale_bar':{'length_mm':bar_mm,'pixels':bar_pixels,'official_1cm_fits':bar_mm==10},
75
  'chunk_sha256':storage.digest(meta['chunk'],'chunks'),'geometry':meta['quality'],'surface_kind':meta['surface_kind'],
76
  'render_kind':prediction_meta['kind'] if prediction_meta else 'CT_density_not_ink',
77
- 'prediction':prediction_meta,'mesh_sha256':storage.digest(path,'meshes'),
78
  'note':'Une grande image rééchantillonnée ne crée pas de résolution CT supplémentaire.'}
79
  valid_xyz=mapped[...,:3][valid]
80
  (mesh/'meta.json').write_text(json.dumps({'format':'tifxyz','type':'seg','uuid':name,
81
- 'scale':[float((n-1)/(high[0]-low[0])),float((n-1)/(high[1]-low[1]))],
82
  'bbox':[valid_xyz.min(0).tolist(),valid_xyz.max(0).tolist()],
83
  'target_volume':meta['request']['uri']+'#vc-base-scale='+meta['request']['level'],
84
  'coordinate_system':'selected_level_voxels','source':meta['request']},indent=2))
 
5
  import zipfile
6
 
7
 
8
+ def export(storage, mesh_path, prediction_path=None, pixels=1024, validation_report=None):
9
  import numpy as np
10
+ from .spatial import rasterize
11
  from scipy.ndimage import map_coordinates
12
  from PIL import Image,ImageDraw
13
  import tifffile
 
16
  if not 64<=int(pixels)<=2048:raise ValueError('résolution de 64 à 2048 pixels par axe')
17
  path=storage.checked(mesh_path,'meshes')
18
  with np.load(path,allow_pickle=False) as data:
19
+ xyz=data['xyz'];uv=data['uv'];meta=json.loads(str(data['metadata']));mask=data['mask'] if 'mask' in data else None
20
  if xyz.shape[:2]!=uv.shape[:2] or max(xyz.shape[:2])>64 or meta['quality']['folds']:
21
  raise ValueError('chart invalide')
22
+ if meta['quality']['max_relative_stretch']>0.05:raise ValueError('distorsion supérieure à 5 % : réviser la surface')
23
  volume,chunk_meta=read_chunk(storage,meta['chunk'])
24
  if chunk_meta['request']!=meta['request']:raise ValueError('provenance du chunk modifiée')
25
  coordinates=xyz[...,::-1]-np.array(meta['request']['origin'])
 
36
  if any(overlaps(meta['request'],r) for r in prediction_meta['training']['training_regions']):
37
  raise ValueError('entraînement et export se recouvrent')
38
  if not np.isfinite(predicted).all():raise ValueError('prédiction non finie')
39
+ if prediction_meta.get('input_representation')=='surface_conditioned' and prediction_meta.get('source_mesh_sha256')!=storage.digest(path,'meshes'):
40
+ raise ValueError('prédiction issue d’une autre surface')
41
+ if prediction_meta.get('valid_mask') is not None:
42
+ sample_mask=np.asarray(prediction_meta['valid_mask'],dtype=bool)
43
+ if sample_mask.shape!=values.shape:raise ValueError('masque de contexte incompatible')
44
+ mask=sample_mask if mask is None else np.asarray(mask,dtype=bool)&sample_mask
45
  values=predicted
46
+ geometry=meta.get('geometry',{'scale_zyx_um':[meta['request']['voxel_um']]*3,'translation_zyx_um':[0.,0.,0.],'verified':False})
47
+ physical=xyz*np.asarray(geometry['scale_zyx_um'][::-1])+np.asarray(geometry['translation_zyx_um'][::-1])
48
+ metric_xyz=physical if meta.get('uv_units')=='micrometres' else xyz
49
+ mapped,raster=rasterize(xyz,uv,values,pixels,mask,metric_xyz)
50
+ valid=np.isfinite(mapped).all(axis=-1)
 
 
51
  if valid.sum()==0:raise ValueError('paramétrisation vide')
52
+ height,width=mapped.shape[:2]
53
+ low,high=np.asarray(raster['bounds'][0]),np.asarray(raster['bounds'][1])
54
+ spacing=raster['spacing_uv']
55
  raw=np.where(valid,mapped[...,3],0)
56
  lo,hi=np.percentile(raw[valid],[1,99])
57
  normalized=np.where(valid,np.clip((raw-lo)/max(float(hi-lo),1e-6),0,1),0)
58
  name='patch_'+uuid.uuid4().hex
59
  folder=storage.path('submissions',name);folder.mkdir()
60
  mesh=folder/(name+'.tifxyz');mesh.mkdir()
61
+ np.savez_compressed(folder/'original_geometry.npz',xyz=xyz,uv=uv,mask=np.isfinite(xyz).all(-1) if mask is None else mask)
62
+ quantization=float(np.max(np.linalg.norm((mapped[...,:3][valid].astype('float32').astype('float64')-mapped[...,:3][valid])*np.asarray(geometry['scale_zyx_um'][::-1]),axis=1)))
63
+ if quantization>.1:raise ValueError('quantification TIFXYZ supérieure à 0,1 µm')
64
  for i,axis in enumerate('xyz'):
65
  tifffile.imwrite(mesh/(axis+'.tif'),np.where(valid,mapped[...,i],-1).astype('float32'))
66
  Image.fromarray((valid*255).astype('uint8')).save(mesh/'mask.png')
67
  tifffile.imwrite(mesh/'mask.tif',(valid*255).astype('uint8'))
68
  tifffile.imwrite(folder/(name+'.raw.tif'),raw.astype('float32'))
69
  image=Image.fromarray((normalized*65535).astype('uint16'));image.save(folder/(name+'.png'))
70
+ voxel_um=meta['request']['voxel_um'];uv_scale=1. if meta.get('uv_units')=='micrometres' else voxel_um
71
+ um_per_pixel=float(spacing[0]*uv_scale)
72
+ possible_mm=float((high[0]-low[0])*uv_scale/1000)
73
  bar_mm=float(10.0 if possible_mm>=12 else max(0.001,possible_mm/4))
74
  bar_pixels=max(1,int(round(bar_mm*1000/um_per_pixel)))
75
  preview=Image.fromarray((normalized*255).astype('uint8')).convert('RGB')
76
+ canvas=Image.new('RGB',(width,height+48),'black');canvas.paste(preview,(0,0));draw=ImageDraw.Draw(canvas)
77
+ draw.line((8,height+12,8+bar_pixels,height+12),fill='white',width=3)
78
+ draw.text((8,height+22),f'{bar_mm:g} mm',fill='white');canvas.save(folder/(name+'.scale.png'))
79
+ validation_evidence=None
80
+ if validation_report:
81
+ evidence_path=storage.checked(validation_report,'submissions')
82
+ validation_evidence=json.loads(evidence_path.read_text())
83
+ if not validation_evidence.get('acceptance',{}).get('automatic_gate_passed'):
84
+ raise ValueError('validation scientifique automatique non réussie')
85
+ if not prediction_meta or validation_evidence['weights_sha256']!=prediction_meta['checkpoint_sha256']:
86
+ raise ValueError('validation d’un autre modèle')
87
  report={'schema':'unique-vesuvius-research/1','created_at':time.time(),'submission_ready':False,
88
  'reasons':['local_patch_not_full_column','held_out_and_papyrological_validation_missing',
89
  'VC3D_roundtrip_not_certified'],
90
  'source':meta['request'],'mesh':name+'.tifxyz','coordinate_units':'selected_level_voxels',
91
+ 'axes':'xyz','shape':[height,width],'quantization_max_um':quantization,
92
+ 'geometry_calibration':geometry,'interpolation':'piecewise_linear_structured_triangles_no_hole_bridging','uv_bounds':[low.tolist(),high.tolist()],
93
+ 'micrometres_per_pixel_x':um_per_pixel,'micrometres_per_pixel_y':float(spacing[1]*uv_scale),
94
  'scale_bar':{'length_mm':bar_mm,'pixels':bar_pixels,'official_1cm_fits':bar_mm==10},
95
  'chunk_sha256':storage.digest(meta['chunk'],'chunks'),'geometry':meta['quality'],'surface_kind':meta['surface_kind'],
96
  'render_kind':prediction_meta['kind'] if prediction_meta else 'CT_density_not_ink',
97
+ 'prediction':prediction_meta,'validation_evidence':validation_evidence,'mesh_sha256':storage.digest(path,'meshes'),
98
  'note':'Une grande image rééchantillonnée ne crée pas de résolution CT supplémentaire.'}
99
  valid_xyz=mapped[...,:3][valid]
100
  (mesh/'meta.json').write_text(json.dumps({'format':'tifxyz','type':'seg','uuid':name,
101
+ 'scale':[float(voxel_um/(spacing[0]*uv_scale)),float(voxel_um/(spacing[1]*uv_scale))],
102
  'bbox':[valid_xyz.min(0).tolist(),valid_xyz.max(0).tolist()],
103
  'target_volume':meta['request']['uri']+'#vc-base-scale='+meta['request']['level'],
104
  'coordinate_system':'selected_level_voxels','source':meta['request']},indent=2))
src/loader.py CHANGED
@@ -38,7 +38,21 @@ class Loader:
38
  'retries':{'max_attempts':1}}, default_fill_cache=False)
39
  store = fs.get_mapper(spec['uri'], check=False)
40
  # mode r : pas de création de métadonnées ni d'objets S3.
41
- return zarr.open_array(store=store, path=spec['level'], mode='r')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  def load(self, spec):
44
  import numpy as np
@@ -63,12 +77,14 @@ class Loader:
63
  if not np.isfinite(volume).all():
64
  raise ValueError('voxels non finis')
65
  metadata = {'request':spec, 'source_shape':list(array.shape), 'loaded_at':time.time(),
66
- 'provenance':'public_CT', 'cache_fallback':False}
 
 
67
  tmp = cache.with_suffix('.tmp.npz')
68
  np.savez_compressed(tmp, volume=volume, metadata=json.dumps(metadata))
69
  tmp.replace(cache)
70
  # Quota du laboratoire uniquement, les autres dossiers restent intacts.
71
- entries = sorted(self.storage.folder('chunks').glob('*.npz'), key=lambda p:p.stat().st_mtime)
72
  total = sum(p.stat().st_size for p in entries)
73
  for p in entries:
74
  if total <= 512*1024**2: break
@@ -96,6 +112,6 @@ def read_chunk(storage, path):
96
  volume = saved['volume'].copy()
97
  metadata = json.loads(str(saved['metadata']))
98
  spec = request_spec(**{k:metadata['request'][k] for k in ('uri','level','origin','shape','voxel_um')})
99
- if list(volume.shape) != spec['shape'] or not np.isfinite(volume).all():
100
  raise ValueError('chunk incohérent')
101
  return volume, metadata
 
38
  'retries':{'max_attempts':1}}, default_fill_cache=False)
39
  store = fs.get_mapper(spec['uri'], check=False)
40
  # mode r : pas de création de métadonnées ni d'objets S3.
41
+ array=zarr.open_array(store=store,path=spec['level'],mode='r')
42
+ from .spatial import ngff_geometry
43
+ try:attrs=dict(zarr.open_group(store=store,mode='r').attrs)
44
+ except zarr.errors.GroupNotFoundError:attrs={}
45
+ from pathlib import Path
46
+ calibration_file=Path(__file__).resolve().parent.parent/'config/vesuvius_calibrations.json'
47
+ catalogue=json.loads(calibration_file.read_text()) if calibration_file.exists() else {'volumes':{}}
48
+ calibration=catalogue.get('volumes',{}).get(spec['uri'])
49
+ geometry=ngff_geometry(attrs,spec['level'],spec['voxel_um'],calibration)
50
+ if calibration:geometry['calibration_reference']=calibration['reference']
51
+ class ReadOnlyArray:
52
+ shape=array.shape;chunks=array.chunks;dtype=array.dtype
53
+ def __getitem__(self,slices):return array[slices]
54
+ result=ReadOnlyArray();result.geometry=geometry
55
+ return result
56
 
57
  def load(self, spec):
58
  import numpy as np
 
77
  if not np.isfinite(volume).all():
78
  raise ValueError('voxels non finis')
79
  metadata = {'request':spec, 'source_shape':list(array.shape), 'loaded_at':time.time(),
80
+ 'provenance':'public_CT', 'cache_fallback':False,
81
+ 'geometry':getattr(array,'geometry',{'verified':False,'source':'user_declared_isotropic',
82
+ 'scale_zyx_um':[spec['voxel_um']]*3,'translation_zyx_um':[0.,0.,0.]})}
83
  tmp = cache.with_suffix('.tmp.npz')
84
  np.savez_compressed(tmp, volume=volume, metadata=json.dumps(metadata))
85
  tmp.replace(cache)
86
  # Quota du laboratoire uniquement, les autres dossiers restent intacts.
87
+ entries = sorted(self.storage.folder('chunks').glob('????????????????????????.npz'), key=lambda p:p.stat().st_mtime)
88
  total = sum(p.stat().st_size for p in entries)
89
  for p in entries:
90
  if total <= 512*1024**2: break
 
112
  volume = saved['volume'].copy()
113
  metadata = json.loads(str(saved['metadata']))
114
  spec = request_spec(**{k:metadata['request'][k] for k in ('uri','level','origin','shape','voxel_um')})
115
+ if list(volume.shape) != metadata.get('sample_shape',spec['shape']) or not np.isfinite(volume).all():
116
  raise ValueError('chunk incohérent')
117
  return volume, metadata
src/plan.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Construit un DAG reproductible à partir de vraies surfaces et annotations."""
2
+ import argparse
3
+ import json
4
+ from pathlib import Path
5
+
6
+
7
+ def build(storage,regions,device='cuda',steps=4):
8
+ import numpy as np
9
+ from .loader import request_spec
10
+ from .trainer import overlaps
11
+ roles=('train','calibration','validation','negative');specs={};tasks=[];manifest={}
12
+ for role in roles:
13
+ data=regions[role];r=data['roi']
14
+ specs[role]=request_spec(**{k:r[k] for k in ('uri','level','origin','shape','voxel_um')})
15
+ xyz=storage.checked(data['surface_xyz'],'meshes');label=storage.checked(data['labels'],'chunks')
16
+ if xyz.stat().st_size>128000 or label.stat().st_size>1024**2:raise ValueError('surface ou annotation trop volumineuse')
17
+ points=np.load(xyz,allow_pickle=False);labels=np.load(label,allow_pickle=False)
18
+ if points.ndim!=3 or points.shape[-1]!=3 or labels.shape!=points.shape[:2] or not np.isin(labels,[0,1]).all():
19
+ raise ValueError('surface et annotations binaires alignées requises')
20
+ if role=='negative' and np.any(labels):raise ValueError('contrôle négatif non vide')
21
+ manifest[role]={'roi':specs[role],'xyz_sha256':storage.digest(xyz,'meshes'),'labels_sha256':storage.digest(label,'chunks')}
22
+ tasks.extend([
23
+ {'name':'load_'+role,'action':'load','payload':specs[role]},
24
+ {'name':'mesh_'+role,'action':'unwrap','payload':{'chunk':'@load_'+role,'surface':str(xyz)}},
25
+ {'name':'surface_'+role,'action':'surface','payload':{'mesh':'@mesh_'+role,'layers':17}}])
26
+ for i,role in enumerate(roles):
27
+ if any(overlaps(specs[role],specs[other]) for other in roles[i+1:]):raise ValueError('régions de benchmark non disjointes')
28
+ tasks.append({'name':'fit','action':'train','payload':{'chunk':'@surface_train','mask':regions['train']['labels'],
29
+ 'mode':'ink_supervised','device':device,'steps':int(steps)}})
30
+ for role in roles[1:]:
31
+ tasks.append({'name':'predict_'+role,'action':'predict','payload':{'chunk':'@surface_'+role,'checkpoint':'@fit','device':device}})
32
+ tasks.append({'name':'calibrate','action':'evaluate','payload':{'prediction':'@predict_calibration',
33
+ 'labels':regions['calibration']['labels'],'chunk':'@surface_calibration','role':'calibration'}})
34
+ tasks.append({'name':'validate','action':'evaluate','payload':{'prediction':'@predict_validation',
35
+ 'labels':regions['validation']['labels'],'chunk':'@surface_validation','role':'validation',
36
+ 'calibration_report':'@calibrate','negative_prediction':'@predict_negative','negative_labels':regions['negative']['labels']}})
37
+ tasks.append({'name':'research_export','action':'export','payload':{'mesh':'@mesh_validation',
38
+ 'prediction':'@predict_validation','validation_report':'@validate','pixels':1024}})
39
+ return {'schema':'unique-competition-plan/1','model':'tiny_hybrid_3d_2d_v1_experimental',
40
+ 'source_manifest':manifest,'tasks':tasks,'official_submission_authorized':False}
41
+
42
+
43
+ def main():
44
+ from .storage import Storage
45
+ parser=argparse.ArgumentParser()
46
+ parser.add_argument('--regions',required=True);parser.add_argument('--device',choices=['cuda','cpu'],default='cuda')
47
+ parser.add_argument('--steps',type=int,default=4);args=parser.parse_args()
48
+ storage=Storage();storage.initialize()
49
+ path=storage.checked(args.regions,'chunks')
50
+ result=build(storage,json.loads(path.read_text()),args.device,args.steps)
51
+ print(storage.json('chunks','competition-plan.json',result))
52
+
53
+
54
+ if __name__=='__main__':main()
src/quality.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mesures supervisées, négatifs et bruit : aucune vérité terrain inventée."""
2
+ import math
3
+
4
+
5
+ def surface_metrics(predicted_xyz, reference_xyz, scale_xyz_um=(1.,1.,1.)):
6
+ import numpy as np
7
+ from scipy.spatial import cKDTree
8
+ p=np.asarray(predicted_xyz,dtype=float).reshape(-1,3)*scale_xyz_um
9
+ r=np.asarray(reference_xyz,dtype=float).reshape(-1,3)*scale_xyz_um
10
+ if not len(p) or not len(r) or not np.isfinite(p).all() or not np.isfinite(r).all():
11
+ raise ValueError('surfaces de référence finies et non vides requises')
12
+ forward=cKDTree(r).query(p,workers=1)[0];backward=cKDTree(p).query(r,workers=1)[0]
13
+ return {'chamfer_mean_um':float((forward.mean()+backward.mean())/2),
14
+ 'hausdorff_max_um':float(max(forward.max(),backward.max())),
15
+ 'hausdorff_p95_um':float(max(np.quantile(forward,.95),np.quantile(backward,.95))),
16
+ 'predicted_points':len(p),'reference_points':len(r),'reference_frame':'shared_selected_level_voxels'}
17
+
18
+
19
+ def ink_metrics(probability, labels, threshold=.5, valid=None):
20
+ import numpy as np
21
+ from scipy.ndimage import binary_erosion,distance_transform_edt
22
+ p=np.asarray(probability,dtype='float64');y=np.asarray(labels)
23
+ if p.shape!=y.shape or p.ndim!=2 or not np.isfinite(p).all() or np.any((p<0)|(p>1)):
24
+ raise ValueError('probabilités H×W dans [0,1] requises')
25
+ if not np.isin(y,[0,1]).all():raise ValueError('vérité terrain binaire requise')
26
+ mask=np.ones(y.shape,dtype=bool) if valid is None else np.asarray(valid,dtype=bool)
27
+ if mask.shape!=y.shape or not mask.any():raise ValueError('masque de validation vide/invalide')
28
+ truth=y[mask].astype(bool);pred=p[mask]>=threshold
29
+ tp=int(np.sum(pred&truth));fp=int(np.sum(pred&~truth));fn=int(np.sum(~pred&truth));tn=int(np.sum(~pred&~truth))
30
+ ratio=lambda n,d:float(n/d) if d else None
31
+ calibration=0.
32
+ for lo in np.linspace(0,.9,10):
33
+ bin_mask=(p[mask]>=lo)&(p[mask]<(lo+.1) if lo<.89 else p[mask]<=1)
34
+ if bin_mask.any():calibration+=float(bin_mask.mean()*abs(p[mask][bin_mask].mean()-truth[bin_mask].mean()))
35
+ target_edge=(y.astype(bool)&~binary_erosion(y.astype(bool)))&mask
36
+ predicted=(p>=threshold)&mask;pred_edge=predicted&~binary_erosion(predicted)
37
+ bp=ratio(int((pred_edge&(distance_transform_edt(~target_edge)<=1)).sum()),int(pred_edge.sum())) if target_edge.any() else None
38
+ br=ratio(int((target_edge&(distance_transform_edt(~pred_edge)<=1)).sum()),int(target_edge.sum())) if pred_edge.any() else None
39
+ bf=2*bp*br/(bp+br) if bp is not None and br is not None and bp+br else None
40
+ return {'precision':ratio(tp,tp+fp),'recall':ratio(tp,tp+fn),'dice':ratio(2*tp,2*tp+fp+fn),
41
+ 'iou':ratio(tp,tp+fp+fn),'false_positive_rate':ratio(fp,fp+tn),
42
+ 'brier':float(np.mean((p[mask]-truth)**2)),'ece':calibration,'boundary_f1_1px':bf,
43
+ 'tp':tp,'fp':fp,'fn':fn,'tn':tn,'pixels':int(mask.sum())}
44
+
45
+
46
+ def choose_threshold(probability, labels, *, role, minimum_precision=.95, valid=None):
47
+ if role!='calibration':raise ValueError('seuil réglable sur calibration seulement, jamais sur validation/test')
48
+ candidates=[]
49
+ for t in (.1,.2,.3,.4,.5,.6,.7,.8,.9):
50
+ m=ink_metrics(probability,labels,t,valid)
51
+ if m['precision'] is not None and m['precision']>=minimum_precision:
52
+ candidates.append((m['recall'] or 0,t,m))
53
+ if not candidates:return {'status':'abstain','threshold':None}
54
+ _,t,m=max(candidates,key=lambda x:(x[0],x[1]))
55
+ return {'status':'calibrated','threshold':t,'metrics':m}
56
+
57
+
58
+ def robustness(volume, infer, labels, threshold=.5, seed=0, valid=None):
59
+ import numpy as np
60
+ v=np.asarray(volume,dtype='float32')
61
+ rng=np.random.default_rng(seed);reports=[]
62
+ for sigma in (0.,.01,.03):
63
+ perturbed=np.clip(v+rng.normal(0,sigma,v.shape).astype('float32'),0,1)
64
+ reports.append({'simulated_sigma':sigma,'metrics':ink_metrics(infer(perturbed),labels,threshold,valid)})
65
+ return {'seed':seed,'noise_model':'synthetic_additive_gaussian_normalized_intensity',
66
+ 'physical_scanner_noise_validated':False,'reports':reports}
67
+
68
+
69
+ def acceptance(report, minimum_precision=.95, minimum_dice=.6, max_false_positive_rate=.01):
70
+ reasons=[]
71
+ if report.get('role')!='validation':reasons.append('independent_validation_required')
72
+ if not report.get('surface_conditioned',False):reasons.append('surface_conditioned_input_required')
73
+ if not report.get('provided_surface',False):reasons.append('provided_surface_required')
74
+ if not report.get('geometry_verified',False):reasons.append('unverified_geometry')
75
+ if not report.get('disjoint',False):reasons.append('dataset_leakage_or_unknown')
76
+ m=report.get('metrics',{})
77
+ for key,limit,direction in [('precision',minimum_precision,1),('dice',minimum_dice,1),('false_positive_rate',max_false_positive_rate,-1)]:
78
+ value=m.get(key)
79
+ if value is None or not math.isfinite(value) or (value<limit if direction==1 else value>limit):reasons.append(key)
80
+ if not report.get('negative_control_passed'):reasons.append('negative_control_required')
81
+ if not report.get('noise_control_passed'):reasons.append('noise_control_required')
82
+ return {'automatic_gate_passed':not reasons,'reasons':reasons,
83
+ 'legibility_certified':False,'competition_eligibility_certified':False}
src/spatial.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Calibration NGFF, validation triangulaire et rendu sans pont à travers les masques."""
2
+ import math
3
+
4
+
5
+ def ngff_geometry(attrs, level, declared_voxel_um, base_calibration=None):
6
+ import numpy as np
7
+ multiscales=attrs.get('multiscales',[])
8
+ if not multiscales:
9
+ return {'verified':False,'source':'user_declared_isotropic','scale_zyx_um':[declared_voxel_um]*3,
10
+ 'translation_zyx_um':[0.,0.,0.],'axes':'zyx'}
11
+ entry=multiscales[0];axes=entry.get('axes',[])
12
+ names=[a.get('name') if isinstance(a,dict) else a for a in axes]
13
+ if names!=['z','y','x']:raise ValueError('axes NGFF non z,y,x : transformation explicite requise')
14
+ factors=[]
15
+ for a in axes:
16
+ unit=a.get('unit') if isinstance(a,dict) else None
17
+ units={'micrometer':1.,'micrometre':1.,'um':1.,'µm':1.,'nanometer':.001,'millimeter':1000.}
18
+ if unit is not None and unit not in units:raise ValueError('unité NGFF non prise en charge')
19
+ factors.append(units.get(unit))
20
+ dataset=next((d for d in entry.get('datasets',[]) if d.get('path')==str(level)),None)
21
+ if dataset is None:raise ValueError('niveau absent des métadonnées NGFF')
22
+ scale=np.ones(3);translation=np.zeros(3)
23
+ transforms=list(dataset.get('coordinateTransformations',[]))+list(entry.get('coordinateTransformations',[]))
24
+ for t in transforms:
25
+ if t.get('type')=='scale':
26
+ v=np.asarray(t.get('scale'),dtype=float)
27
+ if v.shape!=(3,) or np.any(v<=0) or not np.isfinite(v).all():raise ValueError('échelle NGFF invalide')
28
+ scale*=v;translation*=v
29
+ elif t.get('type')=='translation':
30
+ v=np.asarray(t.get('translation'),dtype=float)
31
+ if v.shape!=(3,) or not np.isfinite(v).all():raise ValueError('translation NGFF invalide')
32
+ translation+=v
33
+ else:raise ValueError('transformation NGFF non prise en charge')
34
+ raw_scale=scale.copy();raw_translation=translation.copy()
35
+ if all(f is None for f in factors):
36
+ if base_calibration:
37
+ factors=[float(base_calibration['base_voxel_um'])]*3
38
+ else:
39
+ return {'verified':False,'source':'user_declared_with_NGFF_pixel_transform',
40
+ 'scale_zyx_um':[float(declared_voxel_um)]*3,
41
+ 'translation_zyx_um':(translation*declared_voxel_um/scale).tolist(),
42
+ 'raw_scale_zyx':raw_scale.tolist(),'raw_translation_zyx':raw_translation.tolist(),'axes':'zyx'}
43
+ elif any(f is None for f in factors):raise ValueError('unités NGFF partiellement renseignées')
44
+ scale*=factors;translation*=factors
45
+ if not np.allclose(scale,declared_voxel_um,rtol=1e-5,atol=1e-6):
46
+ raise ValueError('calibration NGFF différente du pas isotrope demandé ; choisir un niveau compatible')
47
+ return {'verified':True,'source':'curated_catalogue_plus_NGFF' if base_calibration and not any(isinstance(a,dict) and a.get('unit') for a in axes) else 'OME_NGFF','scale_zyx_um':scale.tolist(),
48
+ 'translation_zyx_um':translation.tolist(),'axes':'zyx'}
49
+
50
+
51
+ def validate_geometry(xyz,uv,valid=None):
52
+ import numpy as np
53
+ xyz=np.asarray(xyz,dtype=float);uv=np.asarray(uv,dtype=float)
54
+ if xyz.ndim!=3 or xyz.shape[-1]!=3 or uv.shape!=xyz.shape[:2]+(2,):raise ValueError('géométrie H×W invalide')
55
+ good=np.isfinite(xyz).all(-1)&np.isfinite(uv).all(-1)
56
+ if valid is not None:good&=np.asarray(valid,dtype=bool)
57
+ h,w=good.shape;triangles=[]
58
+ for y in range(h-1):
59
+ for x in range(w-1):
60
+ # Même diagonale p10-p01 que les surfaces quad de Villa.
61
+ a=y*w+x;b=a+1;c=a+w;d=c+1
62
+ for t in ((a,b,c),(b,d,c)):
63
+ if good.ravel()[list(t)].all():triangles.append(t)
64
+ if not triangles:raise ValueError('aucun triangle valide')
65
+ triangles=np.asarray(triangles);p=xyz.reshape(-1,3)[triangles];q=uv.reshape(-1,2)[triangles]
66
+ norms=np.cross(p[:,1]-p[:,0],p[:,2]-p[:,0]);areas=np.linalg.norm(norms,axis=1)/2
67
+ cross=(q[:,1,0]-q[:,0,0])*(q[:,2,1]-q[:,0,1])-(q[:,1,1]-q[:,0,1])*(q[:,2,0]-q[:,0,0])
68
+ if np.any(areas<=1e-10) or np.any(np.abs(cross)<=1e-10):raise ValueError('triangles dégénérés')
69
+ if np.any(cross>0) and np.any(cross<0):raise ValueError('triangles retournés dans UV')
70
+ edges=np.concatenate([triangles[:,[0,1]],triangles[:,[1,2]],triangles[:,[2,0]]])
71
+ unique,counts=np.unique(np.sort(edges,axis=1),axis=0,return_counts=True)
72
+ boundary=unique[counts==1];flat=uv.reshape(-1,2)
73
+ # Un bord auto-intersecté invalide le chart même si les Jacobiennes locales sont positives.
74
+ def orient(a,b,c):return (b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])
75
+ for i,(a,b) in enumerate(boundary):
76
+ for c,d in boundary[i+1:]:
77
+ if len({int(a),int(b),int(c),int(d)})<4:continue
78
+ if orient(flat[a],flat[b],flat[c])*orient(flat[a],flat[b],flat[d])<0 and orient(flat[c],flat[d],flat[a])*orient(flat[c],flat[d],flat[b])<0:
79
+ raise ValueError('bord UV auto-intersecté')
80
+ length3=np.linalg.norm(xyz.reshape(-1,3)[unique[:,0]]-xyz.reshape(-1,3)[unique[:,1]],axis=1)
81
+ length2=np.linalg.norm(flat[unique[:,0]]-flat[unique[:,1]],axis=1)
82
+ if np.any(length3<=1e-10):raise ValueError('arête nulle')
83
+ stretch=np.abs(length2/length3-1)
84
+ return {'triangles':int(len(triangles)),'valid_fraction':float(good.mean()),'area':float(areas.sum()),
85
+ 'max_relative_stretch':float(stretch.max()),'p95_relative_stretch':float(np.quantile(stretch,.95)),
86
+ 'rms_relative_stretch':float(np.sqrt(np.mean(stretch**2)))},triangles
87
+
88
+
89
+ def rasterize(xyz,uv,values,pixels,valid=None,metric_xyz=None):
90
+ import numpy as np
91
+ report,triangles=validate_geometry(xyz if metric_xyz is None else metric_xyz,uv,valid)
92
+ flat=uv.reshape(-1,2);used=flat[np.unique(triangles)];lo=used.min(0);hi=used.max(0);span=hi-lo
93
+ spacing=float(span.max()/(int(pixels)-1))
94
+ if spacing<=0:raise ValueError('chart vide')
95
+ width=max(2,int(round(span[0]/spacing))+1);height=max(2,int(round(span[1]/spacing))+1)
96
+ xs=np.linspace(lo[0],hi[0],width);ys=np.linspace(lo[1],hi[1],height)
97
+ mapped=np.full((height,width,4),np.nan);payload=np.c_[xyz.reshape(-1,3),np.asarray(values).ravel()]
98
+ for ids in triangles:
99
+ triangle=flat[ids];a=triangle.min(0);b=triangle.max(0)
100
+ x0=max(0,int(np.searchsorted(xs,a[0],side='left'))-1);x1=min(width,int(np.searchsorted(xs,b[0],side='right'))+1)
101
+ y0=max(0,int(np.searchsorted(ys,a[1],side='left'))-1);y1=min(height,int(np.searchsorted(ys,b[1],side='right'))+1)
102
+ gx,gy=np.meshgrid(xs[x0:x1],ys[y0:y1]);points=np.stack([gx,gy],axis=-1)
103
+ inverse=np.linalg.inv(np.stack([triangle[1]-triangle[0],triangle[2]-triangle[0]],axis=1))
104
+ bary=(points-triangle[0])@inverse.T
105
+ inside=(bary[...,0]>=-1e-9)&(bary[...,1]>=-1e-9)&(bary.sum(-1)<=1+1e-9)
106
+ weights=np.concatenate([1-bary.sum(-1,keepdims=True),bary],axis=-1)
107
+ output=weights@payload[ids]
108
+ block=mapped[y0:y1,x0:x1];block[inside]=output[inside]
109
+ return mapped,{'bounds':[lo.tolist(),hi.tolist()], 'spacing_uv':[float(span[0]/(width-1)),float(span[1]/(height-1))],
110
+ 'shape':[height,width],'geometry':report}
src/surface_volume.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Échantillonnage 3D le long des normales, masque des contextes hors CT."""
2
+ import json
3
+ import uuid
4
+
5
+
6
+ def render(storage,mesh_path,layers=17):
7
+ import numpy as np
8
+ from scipy.ndimage import map_coordinates,binary_erosion
9
+ from .loader import read_chunk
10
+ path=storage.checked(mesh_path,'meshes')
11
+ with np.load(path,allow_pickle=False) as f:xyz=f['xyz'];meta=json.loads(str(f['metadata']))
12
+ if not 3<=int(layers)<=63 or int(layers)%2!=1:raise ValueError('nombre impair de couches de 3 à 63 requis')
13
+ volume,source=read_chunk(storage,meta['chunk'])
14
+ if meta.get('source_chunk_sha256') and storage.digest(meta['chunk'],'chunks')!=meta['source_chunk_sha256']:
15
+ raise ValueError('CT modifié depuis la construction de la surface')
16
+ scale=np.asarray(source.get('geometry',{}).get('scale_zyx_um',[source['request']['voxel_um']]*3),dtype=float)
17
+ physical=xyz*scale[::-1]
18
+ dy,dx=np.gradient(physical,axis=(0,1));normal=np.cross(dx,dy)
19
+ norm=np.linalg.norm(normal,axis=-1)
20
+ if np.any(norm<1e-10):raise ValueError('normales de surface dégénérées')
21
+ normal/=norm[...,None]
22
+ offsets=np.arange(int(layers))-int(layers)//2
23
+ positions=xyz[None]+offsets[:,None,None,None]*normal[None]*source['request']['voxel_um']/scale[::-1]
24
+ local=positions[...,::-1]-np.array(source['request']['origin'])
25
+ valid=((local>=0)&(local<=np.asarray(volume.shape)-1)).all(-1).all(0)
26
+ valid=binary_erosion(valid,iterations=3) # exclut le halo d'influence du padding artificiel
27
+ if not valid.any():raise ValueError('aucun contexte complet : charger une ROI avec davantage de marge')
28
+ samples=map_coordinates(volume.astype('float32'),local.reshape(-1,3).T,order=1,mode='constant',cval=0)
29
+ samples=samples.reshape((int(layers),)+xyz.shape[:2]).astype('float32')
30
+ target=storage.path('chunks',uuid.uuid4().hex+'.surface.npz')
31
+ metadata={**source,'sample_shape':list(samples.shape),'input_representation':'surface_conditioned',
32
+ 'source_mesh_sha256':storage.digest(path,'meshes'),'surface_kind':meta['surface_kind'],
33
+ 'valid_mask':valid.tolist(),'layer_spacing_um':source['request']['voxel_um'],
34
+ 'layer_offsets':offsets.tolist(),'interpolation_order':1}
35
+ np.savez_compressed(target,volume=samples,metadata=json.dumps(metadata))
36
+ return {'path':str(target),'shape':list(samples.shape),'valid_fraction':float(valid.mean())}
src/trainer.py CHANGED
@@ -23,32 +23,38 @@ def build_model():
23
  return Hybrid()
24
 
25
 
26
- def train(storage, chunk_path, steps=4, mode='reconstruction', mask_path=None, checkpoint=None, device='cpu'):
27
  import numpy as np
28
  import torch
29
- from safetensors.torch import load_file,save_file
30
  from .loader import read_chunk
 
 
31
  if mode not in ('reconstruction','ink_supervised') or not 1<=int(steps)<=32:
32
  raise ValueError('mode ou budget de microcycle invalide')
33
  volume,metadata=read_chunk(storage,chunk_path)
 
 
 
 
 
 
 
 
 
 
 
34
  torch.set_num_threads(1)
35
- with torch.random.fork_rng(devices=[0] if device=='cuda' else []):
36
- torch.manual_seed(0)
37
- torch.use_deterministic_algorithms(True)
38
- if device=='cuda':
39
- if not torch.cuda.is_available():raise RuntimeError('CUDA indisponible')
40
- if torch.cuda.mem_get_info()[0]<1024**3:raise RuntimeError('moins de 1 Gio de VRAM libre')
41
- model=build_model().to(device)
42
- history=[];initial='random'
43
  if checkpoint:
44
- path=storage.checked(checkpoint,'weights')
45
- if path.stat().st_size>16*1024**2:raise ValueError('checkpoint trop volumineux')
46
- prior=json.loads(path.with_suffix('.json').read_text())
47
  if prior['mode']!=mode:raise ValueError('objectif du checkpoint incompatible')
48
- model.load_state_dict(load_file(str(path),device=device))
49
- history=list(prior['training_regions']);initial=str(path)
50
  normalized=volume.astype('float32')
51
- lo,hi=np.percentile(normalized,[1,99]);normalized=np.clip((normalized-lo)/max(float(hi-lo),1e-6),0,1).astype("float32")
 
52
  x=torch.from_numpy(normalized[None,None]).to(device)
53
  if mode=='ink_supervised':
54
  if not mask_path:raise ValueError('masque d’encre annoté requis pour un objectif supervisé')
@@ -59,52 +65,75 @@ def train(storage, chunk_path, steps=4, mode='reconstruction', mask_path=None, c
59
  raise ValueError('masque attendu H×W dans [0,1]')
60
  target=torch.from_numpy(mask.astype('float32')[None,None]).to(device)
61
  else:target=x.mean(dim=2)
62
- optimizer=torch.optim.AdamW(model.parameters(),lr=1e-3)
63
  bf16=device=='cuda' and torch.cuda.is_bf16_supported()
64
- loss_fn=torch.nn.BCEWithLogitsLoss() if mode=='ink_supervised' else torch.nn.MSELoss()
65
- loss_values=[]
66
- model.train()
67
- for _ in range(int(steps)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  optimizer.zero_grad(set_to_none=True)
69
- # Dénoyautage masqué pour la reconstruction ; jamais présenté comme une carte d'encre.
70
  inp=x*(torch.rand_like(x)>0.15) if mode=='reconstruction' else x
71
  with torch.autocast(device_type=device,dtype=torch.bfloat16,enabled=bf16):
72
- output=model(inp);loss=loss_fn(output,target)
73
  if not torch.isfinite(loss):raise RuntimeError('loss non finie')
74
- loss.backward();optimizer.step();loss_values.append(float(loss.detach().cpu()))
75
- name=uuid.uuid4().hex
76
- path=storage.path('weights',name+'.safetensors')
77
- save_file({k:v.detach().cpu().contiguous() for k,v in model.state_dict().items()},str(path))
78
- history.append(metadata['request'])
79
- result={'mode':mode,'seed':0,'steps':int(steps),'loss':loss_values,'device':device,
80
- 'precision':'bf16' if bf16 else 'float32','initialization':initial,'training_regions':history,
81
- 'held_out_evaluated':False,'ink_quality_measured':False,'created_at':time.time(),
82
- 'architecture':'tiny_hybrid_3d_2d_v1','torch_version':torch.__version__,'numpy_version':np.__version__,
83
- 'training_chunk_sha256':storage.digest(chunk_path,'chunks'),'normalization':{'low':float(lo),'high':float(hi)}}
84
- storage.json('weights',name+'.json',result)
85
- return {'path':str(path),'report':result}
86
 
87
 
88
- def predict(storage, chunk_path, checkpoint):
89
  import numpy as np
90
  import torch
91
- from safetensors.torch import load_file
92
  from .loader import read_chunk
 
 
93
  volume,metadata=read_chunk(storage,chunk_path)
 
94
  path=storage.checked(checkpoint,'weights')
95
- if path.stat().st_size>16*1024**2:raise ValueError('checkpoint trop volumineux')
96
- report=json.loads(path.with_suffix('.json').read_text())
97
  if any(overlaps(metadata['request'],region) for region in report['training_regions']):
98
  raise ValueError('région de prédiction en recouvrement avec un entraînement : refus')
99
- model=build_model();model.load_state_dict(load_file(str(path),device='cpu'));model.eval()
100
  lo,hi=report['normalization']['low'],report['normalization']['high']
101
- x=np.clip((volume.astype('float32')-lo)/max(hi-lo,1e-6),0,1).astype("float32")
102
- with torch.inference_mode():out=model(torch.from_numpy(x[None,None]))
103
- if report['mode']=='ink_supervised':out=out.sigmoid()
104
- out=out.squeeze().numpy()
 
 
 
 
 
 
 
 
 
 
 
105
  name=uuid.uuid4().hex
106
  target=storage.path('chunks',name+'.prediction.npz')
107
  np.savez_compressed(target,prediction=out,metadata=json.dumps({'request':metadata['request'],
108
  'checkpoint':str(path),'checkpoint_sha256':storage.digest(path,'weights'),'training':report,
109
- 'kind':'ink_probability_unvalidated' if report['mode']=='ink_supervised' else 'reconstruction_not_ink'}))
110
- return {'path':str(target),'kind':report['mode'],'held_out_evaluated':False}
 
 
 
 
 
23
  return Hybrid()
24
 
25
 
26
+ def train(storage, chunk_path, steps=4, mode='reconstruction', mask_path=None, checkpoint=None, device='cpu', cancelled=lambda:False, run_id=None):
27
  import numpy as np
28
  import torch
 
29
  from .loader import read_chunk
30
+ from . import checkpoints
31
+ from .adaptive import Preempted,CudaBudget
32
  if mode not in ('reconstruction','ink_supervised') or not 1<=int(steps)<=32:
33
  raise ValueError('mode ou budget de microcycle invalide')
34
  volume,metadata=read_chunk(storage,chunk_path)
35
+ pointer_name='training-run_'+run_id+'.json' if run_id else None
36
+ source_sha=storage.digest(chunk_path,'chunks')
37
+ if pointer_name:
38
+ pointer=storage.path('weights',pointer_name)
39
+ if pointer.exists():
40
+ saved=json.loads(pointer.read_text())
41
+ if saved['source_sha']!=source_sha or saved['mode']!=mode:raise ValueError('reprise d’une autre expérience')
42
+ checkpoint=saved['checkpoint'];steps=saved['remaining_steps']
43
+ if steps==0:return {'path':checkpoint,'report':json.loads(storage.checked(checkpoint,'weights').with_suffix('.json').read_text()),'cached':True}
44
+ from .datasets import register
45
+ register(storage,metadata['request'],'train')
46
  torch.set_num_threads(1)
47
+ with torch.random.fork_rng(devices=[0] if device=='cuda' else []),CudaBudget(device) as budget:
48
+ torch.manual_seed(0);torch.use_deterministic_algorithms(True)
49
+ model=build_model().to(device);optimizer=torch.optim.AdamW(model.parameters(),lr=1e-3)
50
+ history=[];initial='random';global_step=0
 
 
 
 
51
  if checkpoint:
52
+ prior=checkpoints.load(storage,checkpoint,model,optimizer,device)
 
 
53
  if prior['mode']!=mode:raise ValueError('objectif du checkpoint incompatible')
54
+ history=list(prior['training_regions']);initial=str(checkpoint);global_step=int(prior.get('global_step',0))
 
55
  normalized=volume.astype('float32')
56
+ calibration_pixels=normalized[:,np.asarray(metadata['valid_mask'],dtype=bool)] if metadata.get('valid_mask') is not None else normalized
57
+ lo,hi=np.percentile(calibration_pixels,[1,99]);normalized=np.clip((normalized-lo)/max(float(hi-lo),1e-6),0,1).astype('float32')
58
  x=torch.from_numpy(normalized[None,None]).to(device)
59
  if mode=='ink_supervised':
60
  if not mask_path:raise ValueError('masque d’encre annoté requis pour un objectif supervisé')
 
65
  raise ValueError('masque attendu H×W dans [0,1]')
66
  target=torch.from_numpy(mask.astype('float32')[None,None]).to(device)
67
  else:target=x.mean(dim=2)
 
68
  bf16=device=='cuda' and torch.cuda.is_bf16_supported()
69
+ loss_fn=torch.nn.BCEWithLogitsLoss(reduction='none') if mode=='ink_supervised' else torch.nn.MSELoss(reduction='none')
70
+ valid=np.asarray(metadata.get('valid_mask',np.ones(volume.shape[1:],bool)),dtype='float32')
71
+ if valid.shape!=volume.shape[1:] or valid.sum()==0:raise ValueError('masque d’apprentissage vide')
72
+ valid_tensor=torch.from_numpy(valid[None,None]).to(device)
73
+ if metadata['request'] not in history:history.append(metadata['request'])
74
+ loss_values=[];model.train();last_path=None;result=None
75
+ def snapshot():
76
+ report={'mode':mode,'seed':0,'steps':len(loss_values),'global_step':global_step,
77
+ 'loss':list(loss_values),'device':device,'precision':'bf16' if bf16 else 'float32',
78
+ 'initialization':initial,'training_regions':history,'held_out_evaluated':False,
79
+ 'ink_quality_measured':False,'created_at':time.time(),'architecture':'tiny_hybrid_3d_2d_v1','input_representation':metadata.get('input_representation','raw_CT_projection_experimental'),
80
+ 'torch_version':torch.__version__,'numpy_version':np.__version__,
81
+ 'training_chunk_sha256':storage.digest(chunk_path,'chunks'),
82
+ 'normalization':{'low':float(lo),'high':float(hi)}}
83
+ return checkpoints.save(storage,model,optimizer,report)
84
+ for index in range(int(steps)):
85
+ if cancelled():
86
+ if last_path is None:last_path,result=snapshot()
87
+ raise Preempted({'checkpoint':last_path,'steps':int(steps)-index})
88
+ torch.manual_seed(global_step) # même masque et même AdamW après reprise
89
  optimizer.zero_grad(set_to_none=True)
 
90
  inp=x*(torch.rand_like(x)>0.15) if mode=='reconstruction' else x
91
  with torch.autocast(device_type=device,dtype=torch.bfloat16,enabled=bf16):
92
+ output=model(inp);loss=(loss_fn(output,target)*valid_tensor).sum()/valid_tensor.sum()
93
  if not torch.isfinite(loss):raise RuntimeError('loss non finie')
94
+ loss.backward();optimizer.step();loss_values.append(float(loss.detach().cpu()));global_step+=1
95
+ last_path,result=snapshot()
96
+ if pointer_name:storage.json('weights',pointer_name,{'source_sha':source_sha,'mode':mode,
97
+ 'checkpoint':last_path,'remaining_steps':int(steps)-index-1})
98
+ return {'path':last_path,'report':result,'performance':budget.report}
 
 
 
 
 
 
 
99
 
100
 
101
+ def predict(storage, chunk_path, checkpoint, device='cpu', tile=64, resume=None, cancelled=lambda:False):
102
  import numpy as np
103
  import torch
 
104
  from .loader import read_chunk
105
+ from .checkpoints import load
106
+ from .adaptive import tiled_predict,CudaBudget
107
  volume,metadata=read_chunk(storage,chunk_path)
108
+ torch.set_num_threads(1)
109
  path=storage.checked(checkpoint,'weights')
110
+ model=build_model();report=load(storage,path,model)
111
+ if report.get('architecture')!='tiny_hybrid_3d_2d_v1':raise ValueError('adaptateur de modèle et halo validés requis')
112
  if any(overlaps(metadata['request'],region) for region in report['training_regions']):
113
  raise ValueError('région de prédiction en recouvrement avec un entraînement : refus')
 
114
  lo,hi=report['normalization']['low'],report['normalization']['high']
115
+ x=np.clip((volume.astype('float32')-lo)/max(hi-lo,1e-6),0,1).astype('float32')
116
+ with CudaBudget(device) as budget:
117
+ model=model.to(device).eval();bf16=device=='cuda' and torch.cuda.is_bf16_supported()
118
+ def infer(batch):
119
+ tensor=torch.from_numpy(batch[:,None])
120
+ if device=='cuda':tensor=tensor.pin_memory().to(device,non_blocking=True)
121
+ with torch.inference_mode(),torch.autocast(device_type=device,dtype=torch.bfloat16,enabled=bf16):
122
+ output=model(tensor)
123
+ if report['mode']=='ink_supervised':output=output.sigmoid()
124
+ return output[:,0].float().cpu().numpy()
125
+ identity={'source':storage.digest(chunk_path,'chunks'),'weights':storage.digest(path,'weights'),
126
+ 'shape':list(volume.shape),'halo':3,'device':device,'bf16':bf16,'tile':int(tile)}
127
+ out,tiling=tiled_predict(x,infer,storage,identity,side=int(tile),halo=3,
128
+ batch=budget.batch_size(volume.shape[0],int(tile)+6),resume=resume,cancelled=cancelled,
129
+ oom_type=torch.cuda.OutOfMemoryError)
130
  name=uuid.uuid4().hex
131
  target=storage.path('chunks',name+'.prediction.npz')
132
  np.savez_compressed(target,prediction=out,metadata=json.dumps({'request':metadata['request'],
133
  'checkpoint':str(path),'checkpoint_sha256':storage.digest(path,'weights'),'training':report,
134
+ 'kind':'ink_probability_unvalidated' if report['mode']=='ink_supervised' else 'reconstruction_not_ink',
135
+ 'performance':budget.report,'tiling':tiling,'geometry':metadata.get('geometry'),'input_representation':metadata.get('input_representation','raw_CT_projection_experimental'),
136
+ 'valid_mask':metadata.get('valid_mask'),'surface_kind':metadata.get('surface_kind'),
137
+ 'source_mesh_sha256':metadata.get('source_mesh_sha256')}))
138
+ return {'path':str(target),'kind':report['mode'],'held_out_evaluated':False,
139
+ 'performance':budget.report,'tiling':tiling}
src/ui.py CHANGED
@@ -5,6 +5,9 @@ import importlib.util
5
 
6
  def build_tab(controller, authorized):
7
  import gradio as gr
 
 
 
8
  with gr.Tab('Laboratoire Vesuvius 3D'):
9
  gr.Markdown('## Vesuvius — laboratoire de recherche\n'
10
  'Jobs manuels et isolés. Les modèles et les missions UNIQUE restent prioritaires. '
@@ -45,6 +48,8 @@ def build_tab(controller, authorized):
45
  unwrap=gr.Button('Construire le chart local')
46
  unwrap.click(lambda k,c,s:call('unwrap',{'chunk':c,'surface':s},k),[key,chunk,surface],status)
47
  mesh=gr.Textbox(label='Chemin du mesh .npz produit (/data/meshes/vesuvius/...)')
 
 
48
  plot=gr.Plot(label='Surface 3D — coordonnées du niveau choisi')
49
  preview=gr.Button('Afficher le mesh 3D')
50
  def show(secret,path):
@@ -72,7 +77,7 @@ def build_tab(controller, authorized):
72
  train.click(lambda k,c,m,ma,cp,s,d:call('train',{'chunk':c,'mode':m,'mask':ma,
73
  'checkpoint':cp,'steps':int(s),'device':d},k),[key,chunk,mode,mask,checkpoint,steps,device],status)
74
  predict=gr.Button('Prédire une autre ROI, disjointe')
75
- predict.click(lambda k,c,cp:call('predict',{'chunk':c,'checkpoint':cp},k),[key,chunk,checkpoint],status)
76
  with gr.Tab('Export de recherche'):
77
  prediction=gr.Textbox(label='Prédiction .prediction.npz correspondante (facultative ; sinon rendu CT)')
78
  pixels=gr.Dropdown([256,512,1024,2048],value=1024,label='Pixels de sortie (rééchantillonnage)')
@@ -82,6 +87,28 @@ def build_tab(controller, authorized):
82
  gr.Markdown('Le ZIP reste dans `/data/submissions/vesuvius/`. '
83
  'La conformité Grand Prize n’est pas certifiée : colonnes complètes, validation tenue à part '
84
  'et vérification VC3D restent nécessaires. Aucun envoi aux organisateurs.')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  halt=gr.Button('Arrêter le job du laboratoire')
86
  halt.click(lambda k:{'message':controller.stop()} if authorized(k) else {'error':'clé requise'},key,status)
87
  timer=gr.Timer(2)
 
5
 
6
  def build_tab(controller, authorized):
7
  import gradio as gr
8
+ from .autonomous import Supervisor
9
+ supervisor=Supervisor(controller)
10
+ controller.supervisor=supervisor
11
  with gr.Tab('Laboratoire Vesuvius 3D'):
12
  gr.Markdown('## Vesuvius — laboratoire de recherche\n'
13
  'Jobs manuels et isolés. Les modèles et les missions UNIQUE restent prioritaires. '
 
48
  unwrap=gr.Button('Construire le chart local')
49
  unwrap.click(lambda k,c,s:call('unwrap',{'chunk':c,'surface':s},k),[key,chunk,surface],status)
50
  mesh=gr.Textbox(label='Chemin du mesh .npz produit (/data/meshes/vesuvius/...)')
51
+ sample=gr.Button('Extraire 17 couches le long des normales du mesh')
52
+ sample.click(lambda k,m:call('surface',{'mesh':m,'layers':17},k),[key,mesh],status)
53
  plot=gr.Plot(label='Surface 3D — coordonnées du niveau choisi')
54
  preview=gr.Button('Afficher le mesh 3D')
55
  def show(secret,path):
 
77
  train.click(lambda k,c,m,ma,cp,s,d:call('train',{'chunk':c,'mode':m,'mask':ma,
78
  'checkpoint':cp,'steps':int(s),'device':d},k),[key,chunk,mode,mask,checkpoint,steps,device],status)
79
  predict=gr.Button('Prédire une autre ROI, disjointe')
80
+ predict.click(lambda k,c,cp,d:call('predict',{'chunk':c,'checkpoint':cp,'device':d},k),[key,chunk,checkpoint,device],status)
81
  with gr.Tab('Export de recherche'):
82
  prediction=gr.Textbox(label='Prédiction .prediction.npz correspondante (facultative ; sinon rendu CT)')
83
  pixels=gr.Dropdown([256,512,1024,2048],value=1024,label='Pixels de sortie (rééchantillonnage)')
 
87
  gr.Markdown('Le ZIP reste dans `/data/submissions/vesuvius/`. '
88
  'La conformité Grand Prize n’est pas certifiée : colonnes complètes, validation tenue à part '
89
  'et vérification VC3D restent nécessaires. Aucun envoi aux organisateurs.')
90
+ with gr.Tab('Validation et supervision'):
91
+ gr.Markdown('Les seuils se règlent sur calibration seulement. Les annotations de validation et les contrôles négatifs restent indépendants. Aucun score ne certifie la lisibilité.')
92
+ labels=gr.Textbox(label='Annotations binaires .npy sous /data/chunks/vesuvius')
93
+ negative_prediction=gr.Textbox(label='Prédiction d’un contrôle négatif indépendant .npz')
94
+ negative_labels=gr.Textbox(label='Annotations négatives .npy (absence d’encre documentée)')
95
+ role=gr.Dropdown(['calibration','validation'],value='validation',label='Rôle verrouillé dans le registre spatial')
96
+ evaluate=gr.Button('Évaluer Dice, précision, bruit simulé et contrôles')
97
+ evaluate.click(lambda k,p,l,c,np,nl,r:call('evaluate',{'prediction':p,'labels':l,'chunk':c,
98
+ 'negative_prediction':np or None,'negative_labels':nl or None,'role':r},k),
99
+ [key,prediction,labels,chunk,negative_prediction,negative_labels,role],status)
100
+ plan=gr.Textbox(label='Plan de supervision JSON sous /data/chunks/vesuvius',
101
+ value='/data/chunks/vesuvius/competition-plan.json')
102
+ continuous=gr.Button('Démarrer la file persistante')
103
+ supervision=gr.Button('État de la supervision')
104
+ def control(secret,path):
105
+ if not authorized(secret):return {'error':'clé requise'}
106
+ try:return {'message':supervisor.start(path),'supervision':supervisor.status()}
107
+ except Exception as exc:return {'error':str(exc)}
108
+ continuous.click(control,[key,plan],status)
109
+ supervision.click(lambda k:supervisor.status() if authorized(k) else {'error':'clé requise'},key,status)
110
+ stop_queue=gr.Button('Arrêter la supervision et le job actif')
111
+ stop_queue.click(lambda k:{'message':supervisor.stop()} if authorized(k) else {'error':'clé requise'},key,status)
112
  halt=gr.Button('Arrêter le job du laboratoire')
113
  halt.click(lambda k:{'message':controller.stop()} if authorized(k) else {'error':'clé requise'},key,status)
114
  timer=gr.Timer(2)
src/unwrapper.py CHANGED
@@ -55,10 +55,10 @@ def flatten(xyz, iterations=30):
55
  report={'method':'local_edge_length_least_squares','max_relative_stretch':float(np.max(np.abs(metric))),
56
  'rms_relative_stretch':float(np.sqrt(np.mean(metric**2))), 'folds':folds,
57
  'converged':bool(fit.success),'isometry_guaranteed':False,'window':[h,w]}
58
- return uv.astype('float32'),report
59
 
60
 
61
- def unwrap(storage, chunk_path, surface_path=None):
62
  import numpy as np
63
  from .loader import read_chunk
64
  volume,metadata=read_chunk(storage,chunk_path)
@@ -69,10 +69,16 @@ def unwrap(storage, chunk_path, surface_path=None):
69
  kind='provided_surface'
70
  else:
71
  xyz=candidate_surface(volume,metadata['request']['origin']);kind='unreviewed_gradient_candidate'
72
- uv,quality=flatten(xyz)
 
 
 
 
 
 
73
  if quality['folds']:raise ValueError('pli ou dégénérescence détectée dans le chart')
74
  artifact=storage.path('meshes',uuid.uuid4().hex+'.npz')
75
- np.savez_compressed(artifact,xyz=xyz.astype('float32'),uv=uv,
76
- metadata=json.dumps({'chunk':str(chunk_path),'request':metadata['request'],
77
- 'surface_kind':kind,'quality':quality}))
78
  return {'path':str(artifact),'quality':quality,'surface_kind':kind}
 
55
  report={'method':'local_edge_length_least_squares','max_relative_stretch':float(np.max(np.abs(metric))),
56
  'rms_relative_stretch':float(np.sqrt(np.mean(metric**2))), 'folds':folds,
57
  'converged':bool(fit.success),'isometry_guaranteed':False,'window':[h,w]}
58
+ return uv,report
59
 
60
 
61
+ def unwrap(storage, chunk_path, surface_path=None, iterations=30):
62
  import numpy as np
63
  from .loader import read_chunk
64
  volume,metadata=read_chunk(storage,chunk_path)
 
69
  kind='provided_surface'
70
  else:
71
  xyz=candidate_surface(volume,metadata['request']['origin']);kind='unreviewed_gradient_candidate'
72
+ geometry=metadata.get('geometry',{'scale_zyx_um':[metadata['request']['voxel_um']]*3,
73
+ 'translation_zyx_um':[0.,0.,0.],'verified':False})
74
+ physical=xyz.astype('float64')*np.array(geometry['scale_zyx_um'][::-1])+np.array(geometry['translation_zyx_um'][::-1])
75
+ uv,quality=flatten(physical,iterations=iterations)
76
+ from .spatial import validate_geometry
77
+ strict,_=validate_geometry(physical,uv)
78
+ quality.update(strict)
79
  if quality['folds']:raise ValueError('pli ou dégénérescence détectée dans le chart')
80
  artifact=storage.path('meshes',uuid.uuid4().hex+'.npz')
81
+ np.savez_compressed(artifact,xyz=xyz.astype('float64'),uv=uv,
82
+ metadata=json.dumps({'chunk':str(chunk_path),'source_chunk_sha256':storage.digest(chunk_path,'chunks'),'request':metadata['request'],
83
+ 'surface_kind':kind,'quality':quality,'geometry':geometry,'uv_units':'micrometres'}))
84
  return {'path':str(artifact),'quality':quality,'surface_kind':kind}
src/worker.py CHANGED
@@ -1,6 +1,7 @@
1
  """Entrée de calcul séparée du serveur UNIQUE ; requête et résultat sur /data."""
2
  import json
3
  import os
 
4
  from pathlib import Path
5
  import sys
6
  import traceback
@@ -8,7 +9,7 @@ import traceback
8
  from .storage import Storage
9
 
10
 
11
- def execute(storage, action, payload):
12
  if action=='load':
13
  from .loader import Loader
14
  return Loader(storage).load(payload)
@@ -18,17 +19,24 @@ def execute(storage, action, payload):
18
  return scan(read_chunk(storage,payload['chunk'])[0],payload.get('tile',16))
19
  if action=='unwrap':
20
  from .unwrapper import unwrap
21
- return unwrap(storage,payload['chunk'],payload.get('surface') or None)
 
 
 
22
  if action=='train':
23
  from .trainer import train
24
  return train(storage,payload['chunk'],payload.get('steps',4),payload.get('mode','reconstruction'),
25
- payload.get('mask') or None,payload.get('checkpoint') or None,payload.get('device','cpu'))
26
  if action=='predict':
27
  from .trainer import predict
28
- return predict(storage,payload['chunk'],payload['checkpoint'])
 
 
 
 
29
  if action=='export':
30
  from .exporter import export
31
- return export(storage,payload['mesh'],payload.get('prediction') or None,payload.get('pixels',1024))
32
  raise ValueError('action inconnue')
33
 
34
 
@@ -37,11 +45,19 @@ def main():
37
  storage.initialize()
38
  request=storage.checked(sys.argv[1],'chunks')
39
  data=json.loads(request.read_text())
 
 
 
40
  try:
41
  if hasattr(os,'nice'):os.nice(10)
42
- result={'ok':True,'result':execute(storage,data['action'],data['payload'])}
43
  except Exception as exc:
44
- result={'ok':False,'error':type(exc).__name__+': '+str(exc)[:1000]}
 
 
 
 
 
45
  traceback.print_exc()
46
  storage.json('chunks',request.stem+'.result.json',result)
47
  return 0 if result['ok'] else 1
 
1
  """Entrée de calcul séparée du serveur UNIQUE ; requête et résultat sur /data."""
2
  import json
3
  import os
4
+ import signal
5
  from pathlib import Path
6
  import sys
7
  import traceback
 
9
  from .storage import Storage
10
 
11
 
12
+ def execute(storage, action, payload, cancelled=lambda:False):
13
  if action=='load':
14
  from .loader import Loader
15
  return Loader(storage).load(payload)
 
19
  return scan(read_chunk(storage,payload['chunk'])[0],payload.get('tile',16))
20
  if action=='unwrap':
21
  from .unwrapper import unwrap
22
+ return unwrap(storage,payload['chunk'],payload.get('surface') or None,payload.get('iterations',30))
23
+ if action=='surface':
24
+ from .surface_volume import render
25
+ return render(storage,payload['mesh'],payload.get('layers',17))
26
  if action=='train':
27
  from .trainer import train
28
  return train(storage,payload['chunk'],payload.get('steps',4),payload.get('mode','reconstruction'),
29
+ payload.get('mask') or None,payload.get('checkpoint') or None,payload.get('device','cpu'),cancelled=cancelled,run_id=payload.get('_queue_id'))
30
  if action=='predict':
31
  from .trainer import predict
32
+ return predict(storage,payload['chunk'],payload['checkpoint'],payload.get('device','cpu'),
33
+ payload.get('tile',64),payload.get('resume'),cancelled=cancelled)
34
+ if action=='evaluate':
35
+ from .evaluation import evaluate
36
+ return evaluate(storage,**{k:v for k,v in payload.items() if not k.startswith('_')})
37
  if action=='export':
38
  from .exporter import export
39
+ return export(storage,payload['mesh'],payload.get('prediction') or None,payload.get('pixels',1024),payload.get('validation_report'))
40
  raise ValueError('action inconnue')
41
 
42
 
 
45
  storage.initialize()
46
  request=storage.checked(sys.argv[1],'chunks')
47
  data=json.loads(request.read_text())
48
+ flag=[False]
49
+ signal.signal(signal.SIGTERM,lambda *args:flag.__setitem__(0,True))
50
+ cancelled=lambda:flag[0] or request.with_suffix('.cancel').exists()
51
  try:
52
  if hasattr(os,'nice'):os.nice(10)
53
+ result={'ok':True,'result':execute(storage,data['action'],data['payload'],cancelled)}
54
  except Exception as exc:
55
+ message=type(exc).__name__+': '+str(exc)[:1000]
56
+ lowered=message.lower()
57
+ code=('preempted' if type(exc).__name__=='Preempted' else 'oom' if 'outofmemory' in lowered or 'oom' in lowered
58
+ else 'network' if 's3' in lowered or 'connect' in lowered else 'geometry' if any(w in lowered for w in ('triangle','chart','distorsion','pli'))
59
+ else 'unknown')
60
+ result={'ok':False,'error':message,'code':code,'resume':getattr(exc,'resume',None)}
61
  traceback.print_exc()
62
  storage.json('chunks',request.stem+'.result.json',result)
63
  return 0 if result['ok'] else 1
tests/test_competition.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import math
3
+ from pathlib import Path
4
+ import time
5
+
6
+ import numpy as np
7
+ import pytest
8
+
9
+ from test_vesuvius import storage,chunk,spec
10
+ from src.adaptive import tiled_predict,Preempted
11
+
12
+
13
+ def test_tiling_matches_full_model_with_halo(storage):
14
+ import torch
15
+ from src.trainer import build_model
16
+ torch.manual_seed(0);torch.set_num_threads(1);model=build_model().eval()
17
+ volume=np.random.default_rng(2).random((8,48,48),dtype='float32')
18
+ def infer(batch):
19
+ with torch.inference_mode():return model(torch.from_numpy(batch[:,None]))[:,0].numpy()
20
+ full=infer(volume[None])[0]
21
+ tiles,report=tiled_predict(volume,infer,storage,{'test':'halo'},side=16,batch=4)
22
+ np.testing.assert_allclose(tiles,full,atol=1e-6)
23
+ assert report['committed']==9
24
+
25
+
26
+ def test_oom_reduces_batches_then_subdivides_tiles(storage):
27
+ volume=np.ones((4,40,40),dtype='float32')
28
+ class FakeOOM(Exception):pass
29
+ def infer(batch):
30
+ if batch.shape[0]>1 or max(batch.shape[-2:])>22:raise FakeOOM()
31
+ return batch.mean(1)
32
+ result,report=tiled_predict(volume,infer,storage,{'test':'oom'},side=32,batch=4,oom_type=FakeOOM)
33
+ assert np.all(result==1) and report['oom_retries']>0
34
+
35
+
36
+ def test_tile_checkpoint_resumes_without_repeating_committed_work(storage):
37
+ volume=np.ones((4,32,32),dtype='float32');calls=[]
38
+ def infer(batch):calls.append(1);return batch.mean(1)
39
+ with pytest.raises(Preempted) as exc:
40
+ tiled_predict(volume,infer,storage,{'test':'resume'},side=16,cancelled=lambda:len(calls)==1)
41
+ assert len(calls)==1
42
+ output,state=tiled_predict(volume,infer,storage,{'test':'resume'},side=16,resume=exc.value.resume)
43
+ assert len(calls)==4 and np.all(output==1) and state['committed']==4
44
+
45
+
46
+ def test_optimizer_resume_matches_uninterrupted_training(storage):
47
+ from src.trainer import train
48
+ from safetensors.numpy import load_file
49
+ path=chunk(storage);full=train(storage,path,steps=4)
50
+ checks=[0]
51
+ def cancelled():checks[0]+=1;return checks[0]>2
52
+ with pytest.raises(Preempted) as exc:train(storage,path,steps=4,cancelled=cancelled)
53
+ resumed=train(storage,path,**exc.value.resume)
54
+ assert resumed['report']['global_step']==4
55
+ for key,value in load_file(full['path']).items():
56
+ np.testing.assert_array_equal(value,load_file(resumed['path'])[key])
57
+
58
+
59
+ def test_ngff_axes_units_and_translations_are_preserved():
60
+ from src.spatial import ngff_geometry
61
+ attrs={'multiscales':[{'axes':[{'name':a,'unit':'nanometer'} for a in 'zyx'],
62
+ 'datasets':[{'path':'0','coordinateTransformations':[{'type':'scale','scale':[2400]*3},
63
+ {'type':'translation','translation':[1000,2000,3000]}]}]}]}
64
+ g=ngff_geometry(attrs,'0',2.4)
65
+ assert g['verified'] and g['scale_zyx_um']==[2.4]*3 and g['translation_zyx_um']==[1,2,3]
66
+ with pytest.raises(ValueError,match='calibration'):ngff_geometry(attrs,'0',7.91)
67
+ attrs['multiscales'][0]['axes'][0]['name']='x'
68
+ with pytest.raises(ValueError,match='axes'):ngff_geometry(attrs,'0',2.4)
69
+
70
+
71
+ def test_unverified_calibration_is_explicit():
72
+ from src.spatial import ngff_geometry
73
+ assert not ngff_geometry({},'0',2.4)['verified']
74
+
75
+
76
+ def test_second_triangle_fold_is_rejected():
77
+ from src.spatial import validate_geometry
78
+ y,x=np.indices((2,2));xyz=np.stack([x,y,np.zeros_like(x)],-1)
79
+ uv=np.stack([x,y],-1).astype(float);uv[1,1]=[-1,-1]
80
+ with pytest.raises(ValueError,match='retournés'):validate_geometry(xyz,uv)
81
+
82
+
83
+ def test_raster_does_not_bridge_mask_holes_and_respects_aspect_ratio():
84
+ from src.spatial import rasterize
85
+ y,x=np.indices((5,7));xyz=np.stack([x,y,np.zeros_like(x)],-1).astype(float)
86
+ uv=xyz[...,:2];valid=np.ones((5,7),bool);valid[2,3]=False
87
+ image,report=rasterize(xyz,uv,x,64,valid)
88
+ assert report['shape'][1]==64 and report['shape'][0]<64
89
+ assert np.isnan(image[report['shape'][0]//2,32]).all()
90
+
91
+
92
+ def test_metrics_include_false_positives_and_do_not_reward_empty_labels():
93
+ from src.quality import ink_metrics
94
+ y=np.array([[1,0],[1,0]]);p=np.array([[.9,.8],[.1,.1]])
95
+ m=ink_metrics(p,y)
96
+ assert m['tp']==1 and m['fp']==1 and m['fn']==1
97
+ assert m['precision']==.5 and m['dice']==.5 and m['false_positive_rate']==.5
98
+ empty=ink_metrics(np.zeros((4,4)),np.zeros((4,4)))
99
+ assert empty['precision'] is None and empty['dice'] is None
100
+
101
+
102
+ @pytest.mark.parametrize('role',['validation','test','train'])
103
+ def test_threshold_cannot_be_optimized_on_held_out_data(role):
104
+ from src.quality import choose_threshold
105
+ with pytest.raises(ValueError):choose_threshold(np.zeros((2,2)),np.zeros((2,2)),role=role)
106
+
107
+
108
+ def test_scientific_acceptance_requires_independent_controls():
109
+ from src.quality import acceptance
110
+ report={'role':'validation','surface_conditioned':True,'provided_surface':True,'geometry_verified':True,'disjoint':True,'metrics':{'precision':1.,'dice':.9,'false_positive_rate':0.}}
111
+ assert not acceptance(report)['automatic_gate_passed']
112
+ report.update(negative_control_passed=True,noise_control_passed=True)
113
+ assert acceptance(report)['automatic_gate_passed']
114
+ assert not acceptance(report)['legibility_certified']
115
+
116
+
117
+ def test_noise_benchmark_is_seeded_and_marked_as_synthetic():
118
+ from src.quality import robustness
119
+ v=np.zeros((4,4,4),dtype='float32');labels=np.zeros((4,4))
120
+ a=robustness(v,lambda x:np.clip(x.mean(0),0,1),labels)
121
+ b=robustness(v,lambda x:np.clip(x.mean(0),0,1),labels)
122
+ assert a==b and not a['physical_scanner_noise_validated']
123
+
124
+
125
+ def test_dataset_split_leakage_is_rejected(storage):
126
+ from src.datasets import register
127
+ register(storage,spec(),'train')
128
+ with pytest.raises(ValueError,match='recouvrement'):register(storage,spec(),'validation')
129
+ register(storage,spec(origin=(8,0,0)),'validation')
130
+
131
+
132
+ def test_queue_idempotency_and_bounded_network_retries(storage):
133
+ from src.autonomous import Queue
134
+ queue=Queue(storage);task={'action':'scan','payload':{'chunk':'fixed'}}
135
+ assert queue.enqueue([task])==queue.enqueue([task]) and len(queue.records())==1
136
+ record=queue.records()[0];record['attempts']=1
137
+ queue.finish(record,{'status':'error','code':'network','error':'offline'},now=100)
138
+ assert record['state']=='queued' and record['due']==130
139
+ record['attempts']=5
140
+ queue.finish(record,{'status':'error','code':'network','error':'offline'},now=200)
141
+ assert record['state']=='quarantined'
142
+
143
+
144
+ def test_queue_recovers_running_jobs_and_preserves_resume_payload(storage):
145
+ from src.autonomous import Queue
146
+ queue=Queue(storage);queue.enqueue([{'action':'train','payload':{'chunk':'fixed','steps':4}}])
147
+ record=queue.records()[0];record.update(state='running',attempts=1);queue.save(record);queue.recover()
148
+ assert queue.records()[0]['state']=='queued'
149
+ queue.finish(record,{'status':'error','code':'preempted','resume':{'checkpoint':'fixed','steps':2}},now=100)
150
+ assert record['task']['payload']['steps']==2 and record['state']=='queued'
151
+
152
+
153
+ def test_named_plan_dependencies_and_cycles(storage):
154
+ from src.autonomous import Queue
155
+ queue=Queue(storage)
156
+ ids=queue.enqueue([{'name':'load','action':'load','payload':{}},
157
+ {'name':'scan','action':'scan','payload':{'chunk':'@load'}}])
158
+ records={r['id']:r for r in queue.records()};records[ids[0]].update(state='done',result={'path':'real.npz'})
159
+ assert queue.resolve(records[ids[1]]['task'],records)['chunk']=='real.npz'
160
+ with pytest.raises(ValueError,match='cycle'):
161
+ queue.enqueue([{'name':'a','action':'scan','payload':{'chunk':'@b'}},
162
+ {'name':'b','action':'scan','payload':{'chunk':'@a'}}])
163
+
164
+
165
+ def test_failed_quality_is_quarantined_not_relabelled(storage):
166
+ from src.autonomous import Queue
167
+ q=Queue(storage);q.enqueue([{'action':'evaluate','payload':{'labels':'immutable.npy'}}]);r=q.records()[0]
168
+ q.finish(r,{'status':'done','result':{'report':{'acceptance':{'automatic_gate_passed':False}}}})
169
+ assert r['state']=='quarantined' and r['task']['payload']['labels']=='immutable.npy'
170
+
171
+
172
+ def test_hard_restart_finds_committed_tile_checkpoint(storage):
173
+ calls=[]
174
+ def infer(x):calls.append(1);return x.mean(1)
175
+ volume=np.ones((4,32,32),dtype='float32')
176
+ with pytest.raises(Preempted):
177
+ tiled_predict(volume,infer,storage,{'hard':'restart'},side=16,cancelled=lambda:len(calls)==1)
178
+ tiled_predict(volume,infer,storage,{'hard':'restart'},side=16)
179
+ assert len(calls)==4
180
+
181
+
182
+ def test_hard_restart_training_recovers_run_pointer(storage):
183
+ from src.trainer import train
184
+ path=chunk(storage);checks=[0]
185
+ def cancel():checks[0]+=1;return checks[0]>1
186
+ with pytest.raises(Preempted):train(storage,path,steps=3,run_id='fixed',cancelled=cancel)
187
+ resumed=train(storage,path,steps=3,run_id='fixed')
188
+ assert resumed['report']['global_step']==3
189
+ cached=train(storage,path,steps=3,run_id='fixed')
190
+ assert cached['cached'] and cached['path']==resumed['path']
191
+
192
+
193
+ def test_frozen_input_mutation_is_rejected(storage):
194
+ from src.autonomous import Queue
195
+ q=Queue(storage);source=storage.path('chunks','fixed.npy');np.save(source,np.ones((4,4)))
196
+ q.enqueue([{'action':'evaluate','payload':{'labels':str(source)}}]);record=q.records()[0]
197
+ q.freeze_inputs(record,record['task']['payload'])
198
+ np.save(source,np.zeros((4,4)))
199
+ with pytest.raises(ValueError,match='modifiée'):q.freeze_inputs(record,record['task']['payload'])
200
+
201
+
202
+ def test_geometry_refinement_is_bounded_and_does_not_relax_thresholds(storage):
203
+ from src.autonomous import Queue
204
+ q=Queue(storage);q.enqueue([{'action':'unwrap','payload':{'chunk':'fixed','iterations':30}}]);r=q.records()[0]
205
+ r['attempts']=1;q.finish(r,{'status':'error','code':'geometry'},now=100)
206
+ assert r['state']=='queued' and r['task']['payload']['iterations']==60
207
+ r['attempts']=2;q.finish(r,{'status':'error','code':'geometry'},now=200)
208
+ assert r['state']=='quarantined'
209
+
210
+
211
+ def test_bad_dependency_is_rejected_instead_of_waiting_forever(storage):
212
+ from src.autonomous import Queue
213
+ q=Queue(storage)
214
+ with pytest.raises(ValueError,match='inconnue'):
215
+ q.enqueue([{'action':'scan','payload':{'chunk':'@missing'}}])
216
+
217
+
218
+ def test_quarantined_dependency_blocks_downstream_tasks(storage):
219
+ from src.autonomous import Queue
220
+ q=Queue(storage);ids=q.enqueue([{'name':'a','action':'scan','payload':{}},
221
+ {'name':'b','action':'scan','payload':{'chunk':'@a'}}])
222
+ records={r['id']:r for r in q.records()};records[ids[0]]['state']='quarantined'
223
+ with pytest.raises(ValueError,match='quarantaine'):q.resolve(records[ids[1]]['task'],records)
224
+
225
+
226
+ def test_surface_conditioning_preserves_normals_and_masks_invalid_contexts(storage):
227
+ from src.loader import Loader,request_spec,read_chunk
228
+ from src.unwrapper import unwrap
229
+ from src.surface_volume import render
230
+ import zarr
231
+ z,y,x=np.indices((24,24,24));array=zarr.array(z.astype('float32'),chunks=(8,8,8))
232
+ request=request_spec('s3://vesuvius-challenge-open-data/surface.zarr','0',[0,0,0],[24,24,24],2.4)
233
+ source=Loader(storage,opener=lambda s:array).load(request)['path']
234
+ yy,xx=np.indices((12,12),dtype=float)
235
+ points=np.stack([xx+6,yy+6,np.ones_like(xx)*12],axis=-1)
236
+ path=storage.path('meshes','reviewable.npy');np.save(path,points)
237
+ mesh=unwrap(storage,source,str(path));result=render(storage,mesh['path'],layers=7)
238
+ volume,metadata=read_chunk(storage,result['path'])
239
+ assert volume.shape==(7,12,12) and metadata['input_representation']=='surface_conditioned'
240
+ np.testing.assert_allclose(volume[:,6,6],np.arange(9,16))
241
+ assert np.asarray(metadata['valid_mask']).sum()==36
242
+
243
+
244
+ def test_calibrated_public_ngff_without_units_requires_explicit_catalogue():
245
+ from src.spatial import ngff_geometry
246
+ attrs={'multiscales':[{'axes':[{'name':a} for a in 'zyx'],
247
+ 'datasets':[{'path':'2','coordinateTransformations':[{'type':'scale','scale':[4,4,4]}]}]}]}
248
+ assert not ngff_geometry(attrs,'2',9.6)['verified']
249
+ verified=ngff_geometry(attrs,'2',9.6,{'base_voxel_um':2.4})
250
+ assert verified['verified'] and verified['scale_zyx_um']==[9.6]*3
251
+
252
+
253
+ def test_supervised_plan_has_disjoint_named_dependencies(storage):
254
+ from src.plan import build
255
+ from src.autonomous import Queue
256
+ regions={}
257
+ for index,role in enumerate(('train','calibration','validation','negative')):
258
+ points=storage.path('meshes',role+'.npy');labels=storage.path('chunks',role+'.npy')
259
+ yy,xx=np.indices((8,8),dtype=float)
260
+ np.save(points,np.stack([xx,yy,np.ones_like(xx)*3],-1));np.save(labels,np.zeros((8,8)))
261
+ regions[role]={'roi':spec(origin=(index*8,0,0)),'surface_xyz':str(points),'labels':str(labels)}
262
+ plan=build(storage,regions,device='cpu',steps=1)
263
+ assert not plan['official_submission_authorized']
264
+ assert len(Queue(storage).enqueue(plan['tasks']))==19
265
+ regions['validation']['roi']=regions['train']['roi']
266
+ with pytest.raises(ValueError,match='disjointes'):build(storage,regions)
267
+
268
+
269
+ def test_end_to_end_research_dag_abstains_instead_of_exporting_bad_ink(storage,monkeypatch):
270
+ import zarr
271
+ from src.loader import Loader,request_spec
272
+ from src.plan import build
273
+ from src.autonomous import Queue
274
+ from src.worker import execute
275
+ zz,yy,xx=np.indices((128,32,32));array=zarr.array((zz+yy+xx).astype('uint8'),chunks=(16,16,16))
276
+ monkeypatch.setattr(Loader,'open_array',staticmethod(lambda s:array))
277
+ regions={}
278
+ for index,role in enumerate(('train','calibration','validation','negative')):
279
+ y,x=np.indices((12,12),dtype=float)
280
+ xyz=storage.path('meshes',role+'.npy');labels=storage.path('chunks',role+'.npy')
281
+ np.save(xyz,np.stack([x+10,y+10,np.ones_like(x)*(index*32+16)],-1))
282
+ np.save(labels,np.zeros((12,12)))
283
+ roi=request_spec('s3://vesuvius-challenge-open-data/dag.zarr','0',[index*32,0,0],[32,32,32],2.4)
284
+ regions[role]={'roi':roi,'surface_xyz':str(xyz),'labels':str(labels)}
285
+ q=Queue(storage);q.enqueue(build(storage,regions,'cpu',1)['tasks'])
286
+ for _ in range(24):
287
+ records={r['id']:r for r in q.records()};progress=False
288
+ for record in records.values():
289
+ if record['state']!='queued':continue
290
+ try:payload=q.resolve(record['task'],records)
291
+ except LookupError:continue
292
+ except ValueError:
293
+ record['state']='quarantined';q.save(record);progress=True;continue
294
+ try:
295
+ result=execute(storage,record['task']['action'],payload)
296
+ q.finish(record,{'status':'done','result':result})
297
+ except ValueError as exc:
298
+ q.finish(record,{'status':'error','code':'invalid','error':str(exc)})
299
+ progress=True
300
+ if not progress:break
301
+ records=q.records()
302
+ calibration=next(r for r in records if r['task'].get('name')=='calibrate')
303
+ assert calibration['state']=='quarantined' and calibration['result']['report']['status']=='abstain'
304
+ assert next(r for r in records if r['task'].get('name')=='research_export')['state']=='quarantined'
305
+ assert not list(storage.folder('submissions').glob('*.zip'))
306
+
307
+
308
+ def test_surface_accuracy_requires_reference_and_respects_physical_scale():
309
+ from src.quality import surface_metrics
310
+ y,x=np.indices((4,4));reference=np.stack([x,y,np.zeros_like(x)],-1).astype(float)
311
+ predicted=reference.copy();predicted[...,2]+=1
312
+ report=surface_metrics(predicted,reference,(2.4,2.4,2.4))
313
+ assert report['chamfer_mean_um']==pytest.approx(2.4)
314
+ assert report['hausdorff_max_um']==pytest.approx(2.4)
315
+ with pytest.raises(ValueError):surface_metrics(np.empty((0,3)),reference)
unique_ext/__init__.py CHANGED
@@ -13,7 +13,7 @@ from __future__ import annotations
13
  import os
14
 
15
  API_VERSION = 1 # contrat entre le job d'entraînement et ce paquet : le job refuse une autre valeur
16
- __version__ = "4.14.0"
17
 
18
 
19
  def enabled(name: str) -> bool:
 
13
  import os
14
 
15
  API_VERSION = 1 # contrat entre le job d'entraînement et ce paquet : le job refuse une autre valeur
16
+ __version__ = "4.15.0"
17
 
18
 
19
  def enabled(name: str) -> bool: