dicom_labeler_unified.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646
  1. import sys
  2. import os
  3. import json
  4. import cv2
  5. import numpy as np
  6. import pydicom
  7. from PyQt5.QtWidgets import (
  8. QApplication, QMainWindow, QLabel, QPushButton, QColorDialog, QInputDialog,
  9. QFileDialog, QMessageBox, QVBoxLayout, QHBoxLayout, QWidget, QFrame, QSizePolicy,
  10. QSlider, QComboBox, QCheckBox
  11. )
  12. from PyQt5.QtGui import QImage, QPixmap, QPainter, QPen, QColor
  13. from PyQt5.QtCore import Qt, QPoint, QRect
  14. from PyQt5.QtWidgets import QSplitter
  15. # =============================
  16. # Unified Image Viewer + ROI
  17. # =============================
  18. class ImageLabel(QLabel):
  19. def __init__(self, parent=None):
  20. super().__init__(parent)
  21. self.setMouseTracking(True)
  22. self.image = QImage()
  23. self.rois = {}
  24. self.current_roi = []
  25. self.drawing = False
  26. self.current_color = QColor(Qt.red)
  27. self.current_label = "ROI"
  28. self.undo_stack = []
  29. # Series & slice
  30. self.slice_index = 0
  31. self.all_dicom_files = []
  32. self.filtered_series_files = [] # selected series files
  33. self.dataset = None
  34. # ROI propagation (from _12/_13)
  35. self.base_roi = None # first ROI copied to all slices
  36. # Volume-related spacing
  37. self.spacing_xy = (0.0, 0.0)
  38. self.spacing_z = 0.0
  39. # For volume per slice
  40. self.volume_by_slice = {}
  41. # Visual "checkbox" on image (from dicom_labeler.py)
  42. self.mark_area_rect = QRect(10, 10, 30, 30)
  43. self.marked_by_rule_slices = set()
  44. # Zoom
  45. self.zoom_factor = 1.0
  46. self.zoom_step = 0.1
  47. self.min_zoom = 0.1
  48. self.max_zoom = 5.0
  49. # Callbacks the window can set to recompute views on change
  50. self.on_slice_changed = None
  51. # ------------- Loading logic -------------
  52. def load_folder(self, folder_path):
  53. self.all_dicom_files = [os.path.join(folder_path, f) for f in os.listdir(folder_path)]
  54. if not self.all_dicom_files:
  55. raise ValueError("В папке нет DICOM-файлов.")
  56. def filter_series(self, predicate):
  57. """
  58. predicate: callable(dataset) -> bool, choose subset by SeriesDescription etc.
  59. """
  60. self.filtered_series_files = []
  61. for f in self.all_dicom_files:
  62. try:
  63. ds = pydicom.dcmread(f, stop_before_pixels=True, force=True)
  64. if predicate(ds):
  65. self.filtered_series_files.append(f)
  66. except Exception:
  67. pass
  68. if not self.filtered_series_files:
  69. raise ValueError("Подходящая серия не найдена по выбранному фильтру.")
  70. # sort by InstanceNumber when possible
  71. try:
  72. self.filtered_series_files.sort(key=lambda p: int(pydicom.dcmread(p, stop_before_pixels=True, force=True).get("InstanceNumber", 0)))
  73. except Exception:
  74. self.filtered_series_files.sort()
  75. self.slice_index = len(self.filtered_series_files) // 2
  76. self.rois.clear()
  77. self.base_roi = None
  78. self.volume_by_slice.clear()
  79. self.load_slice()
  80. def load_slice(self):
  81. if 0 <= self.slice_index < len(self.filtered_series_files):
  82. self.dataset = pydicom.dcmread(self.filtered_series_files[self.slice_index], force=True)
  83. # spacing
  84. try:
  85. px = self.dataset.PixelSpacing
  86. self.spacing_xy = (float(px[0]), float(px[1]))
  87. except Exception:
  88. self.spacing_xy = (1.0, 1.0)
  89. # z-spacing (will be used as SliceThickness or SpacingBetweenSlices by a toggle in window)
  90. # Store both to allow switching
  91. self.slice_thickness = float(getattr(self.dataset, 'SliceThickness', 1.0))
  92. self.spacing_between_slices = float(getattr(self.dataset, 'SpacingBetweenSlices', self.slice_thickness))
  93. arr = self.dataset.pixel_array
  94. img8 = cv2.normalize(arr, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
  95. self.image = QImage(img8.data, arr.shape[1], arr.shape[0], QImage.Format_Grayscale8)
  96. # If a base ROI was created earlier, ensure it's present on new slice (copy-on-first-show)
  97. if self.base_roi is not None and self.slice_index not in self.rois:
  98. self.rois[self.slice_index] = [self._copy_roi(self.base_roi)]
  99. self.setPixmap(QPixmap.fromImage(self.image))
  100. self.update()
  101. if self.on_slice_changed:
  102. self.on_slice_changed()
  103. # ------------- Navigation -------------
  104. def next_slice(self):
  105. if self.slice_index < len(self.filtered_series_files) - 1:
  106. self.slice_index += 1
  107. self.load_slice()
  108. def previous_slice(self):
  109. if self.slice_index > 0:
  110. self.slice_index -= 1
  111. self.load_slice()
  112. # ------------- ROI ops -------------
  113. def set_current_color(self, color):
  114. self.current_color = color
  115. def set_current_label(self, label):
  116. self.current_label = label
  117. def _copy_roi(self, roi):
  118. return {
  119. 'label': roi['label'],
  120. 'color': QColor(roi['color']),
  121. 'points': [QPoint(p.x(), p.y()) for p in roi['points']],
  122. }
  123. def _unmark_slice(self, slice_idx):
  124. """Убирает служебную галку с одного среза."""
  125. self.marked_by_rule_slices.discard(slice_idx)
  126. if slice_idx in self.rois:
  127. self.rois[slice_idx] = [roi for roi in self.rois[slice_idx] if roi.get('label') != 'AutoMark']
  128. if not self.rois[slice_idx]:
  129. del self.rois[slice_idx]
  130. def _unmark_all_slices(self):
  131. """Убирает все галки и служебные ROI."""
  132. self.marked_by_rule_slices.clear()
  133. for idx in list(self.rois.keys()):
  134. self.rois[idx] = [roi for roi in self.rois[idx] if roi.get('label') != 'AutoMark']
  135. if not self.rois[idx]:
  136. del self.rois[idx]
  137. def mousePressEvent(self, event):
  138. scaled_pos = event.pos() / self.zoom_factor
  139. if self.mark_area_rect.contains(scaled_pos):
  140. idx = self.slice_index
  141. if event.button() == Qt.LeftButton:
  142. if idx in self.marked_by_rule_slices:
  143. self._unmark_slice(idx)
  144. else:
  145. self._mark_range_from_current()
  146. elif event.button() == Qt.RightButton:
  147. self._unmark_all_slices()
  148. self.update()
  149. return
  150. def mouseMoveEvent(self, event):
  151. if self.drawing:
  152. scaled_pos = event.pos() / self.zoom_factor
  153. self.current_roi.append(scaled_pos)
  154. self.update()
  155. def mouseReleaseEvent(self, event):
  156. if event.button() == Qt.LeftButton and self.drawing:
  157. self.drawing = False
  158. if len(self.current_roi) > 2:
  159. roi = {
  160. 'label': self.current_label,
  161. 'color': QColor(self.current_color),
  162. 'points': [QPoint(p.x(), p.y()) for p in self.current_roi],
  163. }
  164. # First ROI becomes base ROI and is added to all slices
  165. if self.base_roi is None:
  166. self.base_roi = self._copy_roi(roi)
  167. for idx in range(len(self.filtered_series_files)):
  168. self.rois.setdefault(idx, []).append(self._copy_roi(self.base_roi))
  169. # Also keep the drawn ROI on this slice as an adjustment
  170. self.rois.setdefault(self.slice_index, []).append(roi)
  171. self.undo_stack.append((self.slice_index, roi))
  172. self.current_roi = []
  173. self.update()
  174. if self.on_slice_changed:
  175. self.on_slice_changed()
  176. # ------------- Painting -------------
  177. def paintEvent(self, event):
  178. super().paintEvent(event)
  179. if self.image.isNull():
  180. return
  181. painter = QPainter(self)
  182. # Zoom transform
  183. t = painter.transform()
  184. t.scale(self.zoom_factor, self.zoom_factor)
  185. painter.setTransform(t)
  186. painter.drawImage(self.rect(), self.image, self.image.rect())
  187. pen = QPen(Qt.red, 2)
  188. painter.setPen(pen)
  189. # Draw stored ROIs
  190. for roi in self.rois.get(self.slice_index, []):
  191. pen.setColor(roi['color'])
  192. painter.setPen(pen)
  193. pts = [QPoint(p.x(), p.y()) for p in roi['points']]
  194. if len(pts) >= 3:
  195. painter.drawPolygon(*pts)
  196. # Current polyline
  197. if self.current_roi:
  198. pen.setColor(Qt.blue)
  199. painter.setPen(pen)
  200. pts = [QPoint(p.x(), p.y()) for p in self.current_roi]
  201. painter.drawPolyline(*pts)
  202. # Draw the green checkbox area
  203. pen = QPen(Qt.green, 2)
  204. painter.setPen(pen)
  205. painter.setBrush(QColor(0, 255, 0, 100))
  206. painter.drawRect(self.mark_area_rect)
  207. if self.slice_index in self.marked_by_rule_slices:
  208. painter.drawLine(self.mark_area_rect.topLeft() + QPoint(5, 10),
  209. self.mark_area_rect.center() + QPoint(0, 8))
  210. painter.drawLine(self.mark_area_rect.center() + QPoint(0, 8),
  211. self.mark_area_rect.topRight() + QPoint(-4, 4))
  212. painter.end()
  213. # ------------- Undo / Clear -------------
  214. def undo(self):
  215. if self.undo_stack:
  216. slice_idx, last_roi = self.undo_stack.pop()
  217. if slice_idx in self.rois and last_roi in self.rois[slice_idx]:
  218. self.rois[slice_idx].remove(last_roi)
  219. if not self.rois[slice_idx]:
  220. del self.rois[slice_idx]
  221. self.update()
  222. if self.on_slice_changed:
  223. self.on_slice_changed()
  224. def clear_slice(self):
  225. self.rois[self.slice_index] = []
  226. self.update()
  227. if self.on_slice_changed:
  228. self.on_slice_changed()
  229. # ------------- Auto-mark rule -------------
  230. def _mark_range_from_current(self):
  231. current = self.slice_index
  232. total = len(self.filtered_series_files)
  233. middle = total // 2
  234. if current <= middle:
  235. indices = range(0, current + 1)
  236. else:
  237. indices = range(current, total)
  238. for idx in indices:
  239. roi = {
  240. 'label': 'AutoMark',
  241. 'color': QColor(Qt.green),
  242. 'points': [QPoint(10, 10), QPoint(40, 10), QPoint(40, 40), QPoint(10, 40)],
  243. }
  244. self.rois.setdefault(idx, []).append(self._copy_roi(roi))
  245. self.undo_stack.append((idx, roi))
  246. self.marked_by_rule_slices.add(idx)
  247. self.update()
  248. # ------------- Wheel: zoom or navigate -------------
  249. def wheelEvent(self, event):
  250. if event.modifiers() & Qt.ControlModifier:
  251. angle = event.angleDelta().y()
  252. factor = 1.1 if angle > 0 else 0.9
  253. self.zoom_factor = max(self.min_zoom, min(self.max_zoom, self.zoom_factor * factor))
  254. self.update()
  255. else:
  256. if event.angleDelta().y() > 0:
  257. self.previous_slice()
  258. else:
  259. self.next_slice()
  260. def keyPressEvent(self, event):
  261. # Optional Ctrl+Z
  262. if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_Z:
  263. self.undo()
  264. # =============================
  265. # Unified Main Window
  266. # =============================
  267. class ROIDrawer(QMainWindow):
  268. def __init__(self):
  269. super().__init__()
  270. self.threshold_brightness = 0.5
  271. self.contours_thr = 0.3
  272. self.use_slice_thickness = True # toggle between SliceThickness and SpacingBetweenSlices
  273. self.series_predicates = [] # (name, callable)
  274. self._build_ui()
  275. self._init_series_filters()
  276. # ---------- UI ----------
  277. def _build_ui(self):
  278. self.setWindowTitle("KneeSeg - Unified")
  279. screen = QApplication.desktop().availableGeometry()
  280. self.resize(int(screen.width() * 0.95), int(screen.height() * 0.95))
  281. self.setFixedSize(self.size())
  282. central = QWidget(self)
  283. self.setCentralWidget(central)
  284. main_layout = QVBoxLayout(central)
  285. # Top row: sequence dropdown + z-spacing toggle
  286. top_controls = QHBoxLayout()
  287. self.sequence_dropdown = QComboBox()
  288. self.sequence_dropdown.setFixedSize(300, 30)
  289. self.sequence_dropdown.setStyleSheet("font-size: 14px;")
  290. top_controls.addStretch(1)
  291. top_controls.addWidget(self.sequence_dropdown)
  292. self.z_toggle = QCheckBox("Использовать SliceThickness (иначе SpacingBetweenSlices)")
  293. self.z_toggle.setChecked(True)
  294. self.z_toggle.stateChanged.connect(self._toggle_z_source)
  295. top_controls.addWidget(self.z_toggle)
  296. main_layout.addLayout(top_controls)
  297. # Threshold controls
  298. threshold_layout = QVBoxLayout()
  299. b_lbl = QLabel("Порог яркости")
  300. b_lbl.setStyleSheet("font-size: 14px;font-weight: bold;")
  301. self.brightness_slider = QSlider(Qt.Horizontal)
  302. self.brightness_slider.setMinimum(1)
  303. self.brightness_slider.setMaximum(99)
  304. self.brightness_slider.setValue(50)
  305. self.brightness_slider.valueChanged.connect(self._update_thresholds)
  306. c_lbl = QLabel("Порог площади")
  307. c_lbl.setStyleSheet("font-size: 14px;font-weight: bold;")
  308. self.contour_slider = QSlider(Qt.Horizontal)
  309. self.contour_slider.setMinimum(1)
  310. self.contour_slider.setMaximum(99)
  311. self.contour_slider.setValue(30)
  312. self.contour_slider.valueChanged.connect(self._update_thresholds)
  313. threshold_layout.addWidget(b_lbl)
  314. threshold_layout.addWidget(self.brightness_slider)
  315. threshold_layout.addWidget(c_lbl)
  316. threshold_layout.addWidget(self.contour_slider)
  317. main_layout.addLayout(threshold_layout)
  318. # Splitter views
  319. splitter = QSplitter(Qt.Horizontal)
  320. self.pixel_count_label = QLabel("Суммарный объем (мл): 0.00")
  321. self.pixel_count_label.setStyleSheet("font-size: 16pt; font-weight: bold;")
  322. main_layout.addWidget(self.pixel_count_label)
  323. self.image_label = ImageLabel(self)
  324. self.image_label.setAlignment(Qt.AlignCenter)
  325. self.image_label.on_slice_changed = self._recompute_views
  326. img_frame = self._make_frame("", self.image_label)
  327. splitter.addWidget(img_frame)
  328. self.filtration_label = QLabel()
  329. self.filtration_label.setAlignment(Qt.AlignCenter)
  330. filt_frame = self._make_frame(" ", self.filtration_label)
  331. splitter.addWidget(filt_frame)
  332. self.segmentation_label = QLabel()
  333. self.segmentation_label.setAlignment(Qt.AlignCenter)
  334. seg_frame = self._make_frame("", self.segmentation_label)
  335. splitter.addWidget(seg_frame)
  336. self.image_label.setScaledContents(True)
  337. self.filtration_label.setScaledContents(True)
  338. self.segmentation_label.setScaledContents(True)
  339. splitter.setSizes([1, 1, 1])
  340. splitter.setStretchFactor(0, 1)
  341. splitter.setStretchFactor(1, 1)
  342. splitter.setStretchFactor(2, 1)
  343. main_layout.addWidget(splitter)
  344. # Buttons
  345. btns = QHBoxLayout()
  346. main_layout.addLayout(btns)
  347. load_btn = QPushButton('Load DICOM', self)
  348. load_btn.setFixedSize(150, 50)
  349. load_btn.setStyleSheet('QPushButton { font-size: 20px;}')
  350. load_btn.clicked.connect(self._load_folder)
  351. btns.addWidget(load_btn)
  352. next_btn = QPushButton('<<', self) # next slice (as in original)
  353. next_btn.setFixedSize(50, 50)
  354. next_btn.setStyleSheet('QPushButton { font-size: 20px; }')
  355. next_btn.clicked.connect(self.image_label.next_slice)
  356. btns.addWidget(next_btn)
  357. prev_btn = QPushButton('>>', self) # previous slice (as in original)
  358. prev_btn.setFixedSize(50, 50)
  359. prev_btn.setStyleSheet('QPushButton { font-size: 20px; }')
  360. prev_btn.clicked.connect(self.image_label.previous_slice)
  361. btns.addWidget(prev_btn)
  362. color_btn = QPushButton('Select Color', self)
  363. color_btn.setFixedSize(150, 50)
  364. color_btn.setStyleSheet('QPushButton { font-size: 20px; }')
  365. color_btn.clicked.connect(self._select_color)
  366. btns.addWidget(color_btn)
  367. label_btn = QPushButton('Set Label', self)
  368. label_btn.setFixedSize(120, 50)
  369. label_btn.setStyleSheet('QPushButton { font-size: 20px; }')
  370. label_btn.clicked.connect(self._set_label)
  371. btns.addWidget(label_btn)
  372. undo_btn = QPushButton('Undo', self)
  373. undo_btn.setFixedSize(100, 50)
  374. undo_btn.setStyleSheet('QPushButton { font-size: 20px; }')
  375. undo_btn.clicked.connect(self.image_label.undo)
  376. btns.addWidget(undo_btn)
  377. clear_btn = QPushButton('Clear slice', self)
  378. clear_btn.setFixedSize(140, 50)
  379. clear_btn.setStyleSheet('QPushButton { font-size: 20px; }')
  380. clear_btn.clicked.connect(self.image_label.clear_slice)
  381. btns.addWidget(clear_btn)
  382. seg_btn = QPushButton('Segmentation', self)
  383. seg_btn.setFixedSize(160, 50)
  384. seg_btn.setStyleSheet('QPushButton { font-size: 20px; }')
  385. seg_btn.clicked.connect(self._recompute_views)
  386. btns.addWidget(seg_btn)
  387. save_btn = QPushButton('Save ROIs', self)
  388. save_btn.setFixedSize(150, 50)
  389. save_btn.setStyleSheet('QPushButton { font-size: 20px; }')
  390. save_btn.clicked.connect(self._save_rois)
  391. btns.addWidget(save_btn)
  392. btns.addStretch(1)
  393. # React to sequence selection
  394. self.sequence_dropdown.currentIndexChanged.connect(self._on_sequence_changed)
  395. def _make_frame(self, title, widget):
  396. frame = QFrame()
  397. lay = QVBoxLayout()
  398. title_lbl = QLabel(f"<b>{title}</b>")
  399. title_lbl.setAlignment(Qt.AlignCenter)
  400. lay.addWidget(title_lbl)
  401. lay.addWidget(widget)
  402. frame.setLayout(lay)
  403. return frame
  404. # ---------- Series selection ----------
  405. def _init_series_filters(self):
  406. # Fill later from actual folder (unique SeriesDescription), but also provide common presets
  407. self.series_predicates = [
  408. ("Auto: All files", lambda ds: True),
  409. ("Contains 'Sag PD'", lambda ds: 'SeriesDescription' in ds and 'Sag PD' in str(ds.SeriesDescription)),
  410. ("Contains 'T1 Cube'", lambda ds: 'SeriesDescription' in ds and 'T1 Cube' in str(ds.SeriesDescription)),
  411. ]
  412. self.sequence_dropdown.clear()
  413. self.sequence_dropdown.addItems([name for name, _ in self.series_predicates])
  414. def _populate_series_from_folder(self):
  415. # read unique SeriesDescription and add them to dropdown
  416. seen = {}
  417. for f in self.image_label.all_dicom_files:
  418. try:
  419. ds = pydicom.dcmread(f, stop_before_pixels=True, force=True)
  420. desc = str(getattr(ds, 'SeriesDescription', ''))
  421. if desc and desc not in seen:
  422. seen[desc] = (desc, lambda D, d=desc: str(getattr(D, 'SeriesDescription', '')) == d)
  423. except Exception:
  424. pass
  425. # Append discovered series to the end
  426. for desc, (name, pred) in seen.items():
  427. self.series_predicates.append((f"Series: {name}", pred))
  428. self.sequence_dropdown.clear()
  429. self.sequence_dropdown.addItems([name for name, _ in self.series_predicates])
  430. # ---------- Actions ----------
  431. def _load_folder(self):
  432. folder = QFileDialog.getExistingDirectory(self, "Select DICOM Series Folder")
  433. if not folder:
  434. return
  435. try:
  436. self.image_label.load_folder(folder)
  437. self._populate_series_from_folder()
  438. # Auto-filter by current dropdown selection
  439. self._on_sequence_changed()
  440. except Exception as e:
  441. QMessageBox.critical(self, "Error", str(e))
  442. def _on_sequence_changed(self):
  443. idx = self.sequence_dropdown.currentIndex()
  444. if idx < 0 or idx >= len(self.series_predicates):
  445. return
  446. _, pred = self.series_predicates[idx]
  447. try:
  448. self.image_label.filter_series(pred)
  449. except Exception as e:
  450. QMessageBox.critical(self, "Error", str(e))
  451. def _toggle_z_source(self):
  452. self.use_slice_thickness = self.z_toggle.isChecked()
  453. self._recompute_views()
  454. def _update_thresholds(self):
  455. self.threshold_brightness = self.brightness_slider.value() / 100.0
  456. self.contours_thr = self.contour_slider.value() / 100.0
  457. self._recompute_views()
  458. def _select_color(self):
  459. col = QColorDialog.getColor()
  460. if col.isValid():
  461. self.image_label.set_current_color(col)
  462. def _set_label(self):
  463. label, ok = QInputDialog.getText(self, 'Set ROI Label', 'Enter label for ROI:')
  464. if ok and label:
  465. self.image_label.set_current_label(label)
  466. def _save_rois(self):
  467. path, _ = QFileDialog.getSaveFileName(self, "Save ROIs", "", "JSON Files (*.json);;All Files (*)")
  468. if not path:
  469. return
  470. roi_data = {}
  471. for slice_idx, rois in self.image_label.rois.items():
  472. roi_data[slice_idx] = []
  473. for roi in rois:
  474. roi_data[slice_idx].append({
  475. 'label': roi['label'],
  476. 'color': roi['color'].name(),
  477. 'points': [(p.x(), p.y()) for p in roi['points']],
  478. })
  479. with open(path, 'w', encoding='utf-8') as f:
  480. json.dump(roi_data, f, ensure_ascii=False, indent=2)
  481. # ---------- Processing ----------
  482. def _recompute_views(self):
  483. # filtration == contours on thresholded image within ROI mask (blue/red views in originals)
  484. self._apply_filtration()
  485. self._apply_segmentation()
  486. def _grab_gray_np(self):
  487. img = self.image_label.image
  488. if img.isNull():
  489. return None
  490. w, h = img.width(), img.height()
  491. ptr = img.bits()
  492. ptr.setsize(img.byteCount())
  493. arr = np.array(ptr).reshape(h, w, 1)
  494. return arr
  495. def _build_mask_from_rois(self, shape):
  496. mask = np.zeros(shape, dtype=np.uint8)
  497. rois = self.image_label.rois.get(self.image_label.slice_index, [])
  498. for roi in rois:
  499. pts = np.array([[p.x(), p.y()] for p in roi['points']], dtype=np.int32)
  500. if pts.shape[0] >= 3:
  501. cv2.fillPoly(mask, [pts], 255)
  502. return mask
  503. def _apply_filtration(self):
  504. arr = self._grab_gray_np()
  505. if arr is None:
  506. return
  507. h, w = arr.shape[:2]
  508. mask = self._build_mask_from_rois((h, w, 1))
  509. masked = arr.copy()
  510. masked[mask != 255] = 0
  511. thr_val = self.threshold_brightness * masked.max() if masked.max() > 0 else 0
  512. seg = np.zeros_like(arr)
  513. seg[(mask == 255) & (arr >= thr_val)] = 255
  514. contours, _ = cv2.findContours(seg.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
  515. color = cv2.cvtColor(arr, cv2.COLOR_GRAY2BGR)
  516. cv2.drawContours(color, contours, -1, (255, 0, 0), 2)
  517. qimg = QImage(color.data, color.shape[1], color.shape[0], QImage.Format_RGB888)
  518. self.filtration_label.setPixmap(QPixmap.fromImage(qimg))
  519. def _apply_segmentation(self):
  520. arr = self._grab_gray_np()
  521. if arr is None:
  522. return
  523. h, w = arr.shape[:2]
  524. mask = self._build_mask_from_rois((h, w, 1))
  525. masked = arr.copy()
  526. masked[mask != 255] = 0
  527. thr_val = self.threshold_brightness * masked.max() if masked.max() > 0 else 0
  528. seg = np.zeros_like(arr)
  529. seg[(mask == 255) & (arr >= thr_val)] = 255
  530. contours, _ = cv2.findContours(seg.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
  531. max_area = max((cv2.contourArea(c) for c in contours), default=0)
  532. area_thr = self.contours_thr * max_area
  533. filtered = [c for c in contours if cv2.contourArea(c) > area_thr]
  534. # Volume
  535. spacing_x, spacing_y = self.image_label.spacing_xy
  536. z = self.image_label.slice_thickness if self.use_slice_thickness else self.image_label.spacing_between_slices
  537. voxel_mm3 = float(spacing_x) * float(spacing_y) * float(z)
  538. pixel_area_sum = sum(cv2.contourArea(c) for c in filtered)
  539. volume_ml = pixel_area_sum * voxel_mm3 / 1000.0
  540. self.image_label.volume_by_slice[self.image_label.slice_index] = volume_ml
  541. total_ml = sum(self.image_label.volume_by_slice.values())
  542. self.pixel_count_label.setText(f"Суммарный объем (мл): {total_ml:.2f}")
  543. # Draw yellow filtered contours and per-slice text
  544. color = cv2.cvtColor(arr, cv2.COLOR_GRAY2BGR)
  545. cv2.drawContours(color, filtered, -1, (0, 255, 255), 2)
  546. cv2.putText(color,
  547. f"Slice: {self.image_label.slice_index} Volume (ml): {volume_ml:.3f}",
  548. (10, 22), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 1)
  549. qimg = QImage(color.data, color.shape[1], color.shape[0], QImage.Format_RGB888)
  550. self.segmentation_label.setPixmap(QPixmap.fromImage(qimg))
  551. def main():
  552. app = QApplication(sys.argv)
  553. w = ROIDrawer()
  554. w.show()
  555. sys.exit(app.exec_())
  556. if __name__ == '__main__':
  557. main()