scanning_tab.py 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196
  1. """
  2. ScanningTab - clinical MRI scanner UI simulation with real scan initiation.
  3. Panels:
  4. - Left: scrollable protocol queue (ProtocolListWidget)
  5. - Centre: 3 MRI image viewers (MriViewerWidget x 3)
  6. - Bottom: QTabWidget with parameter tabs; "Геометрия" tab holds rotation controls
  7. Rotation matrix (3x3, ZYX Euler) is merged into the seq_info dict and sent
  8. to the orchestrator's start_measurement step as "rotation_matrix".
  9. """
  10. from __future__ import annotations
  11. import math
  12. import json
  13. import os
  14. import numpy as np
  15. from PySide6.QtCore import Qt, QThread, QTimer, Signal
  16. from PySide6.QtGui import (
  17. QColor, QFont, QImage, QLinearGradient, QPainter, QPen, QPolygonF,
  18. )
  19. from PySide6.QtCore import QPointF
  20. from PySide6.QtWidgets import (
  21. QButtonGroup, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QFrame,
  22. QGridLayout, QGroupBox, QHBoxLayout, QLabel, QListWidget, QMessageBox,
  23. QPushButton, QSplitter, QTabWidget, QTextEdit, QVBoxLayout, QWidget,
  24. )
  25. try:
  26. import httpx as _httpx
  27. _HAS_HTTPX = True
  28. except ImportError:
  29. _HAS_HTTPX = False
  30. from src import i18n
  31. # -- colour palette -------------------------------------------------------------
  32. _BG_DARK = "#1a1a2e"
  33. _PANEL_BG = "#2a2a2a"
  34. _IMAGE_BG = "#000000"
  35. _ACCENT = "#f0c040"
  36. _ACCENT_DIM = "#555533"
  37. _ORANGE_SEL = "#e65100"
  38. _BTN_BG = "#252535"
  39. # -- animation -----------------------------------------------------------------
  40. _SCAN_LINE_INTERVAL_MS = 40 # 25 fps
  41. _SCAN_SWEEP_DURATION = 120 # ticks per vertical sweep
  42. # -- mouse interaction ---------------------------------------------------------
  43. _ROT_SENSITIVITY = 0.4 # degrees per pixel for Ctrl+drag rotation
  44. # -- default orientations (Rx, Ry, Rz in degrees) ------------------------------
  45. _PRESETS = {
  46. "Axial": (0.0, 0.0, 0.0),
  47. "Coronal": (90.0, 0.0, 0.0),
  48. "Sagittal": (0.0, 90.0, 0.0),
  49. }
  50. _PROTOCOLS = [
  51. "FID", "SE", "TSE"
  52. ]
  53. # Physical axes for each viewer plane: (horizontal_axis, vertical_axis)
  54. # X=0, Y=1, Z=2 in physical space; Y is flipped to screen coords in paintEvent
  55. _VIEWER_AXES: dict[str, tuple[int, int]] = {
  56. "Axial": (0, 1),
  57. "Cor": (0, 2),
  58. "Sag": (1, 2),
  59. }
  60. # ==============================================================================
  61. def _euler_to_matrix(rx_deg: float, ry_deg: float, rz_deg: float) -> list:
  62. """ZYX Euler angles (degrees) -> 3x3 rotation matrix as list-of-lists."""
  63. rx = math.radians(rx_deg)
  64. ry = math.radians(ry_deg)
  65. rz = math.radians(rz_deg)
  66. cx, sx = math.cos(rx), math.sin(rx)
  67. cy, sy = math.cos(ry), math.sin(ry)
  68. cz, sz = math.cos(rz), math.sin(rz)
  69. Rx = np.array([[1, 0, 0 ],
  70. [0, cx, -sx],
  71. [0, sx, cx]])
  72. Ry = np.array([[ cy, 0, sy],
  73. [ 0, 1, 0],
  74. [-sy, 0, cy]])
  75. Rz = np.array([[cz, -sz, 0],
  76. [sz, cz, 0],
  77. [0, 0, 1]])
  78. R = Rz @ Ry @ Rx
  79. return [[round(float(v), 6) for v in row] for row in R]
  80. def _project_slice_quad(R: list, viewer_label: str, size: float = 0.42) -> list:
  81. """
  82. Project the scan-plane square onto a viewer's 2D projection plane.
  83. The scan square lies in the (freq-encode, phase-encode) plane.
  84. freq direction = R column 0, phase direction = R column 1.
  85. Viewer planes:
  86. "Axial" -> XY (physical axes 0, 1)
  87. "Cor" -> XZ (physical axes 0, 2)
  88. "Sag" -> YZ (physical axes 1, 2)
  89. Returns list of 4 (u, v) tuples in [-1, 1] space, Y-up convention.
  90. """
  91. ax0, ax1 = _VIEWER_AXES.get(viewer_label, (0, 1))
  92. # freq and phase unit vectors in physical space
  93. freq = [R[i][0] for i in range(3)]
  94. phase = [R[i][1] for i in range(3)]
  95. corners = []
  96. for sf, sp in ((size, size), (-size, size), (-size, -size), (size, -size)):
  97. pt3 = [freq[i] * sf + phase[i] * sp for i in range(3)]
  98. corners.append((pt3[ax0], pt3[ax1]))
  99. return corners
  100. def _generate_noise_image(w: int = 256, h: int = 256) -> QImage:
  101. rng = np.random.default_rng(42)
  102. data = rng.integers(0, 70, (h, w), dtype=np.uint8)
  103. cy, cx = h / 2, w / 2
  104. Y, X = np.ogrid[:h, :w]
  105. dist = np.sqrt(((X - cx) / cx) ** 2 + ((Y - cy) / cy) ** 2)
  106. mask = np.clip(1.0 - dist * 0.85, 0.05, 1.0)
  107. data = (data * mask).astype(np.uint8)
  108. alpha = np.full((h, w), 200, dtype=np.uint8)
  109. rgba = np.stack([data, data, data, alpha], axis=-1)
  110. img = QImage(rgba.tobytes(), w, h, 4 * w, QImage.Format_RGBA8888)
  111. return img.copy()
  112. # ==============================================================================
  113. class MriViewerWidget(QWidget):
  114. """
  115. Dark MRI image viewer - pure QPainter.
  116. Ctrl + left-drag moves the slice square within the viewer plane.
  117. The widget emits slice_offset_changed(ax0, ax1, d0, d1) so ScanningTab
  118. can accumulate a shared 3-D offset vector and push it back to all viewers.
  119. """
  120. # slice_offset_changed(physical_axis_u, physical_axis_v, delta_u, delta_v)
  121. slice_offset_changed = Signal(int, int, float, float)
  122. # rotation_delta(drx_deg, dry_deg) - Ctrl+drag gamedev-style rotation
  123. rotation_delta = Signal(float, float)
  124. _IDENTITY = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
  125. def __init__(self, label: str = "Axial", parent: QWidget | None = None) -> None:
  126. super().__init__(parent)
  127. self._label = label
  128. self._scan_y = 0
  129. self._active_scan = False
  130. self._rot_matrix = [row[:] for row in self._IDENTITY]
  131. self._slice_offset = [0.0, 0.0, 0.0] # 3-D offset in logical [-1, 1] space
  132. self._drag_pos = None # QPointF while dragging
  133. self._drag_mode = None # 'move' | 'rotate'
  134. self._noise = _generate_noise_image()
  135. self.setMinimumSize(120, 120)
  136. self.setMouseTracking(True) # needed for cursor updates without button
  137. # -- public API ---------------------------------------------------------
  138. def set_scanning(self, active: bool) -> None:
  139. self._active_scan = active
  140. self.update()
  141. def set_rotation_matrix(self, R: list) -> None:
  142. self._rot_matrix = R
  143. self.update()
  144. def set_slice_offset(self, offset: list) -> None:
  145. self._slice_offset = list(offset)
  146. self.update()
  147. def advance_scanline(self, phase: int) -> None:
  148. if self.height() == 0:
  149. return
  150. self._scan_y = int(phase / _SCAN_SWEEP_DURATION * self.height())
  151. self.update()
  152. # -- mouse interaction --------------------------------------------------
  153. # LMB drag -> move slice (translate)
  154. # Ctrl + LMB drag -> rotate slice (gamedev orbit: dx=Ry yaw, dy=Rx pitch)
  155. def _pixel_scale(self) -> float:
  156. return min(self.width(), self.height()) * 0.46
  157. def _hover_cursor(self, modifiers) -> Qt.CursorShape:
  158. return Qt.OpenHandCursor if (modifiers & Qt.ControlModifier) else Qt.SizeAllCursor
  159. def mousePressEvent(self, event) -> None: # noqa: N802
  160. if event.button() == Qt.LeftButton:
  161. self._drag_pos = event.position()
  162. self._drag_mode = "rotate" if (event.modifiers() & Qt.ControlModifier) else "move"
  163. self.setCursor(Qt.ClosedHandCursor if self._drag_mode == "rotate" else Qt.SizeAllCursor)
  164. event.accept()
  165. else:
  166. super().mousePressEvent(event)
  167. def mouseMoveEvent(self, event) -> None: # noqa: N802
  168. if not (event.buttons() & Qt.LeftButton):
  169. self.setCursor(self._hover_cursor(event.modifiers()))
  170. if self._drag_pos is not None and (event.buttons() & Qt.LeftButton):
  171. pos = event.position()
  172. dx = pos.x() - self._drag_pos.x()
  173. dy = pos.y() - self._drag_pos.y()
  174. if self._drag_mode == "move":
  175. scale = self._pixel_scale()
  176. if scale > 0:
  177. ax0, ax1 = _VIEWER_AXES[self._label]
  178. self.slice_offset_changed.emit(ax0, ax1, dx / scale, -dy / scale)
  179. else: # rotate - gamedev FPS orbit
  180. # horizontal drag -> yaw (Ry)
  181. # vertical drag -> pitch (Rx); screen-down = positive pitch
  182. self.rotation_delta.emit(
  183. dy * _ROT_SENSITIVITY, # drx
  184. dx * _ROT_SENSITIVITY, # dry
  185. )
  186. self._drag_pos = pos
  187. event.accept()
  188. else:
  189. super().mouseMoveEvent(event)
  190. def mouseReleaseEvent(self, event) -> None: # noqa: N802
  191. if event.button() == Qt.LeftButton and self._drag_pos is not None:
  192. self._drag_pos = None
  193. self._drag_mode = None
  194. self.setCursor(self._hover_cursor(event.modifiers()))
  195. event.accept()
  196. else:
  197. super().mouseReleaseEvent(event)
  198. # -- painting -----------------------------------------------------------
  199. def paintEvent(self, event) -> None: # noqa: N802
  200. p = QPainter(self)
  201. rc = self.rect()
  202. w, h = rc.width(), rc.height()
  203. # 1 - background
  204. p.fillRect(rc, QColor(_IMAGE_BG))
  205. # 2 - noise texture
  206. p.setOpacity(0.9)
  207. p.drawImage(rc, self._noise)
  208. p.setOpacity(1.0)
  209. # 3 - crosshair
  210. cx, cy = w // 2, h // 2
  211. p.setPen(QPen(QColor("#444444"), 1))
  212. p.drawLine(cx - int(w * 0.30), cy, cx + int(w * 0.30), cy)
  213. p.drawLine(cx, cy - int(h * 0.30), cx, cy + int(h * 0.30))
  214. # 4 - scale ticks (3 per edge at 25/50/75 %)
  215. p.setPen(QPen(QColor("#888888"), 1))
  216. tick = 8
  217. for frac in (0.25, 0.50, 0.75):
  218. tx, ty = int(w * frac), int(h * frac)
  219. p.drawLine(tx, 0, tx, tick)
  220. p.drawLine(tx, h - tick, tx, h)
  221. p.drawLine(0, ty, tick, ty)
  222. p.drawLine(w - tick, ty, w, ty)
  223. # 5 - dim outer border
  224. p.setPen(QPen(QColor(_ACCENT_DIM), 1))
  225. p.drawRect(rc.adjusted(2, 2, -2, -2))
  226. # 6 - projected slice square (with 3-D offset applied)
  227. scale = self._pixel_scale()
  228. ax0, ax1 = _VIEWER_AXES[self._label]
  229. off_u = self._slice_offset[ax0]
  230. off_v = self._slice_offset[ax1]
  231. origin_x = cx + off_u * scale
  232. origin_y = cy - off_v * scale # Y-up -> Y-down
  233. corners_uv = _project_slice_quad(self._rot_matrix, self._label)
  234. pts = QPolygonF([
  235. QPointF(origin_x + u * scale, origin_y - v * scale)
  236. for u, v in corners_uv
  237. ])
  238. # semi-transparent fill
  239. p.setPen(Qt.NoPen)
  240. p.setBrush(QColor(240, 192, 64, 45))
  241. p.drawPolygon(pts)
  242. # solid border
  243. pen = QPen(QColor(_ACCENT), 2)
  244. pen.setJoinStyle(Qt.MiterJoin)
  245. p.setPen(pen)
  246. p.setBrush(Qt.NoBrush)
  247. p.drawPolygon(pts)
  248. # 7 - scan sweep stripe
  249. if self._active_scan:
  250. sy = self._scan_y
  251. grad = QLinearGradient(0, sy, w, sy)
  252. grad.setColorAt(0.0, QColor(0, 0, 0, 0))
  253. grad.setColorAt(0.5, QColor(255, 255, 255, 96))
  254. grad.setColorAt(1.0, QColor(0, 0, 0, 0))
  255. p.fillRect(0, max(0, sy - 2), w, 5, grad)
  256. # 8 - orientation label
  257. font = QFont("Arial", 11, QFont.Bold)
  258. p.setFont(font)
  259. p.setPen(Qt.white)
  260. p.drawText(rc.adjusted(8, 4, 0, 0), Qt.AlignTop | Qt.AlignLeft, self._label)
  261. p.end()
  262. # ==============================================================================
  263. class ProtocolListWidget(QWidget):
  264. protocol_selected = Signal(str)
  265. def __init__(self, parent: QWidget | None = None) -> None:
  266. super().__init__(parent)
  267. self.setStyleSheet(f"background: {_PANEL_BG};")
  268. lay = QVBoxLayout(self)
  269. lay.setContentsMargins(4, 8, 4, 4)
  270. lay.setSpacing(4)
  271. self._header_lbl = QLabel(i18n.tr("protocols_header"))
  272. self._header_lbl.setStyleSheet(
  273. "color: #aaaaaa; font-weight: bold; font-size: 11px; background: transparent;"
  274. )
  275. lay.addWidget(self._header_lbl)
  276. self._list = QListWidget()
  277. self._list.setStyleSheet(f"""
  278. QListWidget {{
  279. background: {_PANEL_BG};
  280. color: #ffffff;
  281. border: 1px solid #3a3a3a;
  282. font-size: 12px;
  283. outline: 0;
  284. }}
  285. QListWidget::item {{
  286. padding: 6px 8px;
  287. border-bottom: 1px solid #333333;
  288. }}
  289. QListWidget::item:selected {{
  290. background: {_ORANGE_SEL};
  291. color: #ffffff;
  292. }}
  293. QListWidget::item:hover:!selected {{
  294. background: #3a3a3a;
  295. }}
  296. """)
  297. for name in _PROTOCOLS:
  298. self._list.addItem(name)
  299. self._list.setCurrentRow(0)
  300. self._list.currentTextChanged.connect(self.protocol_selected)
  301. lay.addWidget(self._list, stretch=1)
  302. def retranslate_ui(self) -> None:
  303. self._header_lbl.setText(i18n.tr("protocols_header"))
  304. # ==============================================================================
  305. class _ScanPipelineWorker(QThread):
  306. """
  307. Full pipeline: health-check → load scenario (with seq_file / seq_info) →
  308. run_all → poll → extract raw data → emit raw_data_ready.
  309. The orchestrator is responsible for:
  310. - Forwarding the .seq file to seq-interp for interpretation
  311. - Running the full measurement scenario (spectrometer, reconstructor, …)
  312. The GUI never calls seq-interp or any other microservice directly.
  313. """
  314. progress = Signal(str)
  315. job_started = Signal(str)
  316. finished = Signal(str)
  317. error = Signal(str)
  318. raw_data_ready = Signal(str) # absolute path to temp JSON file
  319. def __init__(
  320. self,
  321. seq_file_path: str | None,
  322. seq_info: dict | None,
  323. orchestrator_url: str,
  324. scenario_id: str = "full_pipeline",
  325. protocol: str = "",
  326. parent=None,
  327. ) -> None:
  328. super().__init__(parent)
  329. self._seq_file = seq_file_path
  330. self._seq_info = dict(seq_info) if seq_info else {}
  331. self._scenario_id = scenario_id
  332. self._protocol = protocol
  333. from src.clients.orchestrator_client import OrchestratorClient
  334. self._orch = OrchestratorClient(orchestrator_url)
  335. def run(self) -> None:
  336. try:
  337. self._run_pipeline()
  338. except Exception as exc:
  339. if not self.isInterruptionRequested():
  340. self.error.emit(str(exc))
  341. def _run_pipeline(self) -> None:
  342. self.progress.emit("Проверка оркестратора...")
  343. if not self._orch.healthcheck():
  344. raise RuntimeError("Оркестратор недоступен — проверьте, что сервис запущен")
  345. self.progress.emit(
  346. f"Отправка задания в оркестратор "
  347. f"[сценарий: {self._scenario_id}, ИП: {self._protocol or '—'}]…"
  348. )
  349. job_id = self._orch.scan(
  350. seq_file_path=self._seq_file or None,
  351. seq_info=self._seq_info or None,
  352. scenario_id=self._scenario_id,
  353. protocol=self._protocol,
  354. )
  355. self.job_started.emit(job_id)
  356. self.progress.emit(f" Job {job_id[:8]}… запущен, ожидание результата…")
  357. def _on_status(status: str) -> None:
  358. self.progress.emit(f" [{status}]")
  359. final = self._orch.poll_scan(
  360. job_id,
  361. timeout=300.0,
  362. poll_interval=2.0,
  363. progress_cb=_on_status,
  364. interrupted_fn=self.isInterruptionRequested,
  365. )
  366. steps = final.get("steps", [])
  367. meas_id = None
  368. raw_data = None
  369. for step in steps:
  370. name = step.get("name")
  371. res = step.get("result") or {}
  372. if name == "start_measurement":
  373. meas_id = res.get("measurement_id")
  374. if name == "fetch_data":
  375. raw_data = res.get("data")
  376. self.progress.emit(f" Сканирование завершено (meas_id={meas_id})")
  377. is_stub = str(meas_id) in ("", "None", "meas_stub") or meas_id is None
  378. if raw_data and not is_stub:
  379. raw_path = self._save_raw(raw_data, meas_id)
  380. if raw_path:
  381. self.raw_data_ready.emit(raw_path)
  382. self.finished.emit(f"job завершён (meas_id={meas_id})")
  383. def _save_raw(self, data, meas_id) -> str | None:
  384. import tempfile, time as _t
  385. fname = f"scan_raw_{meas_id}_{int(_t.time())}.json"
  386. fpath = os.path.join(tempfile.gettempdir(), fname)
  387. try:
  388. with open(fpath, "w", encoding="utf-8") as fh:
  389. json.dump(data, fh)
  390. self.progress.emit(f" Данные сохранены: {fname}")
  391. return fpath
  392. except Exception as exc:
  393. self.progress.emit(f" Не удалось сохранить данные: {exc}")
  394. return None
  395. # ==============================================================================
  396. class _ScanWorker(QThread):
  397. """Fire-and-forget: load scenario and run_all via orchestrator REST."""
  398. finished = Signal(str) # job_id
  399. error = Signal(str)
  400. def __init__(self, url: str, info: dict, parent=None) -> None:
  401. super().__init__(parent)
  402. self._url = url.rstrip("/")
  403. self._info = info
  404. def run(self) -> None:
  405. try:
  406. if not _HAS_HTTPX:
  407. import urllib.request, urllib.error
  408. self._run_urllib()
  409. else:
  410. self._run_httpx()
  411. except Exception as exc:
  412. self.error.emit(str(exc))
  413. def _run_httpx(self) -> None:
  414. payload = {"param_overrides": {"start_measurement": {"info": self._info}}}
  415. with _httpx.Client(timeout=15) as client:
  416. r = client.post(
  417. f"{self._url}/scenario/load/full_pipeline",
  418. json=payload,
  419. )
  420. r.raise_for_status()
  421. job_id = r.json().get("job_id", "?")
  422. r2 = client.post(f"{self._url}/scenario/{job_id}/run_all")
  423. r2.raise_for_status()
  424. self.finished.emit(job_id)
  425. def _run_urllib(self) -> None:
  426. import urllib.request
  427. payload = {"param_overrides": {"start_measurement": {"info": self._info}}}
  428. data = json.dumps(payload).encode()
  429. headers = {"Content-Type": "application/json"}
  430. req = urllib.request.Request(
  431. f"{self._url}/scenario/load/full_pipeline",
  432. data=data, headers=headers, method="POST",
  433. )
  434. with urllib.request.urlopen(req, timeout=15) as resp:
  435. body = json.loads(resp.read())
  436. job_id = body.get("job_id", "?")
  437. req2 = urllib.request.Request(
  438. f"{self._url}/scenario/{job_id}/run_all",
  439. data=b"{}", headers=headers, method="POST",
  440. )
  441. with urllib.request.urlopen(req2, timeout=15):
  442. pass
  443. self.finished.emit(job_id)
  444. # ==============================================================================
  445. class ScanningTab(QWidget):
  446. """
  447. Operator-facing scanning tab.
  448. Bottom QTabWidget tabs match Siemens syngo layout:
  449. Основные | Контраст | Разрешение | Геометрия | Система
  450. "Геометрия" holds orientation presets + Rx/Ry/Rz spinboxes + live 3x3 matrix.
  451. """
  452. scan_job_started = Signal(str) # job_id — emitted once the orchestrator accepts the job
  453. raw_data_ready = Signal(str) # absolute path to temp JSON file
  454. def __init__(self, parent: QWidget | None = None) -> None:
  455. super().__init__(parent)
  456. self.setStyleSheet(f"background: {_BG_DARK};")
  457. self._viewers: list[MriViewerWidget] = []
  458. self._scan_tick: int = 0
  459. self._seq_info: dict | None = None
  460. self._seq_file_path: str | None = None
  461. self._orchestrator_url: str = "http://localhost:1717"
  462. self._scan_worker: _ScanPipelineWorker | None = None
  463. self._active_protocol: str = _PROTOCOLS[0]
  464. self._scenario_id: str = "full_pipeline"
  465. self._slice_offset: list[float] = [0.0, 0.0, 0.0]
  466. root = QVBoxLayout(self)
  467. root.setContentsMargins(0, 0, 0, 0)
  468. root.setSpacing(0)
  469. vsplit = QSplitter(Qt.Vertical)
  470. vsplit.setStyleSheet("QSplitter::handle { background: #333344; height: 3px; }")
  471. vsplit.addWidget(self._build_upper_area())
  472. vsplit.addWidget(self._build_bottom_panel())
  473. vsplit.setSizes([700, 140])
  474. vsplit.setChildrenCollapsible(False)
  475. root.addWidget(vsplit, stretch=1)
  476. self._setup_animation()
  477. self._update_matrix_display()
  478. self._update_slice_display()
  479. self._update_scan_ready_state()
  480. # -- public API ---------------------------------------------------------
  481. def apply_seq_info(self, info_dict: dict) -> None:
  482. """Receive exported sequence info from SeqInterpTab and auto-start scan."""
  483. self._seq_info = dict(info_dict)
  484. self._seq_file_path = None
  485. self._update_scan_ready_state()
  486. label = info_dict.get("infostr") or "sequence"
  487. self._log(f"Получены параметры последовательности: {label}", "INFO")
  488. if not self._btn_scan.isChecked():
  489. self._btn_scan.setChecked(True)
  490. def set_orchestrator_url(self, url: str) -> None:
  491. self._orchestrator_url = url
  492. def retranslate_ui(self) -> None:
  493. self._protocol_list.retranslate_ui()
  494. # param tabs: 0-3 = placeholders, 4 = geometry
  495. _keys = ["tab_main", "tab_contrast", "tab_resolution", "tab_system", "tab_geometry"]
  496. for idx, key in enumerate(_keys):
  497. self._param_tabs.setTabText(idx, i18n.tr(key))
  498. self._preset_group.setTitle(i18n.tr("grp_orientation"))
  499. self._rot_group.setTitle(i18n.tr("grp_rotation"))
  500. self._matrix_group.setTitle(i18n.tr("grp_rot_matrix"))
  501. # scan button: keep text in sync with current scan state
  502. if self._scan_timer.isActive():
  503. self._btn_scan.setText(i18n.tr("btn_stop_scan"))
  504. else:
  505. self._btn_scan.setText(i18n.tr("btn_scan"))
  506. self._update_scan_ready_state()
  507. # -- layout builders ----------------------------------------------------
  508. def _build_upper_area(self) -> QWidget:
  509. container = QWidget()
  510. container.setStyleSheet(f"background: {_BG_DARK};")
  511. lay = QVBoxLayout(container)
  512. lay.setContentsMargins(0, 0, 0, 0)
  513. lay.setSpacing(0)
  514. hsplit = QSplitter(Qt.Horizontal)
  515. hsplit.setStyleSheet("QSplitter::handle { background: #333344; width: 3px; }")
  516. hsplit.addWidget(self._build_protocol_panel())
  517. hsplit.addWidget(self._build_image_grid())
  518. hsplit.setSizes([220, 980])
  519. hsplit.setChildrenCollapsible(False)
  520. lay.addWidget(hsplit)
  521. return container
  522. def _build_protocol_panel(self) -> QWidget:
  523. container = QWidget()
  524. container.setStyleSheet(f"background: {_PANEL_BG};")
  525. lay = QVBoxLayout(container)
  526. lay.setContentsMargins(0, 0, 0, 0)
  527. lay.setSpacing(0)
  528. self._protocol_list = ProtocolListWidget()
  529. self._protocol_list.protocol_selected.connect(self._on_protocol_selected)
  530. lay.addWidget(self._protocol_list, stretch=1)
  531. sep = QFrame()
  532. sep.setFrameShape(QFrame.HLine)
  533. sep.setFixedHeight(1)
  534. sep.setStyleSheet("background: #3a3a4a; border: none;")
  535. lay.addWidget(sep)
  536. # Scenario selector
  537. sc_container = QWidget()
  538. sc_container.setStyleSheet(f"background: {_PANEL_BG};")
  539. sc_lay = QVBoxLayout(sc_container)
  540. sc_lay.setContentsMargins(6, 6, 6, 4)
  541. sc_lay.setSpacing(4)
  542. sc_header = QLabel("Сценарий оркестратора")
  543. sc_header.setStyleSheet(
  544. "color: #7777aa; font-size: 10px; font-weight: bold; background: transparent;"
  545. )
  546. sc_lay.addWidget(sc_header)
  547. sc_row = QHBoxLayout()
  548. sc_row.setSpacing(4)
  549. _combo_style = (
  550. "QComboBox {"
  551. f" background: {_BTN_BG}; color: #ccccee;"
  552. " border: 1px solid #444466; border-radius: 3px;"
  553. " font-size: 11px; padding: 3px 6px;"
  554. "}"
  555. "QComboBox::drop-down { border: none; width: 16px; }"
  556. "QComboBox QAbstractItemView {"
  557. f" background: {_BTN_BG}; color: #ccccee; border: 1px solid #444466;"
  558. " selection-background-color: #e65100;"
  559. "}"
  560. )
  561. self._scenario_combo = QComboBox()
  562. self._scenario_combo.setStyleSheet(_combo_style)
  563. self._scenario_combo.setToolTip("Тип сценария, который будет запущен в оркестраторе")
  564. self._scenario_combo.addItem("full_pipeline")
  565. self._scenario_combo.currentTextChanged.connect(self._on_scenario_selected)
  566. sc_row.addWidget(self._scenario_combo, stretch=1)
  567. btn_refresh_sc = QPushButton("↻")
  568. btn_refresh_sc.setFixedSize(24, 24)
  569. btn_refresh_sc.setToolTip("Получить список сценариев из оркестратора")
  570. btn_refresh_sc.setStyleSheet(
  571. f"QPushButton {{ background: {_BTN_BG}; color: #7777aa;"
  572. " border: 1px solid #444466; border-radius: 3px; font-size: 13px; }"
  573. "QPushButton:hover { color: #ffffff; background: #303050; }"
  574. )
  575. btn_refresh_sc.clicked.connect(self._on_refresh_scenarios)
  576. sc_row.addWidget(btn_refresh_sc)
  577. sc_lay.addLayout(sc_row)
  578. lay.addWidget(sc_container)
  579. sep2 = QFrame()
  580. sep2.setFrameShape(QFrame.HLine)
  581. sep2.setFixedHeight(1)
  582. sep2.setStyleSheet("background: #3a3a4a; border: none;")
  583. lay.addWidget(sep2)
  584. # .seq file loader
  585. btn_load = QPushButton("Загрузить .seq…")
  586. btn_load.setToolTip("Выбрать готовый .seq файл для запуска полного пайплайна")
  587. btn_load.setStyleSheet(
  588. "QPushButton {"
  589. f" background: {_BTN_BG}; color: #aaaacc;"
  590. " border: 1px solid #444466; border-radius: 3px;"
  591. " font-size: 11px; padding: 5px 8px; margin: 6px 6px 2px 6px;"
  592. "}"
  593. "QPushButton:hover { background: #303050; color: #ffffff; }"
  594. )
  595. btn_load.clicked.connect(self._on_load_seq_clicked)
  596. lay.addWidget(btn_load)
  597. self._lbl_seq_file = QLabel("Файл не выбран")
  598. self._lbl_seq_file.setWordWrap(True)
  599. self._lbl_seq_file.setStyleSheet(
  600. "color: #555577; font-size: 10px; background: transparent;"
  601. "padding: 0 8px 6px 8px;"
  602. )
  603. lay.addWidget(self._lbl_seq_file)
  604. return container
  605. def _build_image_grid(self) -> QWidget:
  606. container = QWidget()
  607. container.setStyleSheet(f"background: {_BG_DARK};")
  608. grid = QGridLayout(container)
  609. grid.setContentsMargins(6, 6, 6, 6)
  610. grid.setSpacing(6)
  611. for col, lbl in enumerate(("Axial", "Cor", "Sag")):
  612. viewer = MriViewerWidget(label=lbl)
  613. viewer.slice_offset_changed.connect(self._on_slice_offset_changed)
  614. viewer.rotation_delta.connect(self._on_rotation_delta)
  615. grid.addWidget(viewer, 0, col)
  616. grid.setColumnStretch(col, 1)
  617. self._viewers.append(viewer)
  618. grid.setRowStretch(0, 1)
  619. return container
  620. def _build_bottom_panel(self) -> QWidget:
  621. panel = QWidget()
  622. panel.setStyleSheet("background: #16162a; border-top: 1px solid #333355;")
  623. outer = QVBoxLayout(panel)
  624. outer.setContentsMargins(0, 0, 0, 0)
  625. outer.setSpacing(0)
  626. # -- parameter tabs -------------------------------------------------
  627. self._param_tabs = QTabWidget()
  628. self._param_tabs.setStyleSheet("""
  629. QTabWidget::pane { border: none; background: #16162a; }
  630. QTabBar::tab {
  631. background: #1e1e38; color: #aaaacc;
  632. padding: 5px 14px; border: 1px solid #333355;
  633. border-bottom: none; margin-right: 2px; font-size: 11px;
  634. }
  635. QTabBar::tab:selected { background: #252545; color: #ffffff; }
  636. QTabBar::tab:hover:!selected { background: #222240; }
  637. """)
  638. _placeholder_keys = ["tab_main", "tab_contrast", "tab_resolution", "tab_system"]
  639. for key in _placeholder_keys:
  640. w = QLabel(f"[ {i18n.tr(key)} — TODO ]")
  641. w.setAlignment(Qt.AlignCenter)
  642. w.setStyleSheet("color: #555577; background: #16162a;")
  643. self._param_tabs.addTab(w, i18n.tr(key))
  644. geo_tab = self._build_geometry_tab()
  645. self._param_tabs.addTab(geo_tab, i18n.tr("tab_geometry"))
  646. log_tab = self._build_log_tab()
  647. self._param_tabs.addTab(log_tab, "Лог")
  648. self._param_tabs.setCurrentWidget(geo_tab)
  649. outer.addWidget(self._param_tabs, stretch=1)
  650. # -- bottom action bar ----------------------------------------------
  651. action_bar = QWidget()
  652. action_bar.setStyleSheet("background: #16162a; border-top: 1px solid #2a2a4a;")
  653. action_lay = QHBoxLayout(action_bar)
  654. action_lay.setContentsMargins(12, 6, 12, 6)
  655. action_lay.setSpacing(12)
  656. self._status_label = QLabel(i18n.tr("no_data"))
  657. self._status_label.setStyleSheet("color: #666688; font-size: 11px;")
  658. action_lay.addWidget(self._status_label)
  659. action_lay.addStretch()
  660. self._btn_scan = QPushButton(i18n.tr("btn_scan"))
  661. self._btn_scan.setCheckable(True)
  662. self._btn_scan.setMinimumWidth(140)
  663. self._btn_scan.setStyleSheet(
  664. "QPushButton {"
  665. " background: #1a3a1a; color: #88ee88;"
  666. " border: 1px solid #336633; border-radius: 4px;"
  667. " font-size: 12px; font-weight: bold; padding: 5px 16px;"
  668. "}"
  669. "QPushButton:checked {"
  670. " background: #3a1a1a; color: #ee8888; border-color: #663333;"
  671. "}"
  672. "QPushButton:hover:!checked { background: #1e4a1e; }"
  673. )
  674. self._btn_scan.toggled.connect(self._on_scan_toggled)
  675. action_lay.addWidget(self._btn_scan)
  676. outer.addWidget(action_bar)
  677. return panel
  678. def _build_geometry_tab(self) -> QWidget:
  679. w = QWidget()
  680. w.setStyleSheet("background: #16162a;")
  681. lay = QHBoxLayout(w)
  682. lay.setContentsMargins(12, 8, 12, 8)
  683. lay.setSpacing(16)
  684. # -- orientation presets --------------------------------------------
  685. self._preset_group = QGroupBox(i18n.tr("grp_orientation"))
  686. preset_group = self._preset_group
  687. preset_group.setStyleSheet(self._group_style())
  688. preset_lay = QVBoxLayout(preset_group)
  689. preset_lay.setSpacing(4)
  690. self._btn_group = QButtonGroup(self)
  691. self._btn_group.setExclusive(True)
  692. self._preset_buttons: dict[str, QPushButton] = {}
  693. for name in ("Axial", "Coronal", "Sagittal"):
  694. btn = QPushButton(name)
  695. btn.setCheckable(True)
  696. btn.setStyleSheet(self._preset_btn_style())
  697. preset_lay.addWidget(btn)
  698. self._btn_group.addButton(btn)
  699. self._preset_buttons[name] = btn
  700. btn.clicked.connect(lambda checked, n=name: self._on_preset(n))
  701. self._preset_buttons["Axial"].setChecked(True)
  702. lay.addWidget(preset_group)
  703. # -- rotation angles ------------------------------------------------
  704. self._rot_group = QGroupBox(i18n.tr("grp_rotation"))
  705. rot_group = self._rot_group
  706. rot_group.setStyleSheet(self._group_style())
  707. form = QFormLayout(rot_group)
  708. form.setSpacing(6)
  709. form.setContentsMargins(8, 4, 8, 4)
  710. spin_style = (
  711. "QDoubleSpinBox {"
  712. " background: #252540; color: #ddddff;"
  713. " border: 1px solid #445; border-radius: 3px; padding: 2px 4px;"
  714. "}"
  715. "QDoubleSpinBox:focus { border-color: #f0c040; }"
  716. )
  717. lbl_style = "color: #aaaacc; font-size: 11px;"
  718. self._spin_rx = QDoubleSpinBox()
  719. self._spin_ry = QDoubleSpinBox()
  720. self._spin_rz = QDoubleSpinBox()
  721. for spin in (self._spin_rx, self._spin_ry, self._spin_rz):
  722. spin.setRange(-180.0, 180.0)
  723. spin.setSingleStep(1.0)
  724. spin.setDecimals(1)
  725. spin.setSuffix(" deg")
  726. spin.setStyleSheet(spin_style)
  727. spin.setMinimumWidth(90)
  728. spin.valueChanged.connect(self._on_rotation_changed)
  729. for lbl_text, spin in (("Rx", self._spin_rx), ("Ry", self._spin_ry), ("Rz", self._spin_rz)):
  730. lbl = QLabel(lbl_text)
  731. lbl.setStyleSheet(lbl_style)
  732. form.addRow(lbl, spin)
  733. lay.addWidget(rot_group)
  734. # -- rotation matrix display ----------------------------------------
  735. self._matrix_group = QGroupBox(i18n.tr("grp_rot_matrix"))
  736. matrix_group = self._matrix_group
  737. matrix_group.setStyleSheet(self._group_style())
  738. matrix_lay = QVBoxLayout(matrix_group)
  739. matrix_lay.setContentsMargins(8, 4, 8, 4)
  740. self._matrix_label = QLabel()
  741. self._matrix_label.setFont(QFont("Courier New", 10))
  742. self._matrix_label.setStyleSheet("color: #99ccff; background: transparent;")
  743. self._matrix_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
  744. matrix_lay.addWidget(self._matrix_label)
  745. lay.addWidget(matrix_group)
  746. lay.addStretch()
  747. return w
  748. def _build_log_tab(self) -> QWidget:
  749. w = QWidget()
  750. w.setStyleSheet("background: #16162a;")
  751. lay = QVBoxLayout(w)
  752. lay.setContentsMargins(6, 6, 6, 4)
  753. lay.setSpacing(4)
  754. self._log_view = QTextEdit()
  755. self._log_view.setReadOnly(True)
  756. self._log_view.setFont(QFont("Courier New", 9))
  757. self._log_view.setStyleSheet(
  758. "QTextEdit {"
  759. " background: #0e0e1c; color: #aaaacc;"
  760. " border: 1px solid #2a2a4a; border-radius: 3px;"
  761. "}"
  762. )
  763. lay.addWidget(self._log_view, stretch=1)
  764. btn_clear = QPushButton("Очистить")
  765. btn_clear.setFixedWidth(90)
  766. btn_clear.setStyleSheet(
  767. "QPushButton {"
  768. f" background: {_BTN_BG}; color: #777799;"
  769. " border: 1px solid #333355; border-radius: 3px;"
  770. " font-size: 10px; padding: 3px 8px;"
  771. "}"
  772. "QPushButton:hover { color: #aaaacc; }"
  773. )
  774. btn_clear.clicked.connect(self._log_view.clear)
  775. lay.addWidget(btn_clear, alignment=Qt.AlignRight)
  776. return w
  777. _LOG_COLORS = {"INFO": "#aaaacc", "WARN": "#e6a817", "ERR": "#ee4444"}
  778. def _log(self, msg: str, level: str = "INFO") -> None:
  779. if not hasattr(self, "_log_view"):
  780. return
  781. from datetime import datetime
  782. ts = datetime.now().strftime("%H:%M:%S")
  783. color = self._LOG_COLORS.get(level, "#aaaacc")
  784. line = (
  785. f"<span style='color:#555577'>{ts}</span>"
  786. f" <span style='color:{color}'>[{level}]</span>"
  787. f" {msg}"
  788. )
  789. self._log_view.append(line)
  790. sb = self._log_view.verticalScrollBar()
  791. sb.setValue(sb.maximum())
  792. @staticmethod
  793. def _group_style() -> str:
  794. return (
  795. "QGroupBox {"
  796. " color: #8888aa; font-size: 10px; font-weight: bold;"
  797. " border: 1px solid #333355; border-radius: 4px; margin-top: 6px;"
  798. "}"
  799. "QGroupBox::title { subcontrol-origin: margin; left: 6px; top: -1px; }"
  800. )
  801. @staticmethod
  802. def _preset_btn_style() -> str:
  803. return (
  804. "QPushButton {"
  805. f" background: {_BTN_BG}; color: #ccccee;"
  806. " border: 1px solid #444466; border-radius: 3px;"
  807. " font-size: 11px; padding: 4px 10px;"
  808. "}"
  809. "QPushButton:checked { background: #e65100; color: #ffffff; border-color: #ff7733; }"
  810. "QPushButton:hover:!checked { background: #303050; }"
  811. )
  812. # -- animation ----------------------------------------------------------
  813. def _setup_animation(self) -> None:
  814. self._scan_timer = QTimer(self)
  815. self._scan_timer.setInterval(_SCAN_LINE_INTERVAL_MS)
  816. self._scan_timer.timeout.connect(self._on_tick)
  817. def _on_tick(self) -> None:
  818. self._scan_tick += 1
  819. offset = _SCAN_SWEEP_DURATION // max(len(self._viewers), 1)
  820. for i, viewer in enumerate(self._viewers):
  821. phase = (self._scan_tick + i * offset) % _SCAN_SWEEP_DURATION
  822. viewer.advance_scanline(phase)
  823. # -- rotation logic -----------------------------------------------------
  824. def _on_preset(self, name: str) -> None:
  825. rx, ry, rz = _PRESETS[name]
  826. for spin in (self._spin_rx, self._spin_ry, self._spin_rz):
  827. spin.blockSignals(True)
  828. self._spin_rx.setValue(rx)
  829. self._spin_ry.setValue(ry)
  830. self._spin_rz.setValue(rz)
  831. for spin in (self._spin_rx, self._spin_ry, self._spin_rz):
  832. spin.blockSignals(False)
  833. self._on_rotation_changed()
  834. def _on_rotation_changed(self) -> None:
  835. self._update_matrix_display()
  836. self._update_slice_display()
  837. self._sync_preset_buttons()
  838. def _compute_rotation_matrix(self) -> list:
  839. return _euler_to_matrix(
  840. self._spin_rx.value(),
  841. self._spin_ry.value(),
  842. self._spin_rz.value(),
  843. )
  844. def _update_matrix_display(self) -> None:
  845. R = self._compute_rotation_matrix()
  846. lines = []
  847. for row in R:
  848. lines.append(" ".join(f"{v:+.3f}" for v in row))
  849. self._matrix_label.setText("\n".join(lines))
  850. def _update_slice_display(self) -> None:
  851. """Push current rotation matrix and offset to all viewers."""
  852. R = self._compute_rotation_matrix()
  853. for v in self._viewers:
  854. v.set_rotation_matrix(R)
  855. v.set_slice_offset(self._slice_offset)
  856. def _on_slice_offset_changed(self, ax0: int, ax1: int, d0: float, d1: float) -> None:
  857. """Accumulate drag delta from any viewer into the shared 3-D offset."""
  858. self._slice_offset[ax0] = max(-0.95, min(0.95, self._slice_offset[ax0] + d0))
  859. self._slice_offset[ax1] = max(-0.95, min(0.95, self._slice_offset[ax1] + d1))
  860. R = self._compute_rotation_matrix()
  861. for v in self._viewers:
  862. v.set_slice_offset(self._slice_offset)
  863. v.set_rotation_matrix(R)
  864. def _on_rotation_delta(self, drx: float, dry: float) -> None:
  865. """Gamedev-style Ctrl+drag: apply incremental Rx/Ry rotation from any viewer."""
  866. def _wrap(val: float) -> float:
  867. while val > 180.0: val -= 360.0
  868. while val < -180.0: val += 360.0
  869. return val
  870. for spin, delta in ((self._spin_rx, drx), (self._spin_ry, dry)):
  871. spin.blockSignals(True)
  872. spin.setValue(_wrap(spin.value() + delta))
  873. spin.blockSignals(False)
  874. self._on_rotation_changed()
  875. def _sync_preset_buttons(self) -> None:
  876. """Check if current angles match a known preset; highlight button if so."""
  877. rx = round(self._spin_rx.value(), 1)
  878. ry = round(self._spin_ry.value(), 1)
  879. rz = round(self._spin_rz.value(), 1)
  880. matched = None
  881. for name, (prx, pry, prz) in _PRESETS.items():
  882. if (rx, ry, rz) == (prx, pry, prz):
  883. matched = name
  884. break
  885. for name, btn in self._preset_buttons.items():
  886. btn.blockSignals(True)
  887. btn.setChecked(name == matched)
  888. btn.blockSignals(False)
  889. # -- .seq file loading --------------------------------------------------
  890. def _on_load_seq_clicked(self) -> None:
  891. path, _ = QFileDialog.getOpenFileName(
  892. self,
  893. "Выбрать .seq файл",
  894. os.path.join(os.path.dirname(__file__), os.pardir, os.pardir),
  895. "Pulseq файлы (*.seq);;Все файлы (*)",
  896. )
  897. if not path:
  898. return
  899. self._seq_file_path = path
  900. self._seq_info = None
  901. fname = os.path.basename(path)
  902. self._lbl_seq_file.setText(fname)
  903. self._lbl_seq_file.setStyleSheet(
  904. "color: #e65100; font-size: 10px; background: transparent;"
  905. "padding: 0 8px 6px 8px;"
  906. )
  907. self._log(f"Загружен файл: {fname}", "INFO")
  908. self._update_scan_ready_state()
  909. # -- scan initiation ----------------------------------------------------
  910. def _on_scan_toggled(self, checked: bool) -> None:
  911. if checked:
  912. if self._scan_worker is not None and self._scan_worker.isRunning():
  913. return
  914. if self._seq_info is None and self._seq_file_path is None:
  915. QMessageBox.warning(
  916. self, i18n.tr("dlg_no_data_title"),
  917. i18n.tr("dlg_no_seq_msg"),
  918. )
  919. self._btn_scan.setChecked(False)
  920. return
  921. info = dict(self._seq_info) if self._seq_info else {}
  922. if info:
  923. info["rotation_matrix"] = self._compute_rotation_matrix()
  924. info["slice_position"] = list(self._slice_offset)
  925. self._scan_worker = _ScanPipelineWorker(
  926. seq_file_path=self._seq_file_path,
  927. seq_info=info if info else None,
  928. orchestrator_url=self._orchestrator_url,
  929. scenario_id=self._scenario_id,
  930. protocol=self._active_protocol,
  931. parent=self,
  932. )
  933. self._scan_worker.job_started.connect(self.scan_job_started)
  934. self._scan_worker.finished.connect(self._on_scan_done)
  935. self._scan_worker.error.connect(self._on_scan_error)
  936. self._scan_worker.progress.connect(self._on_scan_progress)
  937. self._scan_worker.raw_data_ready.connect(self._on_raw_data_ready_log)
  938. self._scan_worker.raw_data_ready.connect(self.raw_data_ready)
  939. self._scan_worker.start()
  940. self._scan_timer.start()
  941. self._btn_scan.setText(i18n.tr("btn_stop_scan"))
  942. self._status_label.setText("Инициализация пайплайна…")
  943. self._status_label.setStyleSheet("color: #88ee88; font-size: 11px;")
  944. self._log("--- Запуск пайплайна сканирования ---", "INFO")
  945. for v in self._viewers:
  946. v.set_scanning(True)
  947. # Switch to Log tab so the user can follow progress
  948. log_idx = self._param_tabs.indexOf(self._log_view.parent())
  949. if log_idx >= 0:
  950. self._param_tabs.setCurrentIndex(log_idx)
  951. else:
  952. self._scan_timer.stop()
  953. if self._scan_worker and self._scan_worker.isRunning():
  954. self._scan_worker.requestInterruption()
  955. self._btn_scan.setText(i18n.tr("btn_scan"))
  956. for v in self._viewers:
  957. v.set_scanning(False)
  958. self._update_scan_ready_state()
  959. def _on_raw_data_ready_log(self, path: str) -> None:
  960. self._log(f"Данные отправлены во вкладку Spectroscopy: {os.path.basename(path)}", "INFO")
  961. def _on_scan_progress(self, msg: str) -> None:
  962. self._status_label.setText(msg[:80])
  963. self._status_label.setStyleSheet("color: #88ee88; font-size: 11px;")
  964. self._log(msg, "INFO")
  965. def _on_scan_done(self, msg: str) -> None:
  966. self._scan_timer.stop()
  967. self._status_label.setText("Готово")
  968. self._status_label.setStyleSheet("color: #66ccff; font-size: 11px;")
  969. self._log(msg, "INFO")
  970. for v in self._viewers:
  971. v.set_scanning(False)
  972. self._btn_scan.setChecked(False)
  973. def _on_scan_error(self, err: str) -> None:
  974. self._scan_timer.stop()
  975. self._status_label.setText(f"{i18n.tr('error_prefix')}: {err[:70]}")
  976. self._status_label.setStyleSheet("color: #ee4444; font-size: 11px;")
  977. self._log(err, "ERR")
  978. for v in self._viewers:
  979. v.set_scanning(False)
  980. self._btn_scan.setChecked(False)
  981. def _update_scan_ready_state(self) -> None:
  982. if self._seq_file_path:
  983. fname = os.path.basename(self._seq_file_path)
  984. self._status_label.setText(f"Файл: {fname}")
  985. self._status_label.setStyleSheet("color: #e65100; font-size: 11px;")
  986. elif self._seq_info is not None:
  987. self._status_label.setText(i18n.tr("ready_to_scan"))
  988. self._status_label.setStyleSheet("color: #e65100; font-size: 11px;")
  989. else:
  990. self._status_label.setText(i18n.tr("no_data"))
  991. self._status_label.setStyleSheet("color: #666688; font-size: 11px;")
  992. # -- scenario selection -------------------------------------------------
  993. def _on_scenario_selected(self, name: str) -> None:
  994. if name:
  995. self._scenario_id = name
  996. def _on_refresh_scenarios(self) -> None:
  997. import httpx
  998. try:
  999. r = httpx.get(
  1000. f"{self._orchestrator_url}/scenario/list",
  1001. timeout=5.0,
  1002. )
  1003. if r.is_success:
  1004. scenarios: list[str] = r.json().get("scenarios", [])
  1005. if scenarios:
  1006. current = self._scenario_combo.currentText()
  1007. self._scenario_combo.blockSignals(True)
  1008. self._scenario_combo.clear()
  1009. for s in scenarios:
  1010. self._scenario_combo.addItem(s)
  1011. idx = self._scenario_combo.findText(current)
  1012. self._scenario_combo.setCurrentIndex(max(idx, 0))
  1013. self._scenario_combo.blockSignals(False)
  1014. self._scenario_id = self._scenario_combo.currentText()
  1015. self._log(f"Сценарии загружены: {scenarios}", "INFO")
  1016. else:
  1017. self._log("Оркестратор вернул пустой список сценариев", "WARN")
  1018. else:
  1019. self._log(f"Ошибка загрузки сценариев: HTTP {r.status_code}", "WARN")
  1020. except Exception as exc:
  1021. self._log(f"Не удалось подключиться к оркестратору: {exc}", "WARN")
  1022. # -- protocol selection -------------------------------------------------
  1023. def _on_protocol_selected(self, name: str) -> None:
  1024. self._active_protocol = name