app.py 68 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477
  1. import time
  2. import os
  3. import io
  4. import zipfile
  5. import shutil
  6. import json
  7. from typing import List, Tuple
  8. from PIL import Image
  9. import streamlit as st
  10. from matplotlib import pyplot as plt
  11. import base64
  12. import yaml
  13. from easydict import EasyDict
  14. import numpy as np
  15. import h5py
  16. from datetime import datetime
  17. from Project_Koma.koma_adapter import (
  18. is_koma_available,
  19. list_h5_files,
  20. list_seq_files,
  21. )
  22. # Интеграция с koma_scan для автозапуска контейнера и вызова сканирования
  23. from utils.koma_scan import (
  24. run_container,
  25. stop_container,
  26. scan_once,
  27. scan_and_reconstruct,
  28. )
  29. # Прямая реконструкция (with/without k-space sort)
  30. from Project_Koma.reconstruction import (
  31. process_hdf5_with_sort,
  32. process_hdf5_without_sort,
  33. )
  34. from flow_model.main import flow_model
  35. from utils.page_tse_nirsii import page_tse_nirsii, make_phantom_zip
  36. # ---------- Theme-aware logo (top-right, base64-embedded) ----------
  37. def _b64_img(path: str) -> str | None:
  38. try:
  39. with open(path, "rb") as f:
  40. return base64.b64encode(f.read()).decode("ascii")
  41. except Exception:
  42. return None
  43. def nav_to(p: str):
  44. st.session_state.page = p
  45. def header_with_theme_logo(title: str,
  46. light_path: str = "logos/NEW_PHYSTECH_for_light.png",
  47. dark_path: str = "logos/NEW_PHYSTECH_for_dark.png",
  48. size_px: int = 100):
  49. light_b64 = _b64_img(light_path)
  50. dark_b64 = _b64_img(dark_path)
  51. if not (light_b64 or dark_b64):
  52. # если нет логотипов — просто выводим заголовок
  53. st.markdown(f"## {title}")
  54. return
  55. light_src = f"data:image/png;base64,{light_b64}" if light_b64 else ""
  56. dark_src = f"data:image/png;base64,{dark_b64}" if dark_b64 else light_src
  57. html = f"""
  58. <style>
  59. /* убираем стандартный padding контейнера Streamlit сверху */
  60. section.main > div:first-child {{
  61. padding-top: 0rem;
  62. }}
  63. .hdr {{
  64. display: flex; align-items: center; justify-content: space-between;
  65. }}
  66. .hdr h1 {{
  67. margin: 0;
  68. font-size: 3.5rem;
  69. line-height: 1.2;
  70. }}
  71. .hdr .logo img {{
  72. width: 250px; height: {size_px}px; object-fit: contain;
  73. border-radius: 8px;
  74. display: inline-block;
  75. }}
  76. .hdr .logo img.light {{ display: inline-block; }}
  77. .hdr .logo img.dark {{ display: none; }}
  78. @media (prefers-color-scheme: dark) {{
  79. .hdr .logo img.light {{ display: none; }}
  80. .hdr .logo img.dark {{ display: inline-block; }}
  81. }}
  82. </style>
  83. <div class="hdr">
  84. <h1>{title}</h1>
  85. <div class="logo">
  86. <img src="{light_src}" alt="logo" class="light" />
  87. <img src="{dark_src}" alt="logo" class="dark" />
  88. </div>
  89. </div>
  90. """
  91. st.markdown(html, unsafe_allow_html=True)
  92. st.set_page_config(
  93. page_title="MRI physics based augmentation",
  94. page_icon="🧠",
  95. layout="wide"
  96. )
  97. header_with_theme_logo("MRI physics based augmentation")
  98. # ---------- Simple router in session_state ----------
  99. if "page" not in st.session_state:
  100. st.session_state.page = "home"
  101. # storage for generated phantom (appears after progress completes)
  102. if "phantom_blob" not in st.session_state:
  103. st.session_state.phantom_blob = None
  104. # ---------- Helpers: validation of uploaded files ----------
  105. def _validate_seq_bytes(filename: str, data: bytes, max_size_mb: int = 5) -> tuple[bool, str | None]:
  106. try:
  107. if len(data) == 0:
  108. return False, f"{filename}: empty file"
  109. if len(data) > max_size_mb * 1024 * 1024:
  110. return False, f"{filename}: file is too large (> {max_size_mb} MB)"
  111. # try decode as text
  112. _ = data.decode("utf-8", errors="ignore")
  113. return True, None
  114. except Exception as e:
  115. return False, f"{filename}: validation error: {e}"
  116. def _validate_kso_json_bytes(filename: str, data: bytes) -> tuple[bool, str | None]:
  117. try:
  118. obj = json.loads(data.decode("utf-8", errors="ignore"))
  119. # Accept two schemas observed in repo
  120. if isinstance(obj, dict) and "k_space_order" in obj:
  121. kso = obj["k_space_order"]
  122. if isinstance(kso, dict) and "k_space_order" in kso:
  123. kso = kso["k_space_order"]
  124. if isinstance(kso, list) and len(kso) > 0:
  125. return True, None
  126. return False, f"{filename}: missing or invalid 'k_space_order'"
  127. except Exception as e:
  128. return False, f"{filename}: invalid JSON ({e})"
  129. def _validate_phantom_h5_bytes(filename: str, data: bytes) -> tuple[bool, str | None]:
  130. try:
  131. with h5py.File(io.BytesIO(data), 'r') as hf:
  132. # find any dataset with non-zero size
  133. has_dataset = False
  134. def _walker(name, obj):
  135. nonlocal has_dataset
  136. try:
  137. if isinstance(obj, h5py.Dataset):
  138. if obj.size and obj.size > 0:
  139. has_dataset = True
  140. except Exception:
  141. pass
  142. hf.visititems(_walker)
  143. if not has_dataset:
  144. return False, f"{filename}: HDF5 contains no datasets"
  145. return True, None
  146. except Exception as e:
  147. return False, f"{filename}: invalid HDF5 ({e})"
  148. if "phantom_name" not in st.session_state:
  149. st.session_state.phantom_name = None
  150. PHANTOM_OUTPUT_PATH = "./flow_model/phantoms_h5"
  151. # ---------- CUDA / GPU helpers ----------
  152. def _cuda_status():
  153. try:
  154. import torch
  155. available = torch.cuda.is_available()
  156. devices = []
  157. if available:
  158. try:
  159. count = torch.cuda.device_count()
  160. for i in range(count):
  161. name = torch.cuda.get_device_name(i)
  162. cap = torch.cuda.get_device_capability(i)
  163. dev = torch.device(f"cuda:{i}")
  164. props = torch.cuda.get_device_properties(i)
  165. total_mem = getattr(props, 'total_memory', None)
  166. devices.append({
  167. "index": i,
  168. "name": name,
  169. "capability": f"{cap[0]}.{cap[1]}",
  170. "total_mem_gb": round((total_mem or 0) / (1024**3), 2),
  171. })
  172. except Exception:
  173. pass
  174. return {
  175. "torch_version": getattr(torch, "__version__", "unknown"),
  176. "cuda_available": available,
  177. "cuda_version": getattr(torch.version, "cuda", None),
  178. "devices": devices,
  179. }
  180. except Exception as e:
  181. return {"error": str(e)}
  182. def _gpu_self_test():
  183. import time
  184. import torch
  185. if not torch.cuda.is_available():
  186. return False, "CUDA недоступна (torch.cuda.is_available() == False)"
  187. try:
  188. dev = torch.device("cuda")
  189. a = torch.randn((1024, 1024), device=dev)
  190. b = torch.randn((1024, 1024), device=dev)
  191. torch.cuda.synchronize()
  192. t0 = time.perf_counter()
  193. c = a @ b
  194. torch.cuda.synchronize()
  195. dt = (time.perf_counter() - t0) * 1000.0
  196. _ = c.mean().item()
  197. return True, f"Успех: матр. умножение на GPU заняло ~{dt:.1f} мс"
  198. except Exception as e:
  199. return False, f"Ошибка вычислений на GPU: {e}"
  200. # ---------- Image helpers ----------
  201. # SUPPORTED_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff")
  202. # SUPPORTED_EXTS_PHANTOM = (".dcm", ".nii", ".nii.gz", ".nrrd", ".npy", ".png", ".jpg", ".jpeg")
  203. SUPPORTED_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff")
  204. # Must be a tuple; previously was a plain string and broke the file_uploader filter
  205. SUPPORTED_EXTS_PHANTOM = (".npy",)
  206. MAX_VALUE_DATASET = 100000
  207. SEQUENCE_PRESETS = {
  208. "Turbo Spin Echo (TSE)": [
  209. {"Параметр": "TR_ms", "Значение": 4000, "Комментарий": "Repetition time"},
  210. {"Параметр": "TE_ms", "Значение": 80, "Комментарий": "Контраст T2"},
  211. {"Параметр": "TurboFactor","Значение": 16, "Комментарий": "Echo train length"},
  212. {"Параметр": "FOV_mm", "Значение": 220, "Комментарий": ""},
  213. {"Параметр": "Slice_mm", "Значение": 3, "Комментарий": ""},
  214. ],
  215. "Gradient Echo (GRE)": [
  216. {"Параметр": "TR_ms", "Значение": 30, "Комментарий": "Короткий TR"},
  217. {"Параметр": "TE_ms", "Значение": 5, "Комментарий": ""},
  218. {"Параметр": "Flip_deg", "Значение": 15, "Комментарий": "Угол наклона"},
  219. {"Параметр": "FOV_mm", "Значение": 220, "Комментарий": ""},
  220. {"Параметр": "Slice_mm", "Значение": 3, "Комментарий": ""},
  221. ],
  222. "FLAIR": [
  223. {"Параметр": "TR_ms", "Значение": 9000, "Комментарий": "Длинный TR"},
  224. {"Параметр": "TE_ms", "Значение": 100, "Комментарий": ""},
  225. {"Параметр": "TI_ms", "Значение": 2500, "Комментарий": "Инверсия CSF"},
  226. {"Параметр": "FOV_mm", "Значение": 220, "Комментарий": ""},
  227. {"Параметр": "Slice_mm", "Значение": 4, "Комментарий": ""},
  228. ],
  229. }
  230. # ---- Заглушка: границы параметров по последовательностям ----
  231. # Позже заменишь на import из своего модуля, например:
  232. # from my_bounds_provider import fetch_param_bounds
  233. def fetch_param_bounds():
  234. # Формат:
  235. # { "SEQ_NAME": { "ParamKey": (min, max), ... }, ... }
  236. return {
  237. "Turbo Spin Echo (TSE)": {
  238. "TR_ms": (500, 12000),
  239. "TE_ms": (10, 300),
  240. "TurboFactor": (2, 64),
  241. "FOV_mm": (100, 300),
  242. "Slice_mm": (1, 10),
  243. # "Matrix" без числовых границ — оставляем строкой
  244. },
  245. "Gradient Echo (GRE)": {
  246. "TR_ms": (5, 200),
  247. "TE_ms": (2, 40),
  248. "Flip_deg": (1, 90),
  249. "FOV_mm": (100, 300),
  250. "Slice_mm": (1, 10),
  251. },
  252. "FLAIR": {
  253. "TR_ms": (4000, 15000),
  254. "TE_ms": (50, 300),
  255. "TI_ms": (800, 3500),
  256. "FOV_mm": (100, 300),
  257. "Slice_mm": (1, 10),
  258. },
  259. }
  260. def as_str(x):
  261. return "" if x is None else str(x)
  262. def stringify_columns(rows, cols=("Значение", "Мин", "Макс")):
  263. """Преобразует указанные колонки во всех строках в строки (для data_editor)."""
  264. out = []
  265. for r in rows:
  266. rr = dict(r)
  267. for c in cols:
  268. if c in rr:
  269. rr[c] = as_str(rr[c])
  270. out.append(rr)
  271. return out
  272. NUMERIC_KEYS = {"TR_ms", "TE_ms", "TI_ms", "FOV_mm", "Slice_mm", "TurboFactor", "Flip_deg"}
  273. def to_kv_dict(rows):
  274. """Список строк таблицы -> dict {param: value} для генератора."""
  275. out = {}
  276. for r in rows:
  277. k = str(r.get("Параметр", "")).strip()
  278. if not k:
  279. continue
  280. out[k] = r.get("Значение", None)
  281. return out
  282. def try_number(x):
  283. """Пытаемся привести введённое значение к float (для числовых ключей)."""
  284. try:
  285. if x is None or x == "":
  286. return None
  287. return float(x)
  288. except Exception:
  289. return x # оставить как есть (строка)
  290. def markdown_table(rows, columns):
  291. """Рендер простой таблицы (без pandas) через Markdown."""
  292. if not rows:
  293. st.write("No data.")
  294. return
  295. header = "| " + " | ".join(columns) + " |"
  296. sep = "| " + " | ".join(["---"] * len(columns)) + " |"
  297. lines = [header, sep]
  298. for r in rows:
  299. line = "| " + " | ".join(str(r.get(col, "")) for col in columns) + " |"
  300. lines.append(line)
  301. st.markdown("\n".join(lines))
  302. def center_crop_to_square(img: Image.Image) -> Image.Image:
  303. """Center-crop PIL image to a square based on the smaller side."""
  304. w, h = img.size
  305. s = min(w, h)
  306. left = (w - s) // 2
  307. top = (h - s) // 2
  308. return img.crop((left, top, left + s, top + s))
  309. def load_and_prepare_assets(asset_dir: str = "assets", count: int = 3, size: Tuple[int, int] = (320, 320)) -> List[Tuple[Image.Image, str]]:
  310. """Load up to `count` images from asset_dir, center-crop to square, resize to `size`."""
  311. results = []
  312. if not os.path.isdir(asset_dir):
  313. return results
  314. files = sorted([f for f in os.listdir(asset_dir) if os.path.splitext(f.lower())[1] in SUPPORTED_EXTS])
  315. for fname in files[:count]:
  316. path = os.path.join(asset_dir, fname)
  317. try:
  318. img = Image.open(path).convert("RGB")
  319. img = center_crop_to_square(img)
  320. img = img.resize(size, Image.LANCZOS)
  321. results.append((img, fname))
  322. except Exception:
  323. continue
  324. return results
  325. def run_job_stub(status_placeholder, progress_placeholder, steps=None, delay=0.9):
  326. """Simulate a long-running job with progress and 3-line status stream.
  327. Returns True when finished."""
  328. if steps is None:
  329. steps = [
  330. "Инициализация пайплайна...",
  331. "Обработка входных данных...",
  332. "Генерация синтетических изображений...",
  333. "Постобработка результатов...",
  334. "Готово!",
  335. ]
  336. progress = 0
  337. last3 = []
  338. progress_placeholder.progress(progress, text="Waiting to start...")
  339. status_placeholder.markdown("")
  340. for i, msg in enumerate(steps, 1):
  341. last3.append(msg)
  342. last3 = last3[-3:] # keep only the last 3
  343. # Newest at the top for the "falls down" feel:
  344. lines = []
  345. for idx, line in enumerate(reversed(last3)):
  346. if idx == 0:
  347. lines.append(f"- **{line}**")
  348. elif idx == 1:
  349. lines.append(f"- <span style='opacity:0.7'>{line}</span>")
  350. else:
  351. lines.append(f"- <span style='opacity:0.45'>{line}</span>")
  352. status_placeholder.markdown("<br/>".join(lines), unsafe_allow_html=True)
  353. progress = int(i * 100 / len(steps))
  354. progress_placeholder.progress(progress, text=f"Progress: {progress}%")
  355. time.sleep(delay)
  356. return True
  357. def make_demo_zip() -> bytes:
  358. """Create a demo ZIP to download as 'phantom' result."""
  359. buf = io.BytesIO()
  360. with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
  361. zf.writestr("phantom/README.txt", "Demo phantom result. Replace with real generated files.")
  362. buf.seek(0)
  363. return buf.getvalue()
  364. # ---------- Pages ----------
  365. def page_home():
  366. st.header("What does this app do")
  367. st.markdown(
  368. """
  369. This is an interactive app for physics‑based MRI data augmentation:
  370. - Phantom generation: using uploaded 2D T1/T2/PD maps, the app builds a volumetric phantom and saves it as an .h5 file (folder `flow_model/phantoms_h5`).
  371. - Pulse sequence generation (TSE): based on the parameters table, it builds a grid and creates `.seq` files plus auxiliary `.json` files (folder `sequences/<set_name>`). The "Number of sequences" field shows how many files will be created.
  372. - KOMA simulation: the selected phantom and sequences are sent to the KOMA simulator; raw data (`download/rawdata`) and reconstructed images (`download/images`) are saved and available for download.
  373. Quick start:
  374. 1) Open "Phantom generation", upload T1/T2/PD maps (.npy, 2D 128×128), then click "Begin generation".
  375. 2) Go to "Sequence dataset generation", adjust ranges in the table and generate sequences.
  376. 3) Choose a phantom and sequences, start the scan and download the results.
  377. """
  378. )
  379. # Load 3 prepared images from assets
  380. images = load_and_prepare_assets("assets", count=3, size=(320, 320))
  381. if images:
  382. cols = st.columns(len(images))
  383. for (img, name), col in zip(images, cols):
  384. with col:
  385. st.image(img, use_container_width=False)
  386. # st.image(img, caption=name, use_container_width=False)
  387. else:
  388. st.info("Place 1–3 images into the `assets/` folder (png/jpg/tif), and they will appear here with the same size.")
  389. st.markdown("---")
  390. c1, c2 = st.columns(2)
  391. with c1:
  392. with st.container(border=True):
  393. st.markdown("#### 🧠 Phantom generation")
  394. st.write("Upload T1/T2/PD images and begin the generation.")
  395. st.button("Move to the phantom", type="primary", use_container_width=True, on_click=nav_to, args=("phantom",))
  396. with c2:
  397. with st.container(border=True):
  398. st.markdown("#### 📦 Sequence dataset generation")
  399. st.write("Generation of a dataset based on pulse sequence parameters.")
  400. st.button("Move to the dataset", type="primary", use_container_width=True, on_click=nav_to, args=("dataset",))
  401. def page_phantom():
  402. st.button("← Homepage", on_click=nav_to, args=("home",))
  403. st.subheader("Generate the phantom")
  404. st.caption("Please upload 2D T1/T2/PD TSE images of brain")
  405. c1, c2, c3 = st.columns(3)
  406. with c1:
  407. t1_file = st.file_uploader("T1", type=SUPPORTED_EXTS_PHANTOM)
  408. # T1 preview
  409. if t1_file is not None:
  410. try:
  411. _t1 = np.load(io.BytesIO(t1_file.getvalue()))
  412. if _t1.ndim == 2:
  413. # нормализация в [0,1] для корректного отображения
  414. arr = _t1.astype(np.float32)
  415. if np.any(np.isfinite(arr)):
  416. arr = np.nan_to_num(arr, nan=np.nanmin(arr) if np.isnan(arr).any() else 0.0)
  417. minv = float(np.min(arr))
  418. maxv = float(np.max(arr))
  419. if maxv > minv:
  420. arr = (arr - minv) / (maxv - minv)
  421. else:
  422. arr = np.zeros_like(arr, dtype=np.float32)
  423. else:
  424. arr = np.zeros_like(arr, dtype=np.float32)
  425. st.caption(f"T1 shape: {tuple(_t1.shape)}")
  426. st.image(arr, clamp=True, use_container_width=True)
  427. else:
  428. st.warning(f"Expected a 2D array for T1, got an array with {_t1.ndim} dimensions.")
  429. except Exception as _e:
  430. st.warning(f"Failed to preview T1: {_e}")
  431. with c2:
  432. t2_file = st.file_uploader("T2", type=SUPPORTED_EXTS_PHANTOM)
  433. # T2 preview
  434. if t2_file is not None:
  435. try:
  436. _t2 = np.load(io.BytesIO(t2_file.getvalue()))
  437. if _t2.ndim == 2:
  438. arr = _t2.astype(np.float32)
  439. if np.any(np.isfinite(arr)):
  440. arr = np.nan_to_num(arr, nan=np.nanmin(arr) if np.isnan(arr).any() else 0.0)
  441. minv = float(np.min(arr))
  442. maxv = float(np.max(arr))
  443. if maxv > minv:
  444. arr = (arr - minv) / (maxv - minv)
  445. else:
  446. arr = np.zeros_like(arr, dtype=np.float32)
  447. else:
  448. arr = np.zeros_like(arr, dtype=np.float32)
  449. st.caption(f"T2 shape: {tuple(_t2.shape)}")
  450. st.image(arr, clamp=True, use_container_width=True)
  451. else:
  452. st.warning(f"Expected a 2D array for T2, got an array with {_t2.ndim} dimensions.")
  453. except Exception as _e:
  454. st.warning(f"Failed to preview T2: {_e}")
  455. with c3:
  456. pd_file = st.file_uploader("PD", type=SUPPORTED_EXTS_PHANTOM)
  457. # PD preview
  458. if pd_file is not None:
  459. try:
  460. _pd = np.load(io.BytesIO(pd_file.getvalue()))
  461. if _pd.ndim == 2:
  462. arr = _pd.astype(np.float32)
  463. if np.any(np.isfinite(arr)):
  464. arr = np.nan_to_num(arr, nan=np.nanmin(arr) if np.isnan(arr).any() else 0.0)
  465. minv = float(np.min(arr))
  466. maxv = float(np.max(arr))
  467. if maxv > minv:
  468. arr = (arr - minv) / (maxv - minv)
  469. else:
  470. arr = np.zeros_like(arr, dtype=np.float32)
  471. else:
  472. arr = np.zeros_like(arr, dtype=np.float32)
  473. st.caption(f"PD shape: {tuple(_pd.shape)}")
  474. st.image(arr, clamp=True, use_container_width=True)
  475. else:
  476. st.warning(f"Expected a 2D array for PD, got an array with {_pd.ndim} dimensions.")
  477. except Exception as _e:
  478. st.warning(f"Failed to preview PD: {_e}")
  479. start_btn = st.button("Begin generation", type="primary")
  480. progress_ph = st.empty()
  481. statuses_ph = st.empty()
  482. with open('upload/upload_cfg.yaml') as f:
  483. upload_cfg = yaml.load(f, Loader=yaml.FullLoader)
  484. upload_cfg = EasyDict(upload_cfg)
  485. if start_btn:
  486. # If user provided all 3 modality maps, combine them into a single weighted input
  487. if t1_file and t2_file and pd_file:
  488. try:
  489. # Read npy from uploaded buffers
  490. t1 = np.load(io.BytesIO(t1_file.getvalue()))
  491. t2 = np.load(io.BytesIO(t2_file.getvalue()))
  492. pd = np.load(io.BytesIO(pd_file.getvalue()))
  493. # Basic validation
  494. if t1.ndim != 2 or t2.ndim != 2 or pd.ndim != 2:
  495. st.error("Each of the T1/T2/PD files must be a 2D array (128x128).")
  496. return
  497. if not (t1.shape == t2.shape == pd.shape):
  498. st.error(f"Shapes do not match: T1 {t1.shape}, T2 {t2.shape}, PD {pd.shape}.")
  499. return
  500. # Enforce exact expected size
  501. if t1.shape != (128, 128):
  502. st.error(f"Expected shape (128, 128) for each map, got {t1.shape}.")
  503. return
  504. # Check numeric/finite values
  505. for name, arr in [("T1", t1), ("T2", t2), ("PD", pd)]:
  506. if not np.issubdtype(arr.dtype, np.number):
  507. st.error(f"{name} must contain numeric values, got dtype {arr.dtype}.")
  508. return
  509. if not np.isfinite(arr).any():
  510. st.error(f"{name} contains no finite values.")
  511. return
  512. # Stack into 3-channel weighted image (H, W, 3)
  513. weighted_3_ch = np.array([t1, t2, pd]).transpose(1, 2, 0).astype(np.float32)
  514. st.info(f"Prepared input (H, W, C) = {weighted_3_ch.shape}")
  515. # Save to the expected upload path for the model dataset
  516. save_dir = os.path.join('upload', 'weighted')
  517. os.makedirs(save_dir, exist_ok=True)
  518. # Unique name per upload
  519. ts = int(time.time())
  520. save_path = os.path.join(save_dir, f'user_upload_{ts}.npy')
  521. np.save(save_path, weighted_3_ch)
  522. # Run model in upload mode (rootB fixed in upload_cfg)
  523. pref = st.session_state.get("device_pref", "Авто (CUDA если доступна)")
  524. pref_map = {
  525. "Авто (CUDA если доступна)": None,
  526. "CPU": 'cpu',
  527. "CUDA": 'cuda',
  528. }
  529. flow_model(upload_cfg, mode='upload', device_pref=pref_map.get(pref))
  530. done = run_job_stub(statuses_ph, progress_ph)
  531. if done:
  532. result_dir = PHANTOM_OUTPUT_PATH
  533. st.session_state.phantom_blob = make_phantom_zip(result_dir)
  534. st.session_state.phantom_name = "phantom_result.zip"
  535. st.success("Done! You can download the result.")
  536. except Exception as e:
  537. st.exception(e)
  538. else:
  539. # Fallback to pre-uploaded sample if user didn't provide all three files
  540. st.error("You must upload all three files: T1, T2, PD.")
  541. st.error("A pre-uploaded sample will be used")
  542. pref = st.session_state.get("device_pref", "Авто (CUDA если доступна)")
  543. pref_map = {
  544. "Авто (CUDA если доступна)": None,
  545. "CPU": 'cpu',
  546. "CUDA": 'cuda',
  547. }
  548. flow_model(upload_cfg, mode='upload', device_pref=pref_map.get(pref))
  549. done = run_job_stub(statuses_ph, progress_ph)
  550. if done:
  551. result_dir = PHANTOM_OUTPUT_PATH
  552. st.session_state.phantom_blob = make_phantom_zip(result_dir)
  553. st.session_state.phantom_name = "phantom_result.zip"
  554. st.success("Done! You can download the result.")
  555. # The download button appears only when phantom_blob is present (after job completes)
  556. if st.session_state.get("phantom_blob"):
  557. st.download_button(
  558. "Download phantom",
  559. data=st.session_state.phantom_blob,
  560. file_name=st.session_state.get("phantom_name", "phantom_result.zip"),
  561. mime="application/zip",
  562. use_container_width=False,
  563. type="primary",
  564. )
  565. # --- Предпросмотр сгенерированного фантома (.h5) ---
  566. def _normalize01(a: np.ndarray) -> np.ndarray:
  567. a = a.astype(np.float32)
  568. a = np.nan_to_num(a, nan=np.nanmin(a) if np.isnan(a).any() else 0.0)
  569. minv = float(np.min(a))
  570. maxv = float(np.max(a))
  571. if maxv > minv:
  572. return (a - minv) / (maxv - minv)
  573. return np.zeros_like(a, dtype=np.float32)
  574. def _to_viridis_rgb(slice2d: np.ndarray) -> np.ndarray:
  575. """
  576. 2D массив -> цветное изображение в тех же цветах,
  577. что и plt.imshow(slice2d) с colormap='viridis'.
  578. Возвращает RGB uint8 (H, W, 3).
  579. """
  580. norm = _normalize01(slice2d) # [0,1]
  581. cmap = plt.get_cmap() # как в matplotlib по умолчанию
  582. rgba = cmap(norm) # (H, W, 4), float32, 0..1
  583. rgb = rgba[..., :3] # отбрасываем альфу
  584. rgb_uint8 = (rgb * 255).astype("uint8") # (H, W, 3) uint8
  585. return rgb_uint8
  586. def _extract_phantom_slices(h5_path: str) -> tuple[np.ndarray | None, list[np.ndarray]]:
  587. """
  588. Читает HDF5-фантом как в примере:
  589. root -> first group -> first dataset -> phantom_data (H, W, Nslice)
  590. Возвращает:
  591. - полный объем phantom_data (или None при ошибке)
  592. - список до 4 нормированных срезов phantom_data[:, :, ph]
  593. """
  594. try:
  595. with h5py.File(h5_path, "r") as f:
  596. keys_lvl1 = list(f.keys())
  597. if not keys_lvl1:
  598. return None, []
  599. g = f[keys_lvl1[0]]
  600. # если сразу dataset
  601. if isinstance(g, h5py.Dataset):
  602. phantom_data = np.array(g)
  603. else:
  604. # предполагаем группу и берём первый dataset внутри
  605. keys_lvl2 = list(g.keys())
  606. if not keys_lvl2:
  607. return None, []
  608. ds = g[keys_lvl2[0]]
  609. phantom_data = np.array(ds)
  610. if phantom_data.ndim != 3:
  611. # не тот формат
  612. return None, []
  613. n_slices = phantom_data.shape[-1]
  614. max_show = min(4, n_slices)
  615. slices = []
  616. for ph in range(max_show):
  617. sl = phantom_data[:, :, ph]
  618. slices.append(_normalize01(sl))
  619. return phantom_data, slices
  620. except Exception:
  621. return None, []
  622. def _list_h5(dir_path: str) -> list[str]:
  623. if not os.path.isdir(dir_path):
  624. return []
  625. return [os.path.join(dir_path, f) for f in os.listdir(dir_path) if f.lower().endswith('.h5')]
  626. def _latest_h5(dir_path: str) -> str | None:
  627. files = _list_h5(dir_path)
  628. if not files:
  629. return None
  630. files.sort(key=lambda p: os.path.getmtime(p), reverse=True)
  631. return files[0]
  632. st.divider()
  633. st.markdown("### Phantom preview")
  634. def _list_h5(dir_path: str) -> list[str]:
  635. if not os.path.isdir(dir_path):
  636. return []
  637. return [
  638. os.path.join(dir_path, f)
  639. for f in os.listdir(dir_path)
  640. if f.lower().endswith(".h5")
  641. ]
  642. def _latest_h5(dir_path: str) -> str | None:
  643. files = _list_h5(dir_path)
  644. if not files:
  645. return None
  646. files.sort(key=lambda p: os.path.getmtime(p), reverse=True)
  647. return files[0]
  648. # --- Автоматический предпросмотр самого последнего фантома ---
  649. latest = _latest_h5(PHANTOM_OUTPUT_PATH)
  650. if latest:
  651. st.caption(f"Latest phantom: {os.path.basename(latest)}")
  652. volume, slices = _extract_phantom_slices(latest)
  653. flag = False
  654. if slices:
  655. # 2x2 сетка с первыми четырьмя срезами
  656. st.markdown("#### Phantom reconstruction")
  657. cols = st.columns(3)
  658. for i, img in enumerate(slices): # slices — это сами срезы phantom_data[:, :, ph]
  659. if i==1:
  660. flag = True
  661. continue
  662. if flag:
  663. i=i-1
  664. with cols[i]:
  665. st.image(
  666. _to_viridis_rgb(img),
  667. use_container_width=True,
  668. )
  669. else:
  670. st.info("Cannot extract 3D dataset for preview from selected .h5 phantom.")
  671. else:
  672. st.info("No phantom .h5 files found. Generate a phantom first to see the preview.")
  673. def page_dataset():
  674. page_tse_nirsii()
  675. # Выбор последовательностей .seq — из всех подпапок каталога "sequences"
  676. seq_dir = "sequences"
  677. # Блок управления директориями (последовательности и фантомы)
  678. with st.container(border=True):
  679. st.markdown("#### Data management")
  680. # Инициализация состояния показа панели очистки/загрузки
  681. if "show_seq_clear" not in st.session_state:
  682. st.session_state.show_seq_clear = False
  683. if "show_seq_upload" not in st.session_state:
  684. st.session_state.show_seq_upload = False
  685. # Делаем две компактные кнопки в правой части строки: Upload и Clear
  686. c_left, c_right_u, c_right_c = st.columns([8, 1, 1])
  687. with c_right_u:
  688. if st.button("Upload", key="seq_upload_btn", type="secondary",
  689. help="Upload files into the 'sequences' folder"):
  690. st.session_state.show_seq_upload = True
  691. with c_right_c:
  692. if st.button("Clear", key="seq_clear_btn", type="secondary", help="Clear the entire 'sequences' folder"):
  693. st.session_state.show_seq_clear = True
  694. with c_left:
  695. st.caption("You can clear the sequences folder. Optionally, download a backup before deletion.")
  696. # Панель загрузки файлов в папку sequences
  697. if st.session_state.show_seq_upload:
  698. with st.expander("Upload sequences — select files", expanded=True):
  699. uploaded_seq_files = st.file_uploader(
  700. "Choose .seq and related .json files",
  701. type=["seq", "json"],
  702. accept_multiple_files=True,
  703. key="seq_upload_files",
  704. )
  705. save_seq_now = st.button("Save files to 'sequences'", key="seq_save_now", type="primary")
  706. if save_seq_now:
  707. if not uploaded_seq_files:
  708. st.warning("No files selected for upload.")
  709. else:
  710. try:
  711. os.makedirs(seq_dir, exist_ok=True)
  712. saved, skipped = [], []
  713. for uf in uploaded_seq_files:
  714. fname = uf.name
  715. data = uf.getbuffer()
  716. ok = False
  717. reason = None
  718. if fname.lower().endswith('.seq'):
  719. ok, reason = _validate_seq_bytes(fname, bytes(data))
  720. elif fname.lower().endswith('.json'):
  721. ok, reason = _validate_kso_json_bytes(fname, bytes(data))
  722. else:
  723. ok, reason = False, f"{fname}: unsupported type"
  724. if ok:
  725. out_path = os.path.join(seq_dir, fname)
  726. with open(out_path, "wb") as f:
  727. f.write(data)
  728. saved.append(fname)
  729. else:
  730. skipped.append(reason or fname)
  731. if saved:
  732. st.success("Saved: " + ", ".join(saved))
  733. if skipped:
  734. st.warning("Skipped: " + "; ".join(skipped))
  735. if saved:
  736. st.session_state.show_seq_upload = False
  737. st.rerun()
  738. except Exception as e:
  739. st.error(f"Error while saving files: {e}")
  740. if st.session_state.show_seq_clear:
  741. with st.expander("Clear sequences folder — confirmation", expanded=True):
  742. # Ask whether to download a backup archive before clearing
  743. need_backup = st.radio(
  744. "Download all files from ‘sequences’ before clearing?",
  745. options=["Yes", "No"],
  746. index=0,
  747. horizontal=True,
  748. key="seq_clear_backup_choice",
  749. )
  750. # Подготовка ZIP при выборе «Да»
  751. zip_bytes: bytes | None = None
  752. zip_name = None
  753. if need_backup == "Yes":
  754. try:
  755. buf = io.BytesIO()
  756. with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
  757. for root, _dirs, files in os.walk(seq_dir):
  758. for f in files:
  759. full_path = os.path.join(root, f)
  760. # относительный путь внутри архива
  761. arcname = os.path.relpath(full_path, start=os.path.dirname(seq_dir))
  762. zf.write(full_path, arcname=arcname)
  763. buf.seek(0)
  764. zip_bytes = buf.read()
  765. ts = int(time.time())
  766. zip_name = f"sequences_backup_{ts}.zip"
  767. except Exception as e:
  768. st.warning(f"Failed to prepare backup archive: {e}")
  769. zip_bytes = None
  770. if zip_bytes is not None and zip_name is not None:
  771. st.download_button(
  772. "Download backup (ZIP)",
  773. data=zip_bytes,
  774. file_name=zip_name,
  775. mime="application/zip",
  776. use_container_width=True,
  777. key="dl_seq_backup_zip",
  778. )
  779. confirm = st.checkbox("I understand that all files in ‘sequences’ will be permanently deleted",
  780. key="seq_confirm")
  781. delete_now = st.button("Delete now", type="primary", key="seq_delete_now")
  782. if delete_now:
  783. if not confirm:
  784. st.error("You must check the confirmation box to proceed with deletion.")
  785. else:
  786. try:
  787. # Удаляем папку целиком и пересоздаём
  788. if os.path.isdir(seq_dir):
  789. shutil.rmtree(seq_dir, ignore_errors=True)
  790. os.makedirs(seq_dir, exist_ok=True)
  791. st.success("The ‘sequences’ folder has been cleared.")
  792. # Скрыть панель и перезапустить, чтобы обновить список
  793. st.session_state.show_seq_clear = False
  794. st.rerun()
  795. except Exception as e:
  796. st.error(f"Error while clearing the folder: {e}")
  797. st.markdown("---")
  798. # Инициализация состояния показа панели очистки/загрузки фантомов
  799. if "show_ph_clear" not in st.session_state:
  800. st.session_state.show_ph_clear = False
  801. if "show_ph_upload" not in st.session_state:
  802. st.session_state.show_ph_upload = False
  803. c_left_ph, c_right_ph_u, c_right_ph_c = st.columns([8, 1, 1])
  804. with c_right_ph_u:
  805. if st.button("Upload", key="ph_upload_btn", type="secondary",
  806. help="Upload phantom .h5 files into the folder"):
  807. st.session_state.show_ph_upload = True
  808. with c_right_ph_c:
  809. if st.button("Clear", key="ph_clear_btn", type="secondary", help="Clear the entire 'phantoms' folder"):
  810. st.session_state.show_ph_clear = True
  811. with c_left_ph:
  812. st.caption("You can clear the phantoms folder. Optionally, download a backup before deletion.")
  813. # Панель загрузки фантомов
  814. if st.session_state.show_ph_upload:
  815. with st.expander("Upload phantoms — select .h5 files", expanded=True):
  816. uploaded_ph_files = st.file_uploader(
  817. "Choose phantom files (.h5)",
  818. type=["h5"],
  819. accept_multiple_files=True,
  820. key="ph_upload_files",
  821. )
  822. save_ph_now = st.button("Save files to 'phantoms'", key="ph_save_now", type="primary")
  823. if save_ph_now:
  824. if not uploaded_ph_files:
  825. st.warning("No files selected for upload.")
  826. else:
  827. try:
  828. os.makedirs(PHANTOM_OUTPUT_PATH, exist_ok=True)
  829. saved, skipped = [], []
  830. for uf in uploaded_ph_files:
  831. fname = uf.name
  832. data = uf.getbuffer()
  833. ok, reason = _validate_phantom_h5_bytes(fname, bytes(data))
  834. if ok:
  835. out_path = os.path.join(PHANTOM_OUTPUT_PATH, fname)
  836. with open(out_path, "wb") as f:
  837. f.write(data)
  838. saved.append(fname)
  839. else:
  840. skipped.append(reason or fname)
  841. if saved:
  842. st.success("Saved: " + ", ".join(saved))
  843. if skipped:
  844. st.warning("Skipped: " + "; ".join(skipped))
  845. if saved:
  846. st.session_state.show_ph_upload = False
  847. st.rerun()
  848. except Exception as e:
  849. st.error(f"Error while saving phantom files: {e}")
  850. if st.session_state.show_ph_clear:
  851. with st.expander("Clear phantoms folder — confirmation", expanded=True):
  852. need_backup_ph = st.radio(
  853. "Download all files from ‘phantoms’ before clearing?",
  854. options=["Yes", "No"],
  855. index=0,
  856. horizontal=True,
  857. key="ph_clear_backup_choice",
  858. )
  859. ph_zip_bytes: bytes | None = None
  860. ph_zip_name = None
  861. if need_backup_ph == "Yes":
  862. try:
  863. buf = io.BytesIO()
  864. with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
  865. for root, _dirs, files in os.walk(PHANTOM_OUTPUT_PATH):
  866. for f in files:
  867. full_path = os.path.join(root, f)
  868. arcname = os.path.relpath(full_path, start=os.path.dirname(PHANTOM_OUTPUT_PATH))
  869. zf.write(full_path, arcname=arcname)
  870. buf.seek(0)
  871. ph_zip_bytes = buf.read()
  872. ts = int(time.time())
  873. ph_zip_name = f"phantoms_backup_{ts}.zip"
  874. except Exception as e:
  875. st.warning(f"Failed to prepare backup archive: {e}")
  876. ph_zip_bytes = None
  877. if ph_zip_bytes is not None and ph_zip_name is not None:
  878. st.download_button(
  879. "Download backup (ZIP)",
  880. data=ph_zip_bytes,
  881. file_name=ph_zip_name,
  882. mime="application/zip",
  883. use_container_width=True,
  884. key="dl_ph_backup_zip",
  885. )
  886. ph_confirm = st.checkbox("I understand that all files in ‘phantoms’ will be permanently deleted",
  887. key="ph_confirm")
  888. ph_delete_now = st.button("Delete now", type="primary", key="ph_delete_now")
  889. if ph_delete_now:
  890. if not ph_confirm:
  891. st.error("You must check the confirmation box to proceed with deletion.")
  892. else:
  893. try:
  894. if os.path.isdir(PHANTOM_OUTPUT_PATH):
  895. shutil.rmtree(PHANTOM_OUTPUT_PATH, ignore_errors=True)
  896. os.makedirs(PHANTOM_OUTPUT_PATH, exist_ok=True)
  897. st.success("The ‘phantoms’ folder has been cleared.")
  898. st.session_state.show_ph_clear = False
  899. st.rerun()
  900. except Exception as e:
  901. st.error(f"Error while clearing the folder: {e}")
  902. # После возможной очистки — перечитываем список файлов
  903. seq_files = list_seq_files(seq_dir)
  904. st.markdown("---")
  905. st.markdown("### Koma MRI simulator")
  906. # Выбор фантома (.h5) для отправки в KOMA
  907. phantom_dir = PHANTOM_OUTPUT_PATH
  908. phantom_h5_list = list_h5_files(phantom_dir)
  909. phantom_label_map = {os.path.basename(p): p for p in phantom_h5_list}
  910. if not phantom_h5_list:
  911. st.info("Directory with phantoms is empty. Generate phantoms first on the page back.")
  912. return
  913. phantom_choice = st.selectbox(
  914. "Choose phantom to scan",
  915. options=list(phantom_label_map.keys()),
  916. index=0,
  917. key="koma_phantom_choice",
  918. )
  919. # Показываем относительные пути, чтобы было видно подпапки и избежать коллизий имен
  920. seq_label_map = {os.path.relpath(p, start=seq_dir): p for p in seq_files}
  921. if not seq_files:
  922. st.info("Sequence directory is empty. Generate them first on this page.")
  923. return
  924. seq_choices = st.multiselect(
  925. "Choose pulse sequences",
  926. options=list(seq_label_map.keys()),
  927. default=list(seq_label_map.keys())[:1],
  928. key="koma_seq_choices",
  929. )
  930. # Инициализация persist-состояния для сохранения результатов между перерисовками
  931. if "koma_last_results" not in st.session_state:
  932. # список элементов: {seq_label, seq_path, ks_order_path, raw_out}
  933. st.session_state.koma_last_results = []
  934. if "koma_batch_zip" not in st.session_state:
  935. # dict: {bytes, name}
  936. st.session_state.koma_batch_zip = None
  937. # Флаг запроса остановки текущего сканирования
  938. if "koma_stop_requested" not in st.session_state:
  939. st.session_state.koma_stop_requested = False
  940. # Вспомогательная функция: очистка выходных директорий перед новым сканом
  941. def _clear_output_dirs(dirs: list[str], status_cb=None):
  942. for d in dirs:
  943. try:
  944. if os.path.isdir(d):
  945. shutil.rmtree(d, ignore_errors=True)
  946. os.makedirs(d, exist_ok=True)
  947. if status_cb:
  948. status_cb.info(f"Cleared folder: {d}")
  949. except Exception as e:
  950. if status_cb:
  951. status_cb.warning(f"Failed to clear {d}: {e}")
  952. # Группа кнопок запуска сканирования и статус в одном боксе
  953. with st.container(border=True):
  954. c_run, c_status = st.columns([2, 3])
  955. with c_run:
  956. run_koma = st.button("Start scan", type="primary", use_container_width=True)
  957. run_all = st.button("Scan all phantoms", use_container_width=True)
  958. stop_pressed = st.button("Stop the scan", use_container_width=True)
  959. if stop_pressed:
  960. st.session_state.koma_stop_requested = True
  961. # Опция: использовать GPU для контейнера KOMA (docker --gpus all)
  962. if "koma_use_gpu" not in st.session_state:
  963. st.session_state.koma_use_gpu = False
  964. st.session_state.koma_use_gpu = st.checkbox(
  965. "Use GPU for KOMA container (--gpus all)", value=st.session_state.koma_use_gpu,
  966. help="Требуется установленный NVIDIA Container Toolkit и CUDA-совместимый образ KOMA"
  967. )
  968. with c_status:
  969. status_box = st.empty()
  970. # Локальная утилита ожидания готовности сервиса после старта контейнера
  971. def _wait_for_koma_ready(status_cb, timeout_sec: float = 60.0, poll_sec: float = 0.5) -> bool:
  972. start_t = time.time()
  973. last_update = -1
  974. while time.time() - start_t < timeout_sec:
  975. if is_koma_available():
  976. status_cb.success("KOMA service is reachable. Proceeding to scan…")
  977. return True
  978. # обновляем индикатор раз в ~1 секунду
  979. elapsed = int(time.time() - start_t)
  980. if elapsed != last_update:
  981. remaining = int(timeout_sec - (time.time() - start_t))
  982. status_cb.info(f"Waiting for KOMA to become ready… {elapsed}s elapsed (≤ {int(timeout_sec)}s)")
  983. last_update = elapsed
  984. time.sleep(poll_sec)
  985. return False
  986. if run_koma:
  987. status_box.info("Preparing KOMA simulator and sending data…")
  988. phantom_path = phantom_label_map[phantom_choice]
  989. # Quick pre-check phantom file sanity
  990. try:
  991. with h5py.File(phantom_path, 'r') as _hf:
  992. pass
  993. except Exception as _e:
  994. status_box.error(f"Selected phantom is not a valid .h5: {phantom_path}. Error: {_e}")
  995. st.stop()
  996. if not seq_choices:
  997. status_box.warning("Please choose at least one sequence to scan.")
  998. st.stop()
  999. # Принудительно очищаем выходные папки перед новым запуском одиночного сканирования
  1000. raw_dir = os.path.join("download", "rawdata")
  1001. images_dir = os.path.join("download", "images")
  1002. _clear_output_dirs([raw_dir, images_dir], status_cb=status_box)
  1003. # Сбрасываем предыдущие результаты, чтобы не отображать превью удалённых файлов
  1004. st.session_state.koma_last_results = []
  1005. try:
  1006. # Автозапуск контейнера только если сервис недоступен
  1007. started_by_us = False
  1008. if not is_koma_available():
  1009. status_box.info("KOMA is not reachable — starting container…")
  1010. rc = run_container(use_gpu=bool(st.session_state.get("koma_use_gpu", False)))
  1011. if rc != 0:
  1012. # Возможно, контейнер уже запущен другим процессом — перепроверим доступность
  1013. if not is_koma_available():
  1014. status_box.error("Failed to start KOMA container: check if the Docker engine is running")
  1015. st.stop()
  1016. else:
  1017. started_by_us = True
  1018. # Дождаться, пока HTTP-сервис поднимется
  1019. if not _wait_for_koma_ready(status_box):
  1020. status_box.error("KOMA did not become ready in time after start. Aborting scan.")
  1021. # Если контейнер запускали мы — останавливаем
  1022. try:
  1023. stop_container()
  1024. except Exception:
  1025. pass
  1026. st.stop()
  1027. else:
  1028. status_box.info("KOMA is already running — will not start a new container.")
  1029. # Сканирование по всем выбранным последовательностям в рамках одного запуска контейнера
  1030. new_results = []
  1031. # Директория для сохранения реконструированных изображений
  1032. os.makedirs(images_dir, exist_ok=True)
  1033. for i, seq_label in enumerate(seq_choices):
  1034. # Проверка запроса остановки перед началом следующей последовательности
  1035. if st.session_state.get("koma_stop_requested"):
  1036. status_box.warning("Stop requested. Finishing current cycle and shutting down container…")
  1037. break
  1038. seq_path = seq_label_map[seq_label]
  1039. seq_base = os.path.splitext(os.path.basename(seq_path))[0]
  1040. ph_base = os.path.splitext(os.path.basename(phantom_path))[0]
  1041. # Добавляем метку времени, чтобы исключить перезапись файлов между запусками
  1042. ts_str = datetime.now().strftime('%Y%m%d_%H%M%S_%f')
  1043. raw_name = f"raw_{ph_base}_{seq_base}_{ts_str}.h5"
  1044. raw_out = os.path.join(raw_dir, raw_name)
  1045. # Путь к JSON порядка укладки k-space рядом с выбранной последовательностью
  1046. ks_order_path = os.path.splitext(seq_path)[0] + "_k_space_order_filing.json"
  1047. status_box.info("Scanning of the set number: "+str(i+1))
  1048. ok, err = scan_once(phantom_path, seq_path, raw_out)
  1049. if not ok:
  1050. status_box.error(f"Scan error for {seq_base}: {err}")
  1051. continue
  1052. status_box.success(f"Raw data saved: {raw_out}")
  1053. # Реконструкция предпросмотра (with/without sort)
  1054. if os.path.isfile(ks_order_path):
  1055. _, img = process_hdf5_with_sort(raw_out, ks_order_path, plot=False)
  1056. else:
  1057. img = process_hdf5_without_sort(raw_out, plot=False)
  1058. # Нормализация пригодится при последующем отображении в постоянном блоке,
  1059. # но здесь больше не рендерим превью и кнопки, чтобы избежать дублирования
  1060. # текущих и предыдущих результатов на странице. Отрисовка выполняется
  1061. # только в блоке «Previous scan results» ниже.
  1062. _ = float(np.min(img)) # no-op to ensure img was computed without errors
  1063. # Сохраняем реконструкцию как .npy, чтобы кнопка "Download all images (ZIP)"
  1064. # включала результаты одиночного сканирования по всем выбранным ИП
  1065. img_fname = f"{ph_base}_{seq_base}_{ts_str}.npy"
  1066. try:
  1067. np.save(os.path.join(images_dir, img_fname), img)
  1068. except Exception as _save_err:
  1069. # Не прерываем весь цикл, просто сообщаем статус
  1070. status_box.warning(f"Failed to save image for {seq_base}: {_save_err}")
  1071. # Сохраняем результат для повторного показа после rerun/навигации
  1072. new_results.append({
  1073. "seq_label": seq_label,
  1074. "seq_path": seq_path,
  1075. "ks_order_path": ks_order_path,
  1076. "raw_out": raw_out,
  1077. })
  1078. # Перезаписываем «последние результаты» с текущего запуска
  1079. if new_results:
  1080. st.session_state.koma_last_results = new_results
  1081. except Exception as e:
  1082. status_box.error(f"KOMA error: {e}")
  1083. finally:
  1084. # Останавливаем контейнер только если запускали сами
  1085. try:
  1086. if 'started_by_us' in locals() and started_by_us:
  1087. stop_container()
  1088. # Сбрасываем флаг остановки после завершения
  1089. if st.session_state.get("koma_stop_requested"):
  1090. st.session_state.koma_stop_requested = False
  1091. except Exception:
  1092. pass
  1093. # Пакетное сканирование всех фантомов
  1094. if run_all:
  1095. status_box.info("Batch: preparing KOMA container and scanning all phantoms…")
  1096. if not seq_choices:
  1097. status_box.warning("Please choose at least one sequence to scan.")
  1098. st.stop()
  1099. # Принудительно очищаем выходные папки перед пакетным сканированием
  1100. raw_dir_all = os.path.join("download", "rawdata")
  1101. images_dir_all = os.path.join("download", "images")
  1102. _clear_output_dirs([raw_dir_all, images_dir_all], status_cb=status_box)
  1103. # Также сбросим предыдущие результаты предпросмотра
  1104. st.session_state.koma_last_results = []
  1105. try:
  1106. # Автозапуск контейнера только если сервис недоступен
  1107. started_by_us = False
  1108. if not is_koma_available():
  1109. status_box.info("KOMA is not reachable — starting container…")
  1110. rc = run_container(use_gpu=bool(st.session_state.get("koma_use_gpu", False)))
  1111. if rc != 0:
  1112. if not is_koma_available():
  1113. status_box.error("Failed to start KOMA container: check if the Docker engine is running")
  1114. st.stop()
  1115. else:
  1116. started_by_us = True
  1117. if not _wait_for_koma_ready(status_box):
  1118. status_box.error("KOMA did not become ready in time after start. Aborting batch scan.")
  1119. try:
  1120. stop_container()
  1121. except Exception:
  1122. pass
  1123. st.stop()
  1124. else:
  1125. status_box.info("KOMA is already running — will not start a new container.")
  1126. # запуск пакетного сканирования для каждой выбранной последовательности
  1127. for seq_label in seq_choices:
  1128. if st.session_state.get("koma_stop_requested"):
  1129. status_box.warning("Stop requested. Aborting batch after current stage and shutting down container…")
  1130. break
  1131. seq_path = seq_label_map[seq_label]
  1132. seq_dir_abs = os.path.dirname(seq_path)
  1133. seq_basename = os.path.splitext(os.path.basename(seq_path))[0]
  1134. scan_and_reconstruct(
  1135. phantoms_dir=PHANTOM_OUTPUT_PATH,
  1136. seq_dir=seq_dir_abs,
  1137. seq_basename=seq_basename,
  1138. raw_dir=os.path.join("download", "rawdata"),
  1139. img_dir=os.path.join("download", "images"),
  1140. ks_dir=os.path.join("download", "k_space"),
  1141. plot=False,
  1142. )
  1143. status_box.success("Batch scan finished for all selected sequences. Files saved to download/rawdata, download/images and download/k_space")
  1144. # Подготовка ZIP-архива со всеми результатами. Кнопку скачивания
  1145. # в этом же рендер-проходе не показываем, чтобы избежать дублирования
  1146. # с постоянным блоком ниже. Кнопка будет отрисована из session_state.
  1147. try:
  1148. buf = io.BytesIO()
  1149. with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
  1150. base_dirs = [
  1151. os.path.join("download", "rawdata"),
  1152. os.path.join("download", "images"),
  1153. os.path.join("download", "k_space"),
  1154. ]
  1155. for bdir in base_dirs:
  1156. if not os.path.isdir(bdir):
  1157. continue
  1158. for root, _dirs, files in os.walk(bdir):
  1159. for f in files:
  1160. full_path = os.path.join(root, f)
  1161. # относительный путь внутри архива — от папки download/
  1162. arcname = os.path.relpath(full_path, start="download")
  1163. zf.write(full_path, arcname=arcname)
  1164. buf.seek(0)
  1165. zip_bytes = buf.read()
  1166. ts = int(time.time())
  1167. zip_name = f"koma_batch_results_{ts}.zip"
  1168. # Сохраняем ZIP в состоянии; кнопка будет выведена в постоянном блоке ниже
  1169. st.session_state.koma_batch_zip = {"bytes": zip_bytes, "name": zip_name}
  1170. except Exception as e:
  1171. st.warning(f"Failed to prepare ZIP with all results: {e}")
  1172. except Exception as e:
  1173. status_box.error(f"Batch KOMA error: {e}")
  1174. finally:
  1175. try:
  1176. if 'started_by_us' in locals() and started_by_us:
  1177. stop_container()
  1178. if st.session_state.get("koma_stop_requested"):
  1179. st.session_state.koma_stop_requested = False
  1180. except Exception:
  1181. pass
  1182. # -------- Постоянный рендер результатов (сохраняется между кликами/навигацией) --------
  1183. if st.session_state.koma_last_results:
  1184. st.markdown("---")
  1185. st.markdown("#### Previous scan results")
  1186. for res in st.session_state.koma_last_results:
  1187. seq_label = res.get("seq_label")
  1188. raw_out = res.get("raw_out")
  1189. ks_order_path = res.get("ks_order_path")
  1190. if not raw_out or not os.path.isfile(raw_out):
  1191. # файл могли удалить — пропускаем
  1192. continue
  1193. # Пытаемся построить изображение предпросмотра на лету
  1194. try:
  1195. if ks_order_path and os.path.isfile(ks_order_path):
  1196. _, img = process_hdf5_with_sort(raw_out, ks_order_path, plot=False)
  1197. else:
  1198. img = process_hdf5_without_sort(raw_out, plot=False)
  1199. vmin, vmax = float(np.min(img)), float(np.max(img))
  1200. img_disp = (img - vmin) / (vmax - vmin) if vmax > vmin else img
  1201. except Exception:
  1202. img_disp = None
  1203. img = None
  1204. seq_base = os.path.splitext(os.path.basename(res.get("seq_path", ""))) [0]
  1205. col_img2, col_ctrl2 = st.columns([1, 2], gap="small")
  1206. with col_img2:
  1207. if img_disp is not None:
  1208. st.image(img_disp, caption=f"Preview: {seq_label}", use_container_width=True, clamp=True)
  1209. else:
  1210. st.caption(f"Preview unavailable for {seq_label}")
  1211. with col_ctrl2:
  1212. st.download_button(
  1213. f"Download raw (.h5): {seq_base}",
  1214. data=open(raw_out, 'rb').read(),
  1215. file_name=os.path.basename(raw_out),
  1216. mime="application/octet-stream",
  1217. key=f"dl_raw_prev_{os.path.splitext(os.path.basename(raw_out))[0]}",
  1218. use_container_width=True,
  1219. )
  1220. # Дополнительные кнопки скачивания реконструированного изображения рядом с превью
  1221. # Формируем базовое имя файла изображения из имени raw (убираем префикс raw_)
  1222. raw_base = os.path.splitext(os.path.basename(raw_out))[0]
  1223. img_base = raw_base[4:] if raw_base.startswith("raw_") else raw_base
  1224. # Кнопка скачать .npy (текущее реконструированное изображение)
  1225. if img is not None:
  1226. try:
  1227. npy_buf = io.BytesIO()
  1228. np.save(npy_buf, img)
  1229. npy_buf.seek(0)
  1230. st.download_button(
  1231. f"Download image (.npy): {seq_base}",
  1232. data=npy_buf.getvalue(),
  1233. file_name=f"{img_base}.npy",
  1234. mime="application/octet-stream",
  1235. key=f"dl_img_npy_{raw_base}",
  1236. use_container_width=True,
  1237. )
  1238. except Exception:
  1239. pass
  1240. # Кнопка скачать .png (нормализованное изображение 0..255)
  1241. try:
  1242. # Безопасная нормализация для PNG
  1243. vmin2, vmax2 = float(np.min(img)), float(np.max(img))
  1244. if vmax2 > vmin2:
  1245. img_norm = (img - vmin2) / (vmax2 - vmin2)
  1246. else:
  1247. img_norm = np.zeros_like(img, dtype=np.float32)
  1248. img_uint8 = (np.clip(img_norm, 0, 1) * 255).astype(np.uint8)
  1249. png_img = Image.fromarray(img_uint8)
  1250. png_buf = io.BytesIO()
  1251. png_img.save(png_buf, format="PNG")
  1252. png_buf.seek(0)
  1253. st.download_button(
  1254. f"Download image (.png): {seq_base}",
  1255. data=png_buf.getvalue(),
  1256. file_name=f"{img_base}.png",
  1257. mime="image/png",
  1258. key=f"dl_img_png_{raw_base}",
  1259. use_container_width=True,
  1260. )
  1261. except Exception:
  1262. pass
  1263. # Кнопка «скачать всё» сохранённая после batch-сканирования
  1264. if st.session_state.koma_batch_zip:
  1265. st.markdown("---")
  1266. st.download_button(
  1267. "Download all results (ZIP)",
  1268. data=st.session_state.koma_batch_zip["bytes"],
  1269. file_name=st.session_state.koma_batch_zip["name"],
  1270. mime="application/zip",
  1271. use_container_width=True,
  1272. key="dl_koma_batch_zip_saved",
  1273. )
  1274. # Дополнительно: отдельные кнопки «скачать все изображения» и «скачать весь k_space»
  1275. # Кнопки доступны независимо от пакетного режима, если в соответствующих папках есть файлы
  1276. images_dir = os.path.join("download", "images")
  1277. kspace_dir = os.path.join("download", "rawdata")
  1278. def _zip_dir_bytes(base_dir: str, arc_base: str) -> tuple[bytes | None, str | None]:
  1279. try:
  1280. if not os.path.isdir(base_dir):
  1281. return None, None
  1282. has_files = any(
  1283. f for _r, _d, fs in os.walk(base_dir) for f in fs
  1284. )
  1285. if not has_files:
  1286. return None, None
  1287. buf = io.BytesIO()
  1288. with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
  1289. for root, _dirs, files in os.walk(base_dir):
  1290. for f in files:
  1291. full_path = os.path.join(root, f)
  1292. # сохраняем структуру относительно корня download/
  1293. arcname = os.path.relpath(full_path, start="download")
  1294. # Вложим в архив, сохраняя структуру относительно корня download/
  1295. zf.write(full_path, arcname=arcname)
  1296. buf.seek(0)
  1297. ts = int(time.time())
  1298. name = f"{arc_base}_{ts}.zip"
  1299. return buf.read(), name
  1300. except Exception:
  1301. return None, None
  1302. # Собираем ZIP для изображений
  1303. img_zip_bytes, img_zip_name = _zip_dir_bytes(images_dir, "koma_images")
  1304. # Собираем ZIP для k-space
  1305. ks_zip_bytes, ks_zip_name = _zip_dir_bytes(kspace_dir, "koma_k_space")
  1306. if img_zip_bytes or ks_zip_bytes:
  1307. st.markdown("---")
  1308. cols_zip = st.columns(2)
  1309. with cols_zip[0]:
  1310. if img_zip_bytes and img_zip_name:
  1311. st.download_button(
  1312. "Download all images (ZIP)",
  1313. data=img_zip_bytes,
  1314. file_name=img_zip_name,
  1315. mime="application/zip",
  1316. use_container_width=True,
  1317. key="dl_koma_all_images_zip",
  1318. )
  1319. else:
  1320. st.caption("No images found in download/images")
  1321. with cols_zip[1]:
  1322. if ks_zip_bytes and ks_zip_name:
  1323. st.download_button(
  1324. "Download all k-spaces (ZIP)",
  1325. data=ks_zip_bytes,
  1326. file_name=ks_zip_name,
  1327. mime="application/zip",
  1328. use_container_width=True,
  1329. key="dl_koma_all_kspace_zip",
  1330. )
  1331. else:
  1332. st.caption("No k-spaces found in download/k_space")
  1333. # ---------- Router ----------
  1334. if st.session_state.page == "home":
  1335. page_home()
  1336. elif st.session_state.page == "phantom":
  1337. page_phantom()
  1338. elif st.session_state.page == "dataset":
  1339. page_dataset()
  1340. else:
  1341. st.session_state.page = "home"
  1342. page_home()