PyQt5
前言
最近要写一个工具来展示用 UNet 进行医学图像分割的效果,选择了 PyQt5 这个框架,有点太重量级了,用得脑壳疼,但是不能白头疼,要把学到的东西记录下来。
首先打个草图,按照草图来安排界面的布局:
界面编写
首先草草地做一个界面出来。
主窗口
PyQt5 的 QtWidgets 包里有个类叫 QMainWindow,用它作为 App 的主窗口,为了将主窗口改成我们想要的形状,我们另外定义一个类,并继承 QMainWindow。
创建 DemoMainWindow.py 文件:
from PyQt5 import QtWidgets
class DemoMainWindow(QtWidgets.QMainWindow):
def __init__(self, central_widget:QtWidgets.QWidget = None):
super(DemoMainWindow, self).__init__(None)
top_bar = self.menuBar() # 获取主窗口的菜单栏
bar_item_file = top_bar.addMenu("文件") # 给菜单栏添加“文件”选项框
# 给“文件”选项框添加选项
file_item_open = bar_item_file.addAction("打开")
file_item_save = bar_item_file.addAction("保存")
file_item_quit = bar_item_file.addAction("退出")
# 为选项设置快捷键
file_item_open.setShortcut("Ctrl+O")
file_item_save.setShortcut("Ctrl+S")
file_item_quit.setShortcut("Ctrl+Q")
# 为主窗口设置中心部件
if central_widget is not None:
self.setCentralWidget(central_widget)
# 为主窗口设置窗口尺寸和标题
self.resize(1280, 720)
self.setWindowTitle('Demo')
布局
PyQt5 中的部件(QWidget)需要设置其布局,主窗口已经有自己的预设布局,其它自定义的部件需要另外设置布局。
布局(QLayout)中可以通过 addLayout 方法添加子布局,或者通过 addWidget 方法添加部件,为了方便代码的编写,最好为每个部件包装一层布局。
主窗口中要设置中心部件,我们自己来 DIY 这个中心部件。
创建 CentralWidget.py 文件:
from PyQt5 import QtWidgets
class DemoCentralWidget(QtWidgets.QWidget):
def __init__(self, introduce:QtWidgets.QLayout = None, presentation:QtWidgets.QLayout = None):
super(DemoCentralWidget, self).__init__()
layout = QtWidgets.QVBoxLayout() # 定义一个垂直布局,这个布局作为中心部件的主布局
introduce_layout = QtWidgets.QHBoxLayout() # 为介绍框定义一个水平布局
presentation_layout = QtWidgets.QHBoxLayout() # 为展示框定义一个水平布局
# 为介绍框和展示框定义子布局,这里通过参数的传入进行解耦
if introduce is not None:
introduce_layout.addLayout(introduce)
layout.addLayout(introduce_layout)
if presentation is not None:
presentation_layout.addLayout(presentation)
layout.addLayout(presentation_layout)
self.setLayout(layout) # 为当前这个中心部件设置布局
一些辅助函数
创建 SubWidgets.py 文件:
from PyQt5 import QtWidgets, QtCore, QtGui
from functools import singledispatch
from numpy import ndarray
def build_introduce(width:int=1000, height:int=150, border:QtWidgets.QFrame.Shape=QtWidgets.QFrame.Box, align:QtCore.Qt.AlignmentFlag=QtCore.Qt.AlignLeft) -> QtWidgets.QLayout:
introduce = QtWidgets.QLabel()
introduce.setMaximumSize(width, height)
introduce.resize(width, height)
if border is not None:
introduce.setFrameShape(border)
introduce.setAlignment(align)
layout = QtWidgets.QHBoxLayout()
layout.addWidget(introduce)
return layout
@singledispatch
def build_image_box(data:str | ndarray, width:int=512, height:int=512) -> QtWidgets.QHBoxLayout:
raise RuntimeError(f'{type(data)} is not supported!')
@build_image_box.register(str)
def build_image_box_by_file(data:str, width:int=512, height:int=512) -> QtWidgets.QHBoxLayout:
image_box = QtWidgets.QLabel()
image_box.setMaximumSize(width, height)
image_box.resize(width, height)
image_box.setFrameShape(QtWidgets.QFrame.Box)
image_box.setAlignment(QtCore.Qt.AlignCenter)
pixmap = QtGui.QPixmap(data)
pixmap = pixmap.scaled(64, 64, QtCore.Qt.KeepAspectRatio)
image_box.setPixmap(pixmap)
layout = QtWidgets.QHBoxLayout()
layout.addWidget(image_box)
return layout
@build_image_box.register(ndarray)
def build_image_box_by_ndarray(data:ndarray, width:int=512, height:int=512) -> QtWidgets.QHBoxLayout:
image_box = QtWidgets.QLabel()
image_box.setMaximumSize(width, height)
image_box.resize(width, height)
image_box.setFrameShape(QtWidgets.QFrame.Box)
image_box.setAlignment(QtCore.Qt.AlignCenter)
image = QtGui.QImage(data, data.shape[0], data.shape[1], QtGui.QImage.Format_RGB32)
pixmap = QtGui.QPixmap.fromImage(image)
# pixmap = pixmap.scaled(128, 128, QtCore.Qt.KeepAspectRatio)
image_box.setPixmap(pixmap)
layout = QtWidgets.QHBoxLayout()
layout.addWidget(image_box)
return layout
def build_attribute_box(surf:float = None, grayscale:float = None) -> QtWidgets.QVBoxLayout:
layout = QtWidgets.QVBoxLayout()
surf_label = QtWidgets.QLabel()
grayscale_label = QtWidgets.QLabel()
if surf is not None:
surf_label.setText(f'Surf area:\t{surf}')
else:
surf_label.setText(f'Surf area:')
if grayscale is not None:
grayscale_label.setText(f'Grayscale:\t{grayscale}')
else:
grayscale_label.setText(f'Grayscale:')
layout.addWidget(surf_label)
layout.addWidget(grayscale_label)
return layout
def build_presentation(input_box:QtWidgets.QLayout, output_box:QtWidgets.QLayout, attribute_box:QtWidgets.QLayout) -> QtWidgets.QHBoxLayout:
layout = QtWidgets.QHBoxLayout()
layout.addLayout(input_box)
layout.addLayout(output_box)
layout.addLayout(attribute_box)
return layout
查看效果
创建 App.py 文件:
if __name__ == '__main__':
import sys
app = QApplication(sys.argv)
introduce = build_introduce(border=None)
input_box = build_image_box('file.png')
output_box = build_image_box('image.png')
attribute_box = build_attribute_box()
presentation = build_presentation(input_box, output_box, attribute_box)
central_widget = DemoCentralWidget(introduce, presentation)
main_window = DemoMainWindow(central_widget)
main_window.show()
print(app.exec())
运行 App.py
功能编写
菜单栏按钮和文件拖拽功能
主窗口的菜单栏添加的是 QAction 对象,QAction 常用信号包括 triggered(点击)、hovered(悬停)、toggled(选中)、changed(状态变化)等。这些信号的类是 QtCore.pyqtSignal,可以通过 connect 方法绑定响应动作。
此外,还要实现一个文件拖放的功能,通过 setAcceptDrops(True) 启用该功能,另外还必须实现 dragEnterEvent(self, evn) 这个抽象接口才能拖拽文件。
修改 DemoMainWindow.py 的内容:
from PyQt5 import QtWidgets, QtGui
class DemoMainWindow(QtWidgets.QMainWindow):
def __init__(self, central_widget:QtWidgets.QWidget = None):
super(DemoMainWindow, self).__init__(None)
top_bar = self.menuBar()
bar_item_file = top_bar.addMenu('文件')
file_item_open = bar_item_file.addAction('打开')
file_item_save = bar_item_file.addAction('保存')
file_item_quit = bar_item_file.addAction('退出')
file_item_open.setShortcut('Ctrl+O')
file_item_save.setShortcut('Ctrl+S')
file_item_quit.setShortcut('Ctrl+Q')
file_item_open.triggered.connect(self.open_file)
if central_widget is not None:
self.setCentralWidget(central_widget)
self.resize(1280, 720)
self.setWindowTitle('Demo')
self.setAcceptDrops(True)
def open_file(self):
file,_ = QtWidgets.QFileDialog.getOpenFileName(self, 'Dicom文件选择', '.', '*.dcm')
print(file)
def dragEnterEvent(self, evn):
evn.accept()
def dropEvent(self, evn):
file = evn.mimeData().text().replace('file:///', '')
print(file)
算了不写了
写累了,直接贴代码了。省略号的地方需要根据需要进行补充。
App.py
from DemoMainWindow import DemoMainWindow
from CentralWidget import DemoCentralWidget
from PyQt5.QtWidgets import QApplication
from SubWidgets import *
if __name__ == '__main__':
import sys
app = QApplication(sys.argv)
introduce = build_introduce(content='说明:\nCtrl+O 打开 dcm 文件后将自动展现分割结果\n或者将文件拖入窗口', border=None)
input_box = build_image_box('....png')
input_box.setObjectName('input_box')
# print(input_box)
output_box = build_image_box('....png')
output_box.setObjectName('output_box')
attribute_box = build_attribute_box()
attribute_box.setObjectName('attribute_box')
presentation = build_presentation(input_box, output_box, attribute_box)
central_widget = DemoCentralWidget(introduce, presentation)
main_window = DemoMainWindow(central_widget)
main_window.show()
print(app.exec())
CentralWidget.py
from PyQt5 import QtWidgets
class DemoCentralWidget(QtWidgets.QWidget):
def __init__(self, introduce:QtWidgets.QLayout = None, presentation:QtWidgets.QLayout = None):
super(DemoCentralWidget, self).__init__()
layout = QtWidgets.QVBoxLayout()
introduce_layout = QtWidgets.QHBoxLayout()
presentation_layout = QtWidgets.QHBoxLayout()
if introduce is not None:
introduce_layout.addLayout(introduce)
layout.addLayout(introduce_layout)
if presentation is not None:
presentation_layout.addLayout(presentation)
layout.addLayout(presentation_layout)
self.setLayout(layout)
DemoMainWindow.py
from PyQt5 import QtWidgets, QtGui
from pydicom import dcmread
from PIL import Image
from numpy import uint8, int32, expand_dims, array, clip, sum, concatenate
from unets import NestedUNet
from torch import Tensor, cuda, load, ones_like, zeros_like, where, float32
from torchvision import transforms
from collections import OrderedDict
class DemoMainWindow(QtWidgets.QMainWindow):
def __init__(self, central_widget:QtWidgets.QWidget = None, threshold:float = 0.9):
super(DemoMainWindow, self).__init__(None)
top_bar = self.menuBar()
bar_item_file = top_bar.addMenu('文件')
file_item_open = bar_item_file.addAction('打开')
file_item_save = bar_item_file.addAction('保存')
file_item_quit = bar_item_file.addAction('退出')
file_item_open.setShortcut('Ctrl+O')
file_item_save.setShortcut('Ctrl+S')
file_item_quit.setShortcut('Ctrl+Q')
file_item_open.triggered.connect(self.open_file)
if central_widget is not None:
self.setCentralWidget(central_widget)
self.resize(1280, 720)
self.setWindowTitle('Demo')
self.threshold = threshold
unet = ...
self.device = 'cuda:0' if cuda.is_available() else 'cpu'
state_dict = load('...')
unet.load_state_dict(state_dict )
self.unet = unet.to(self.device)
self.transforms = transforms.Compose([transforms.ToTensor(), transforms.Resize((512, 512), antialias=True)])
self.topil = transforms.ToPILImage()
self.setAcceptDrops(True)
def open_file(self):
central_widget = self.centralWidget()
input_box = central_widget.findChild(QtWidgets.QLayout, 'input_box')
output_box = central_widget.findChild(QtWidgets.QLayout, 'output_box')
attribute_box = central_widget.findChild(QtWidgets.QLayout, 'attribute_box')
input_image_box = input_box.itemAt(0).widget()
output_image_box = output_box.itemAt(0).widget()
attribute_box_item = attribute_box.itemAt(0).widget()
attribute_box_surf = attribute_box.itemAt(1).widget()
attribute_box_grayscale = attribute_box.itemAt(2).widget()
file,_ = QtWidgets.QFileDialog.getOpenFileName(self, 'Dicom文件选择', '.', '*.dcm')
attribute_box_item.setText(f'Item: {file}')
dcm_file = dcmread(file)
space_x, space_y = dcm_file.PixelSpacing[0], dcm_file.PixelSpacing[1]
dcm_array = dcm_file.pixel_array
dcm_array = dcm_array.clip(-1000, 400)
dcm_array = dcm_array - dcm_array.min()
dcm_array = dcm_array/(dcm_array.max())
dcm_array = dcm_array*255
image = Image.fromarray(uint8(dcm_array)).convert('L')
image.save(r'.temp/input_dcm.jpg')
image = expand_dims(array(image), 2)
input_image_box.setPixmap(QtGui.QPixmap(r'.temp/input_dcm.jpg'))
data:Tensor = self.transforms(dcm_array)
data = data.unsqueeze(0).to(self.device)
if self.device != 'cpu':
data = data.type(cuda.FloatTensor)
else:
data = data.type(float32)
predicts = self.unet(data)
predicts = predicts - predicts.min()
predicts = (predicts + 1e-7)/(predicts.max() + 1e-7)
ones_ref = ones_like(predicts)
zeros_ref = zeros_like(predicts)
predicts = where(predicts >= self.threshold, ones_ref, predicts)
predicts = where(predicts < self.threshold, zeros_ref, predicts).detach().cpu()
mask = predicts.squeeze(0)
mask_area = mask.count_nonzero().item()
surf_area = mask_area*(space_x*space_y)*0.01
mask = self.topil(mask)
mask.save(r'.temp/output_mask.jpg')
mask = expand_dims(array(mask), 2)
image = int32(image)
mask = int32(mask)
mask_ones = clip(mask, 0, 1)
grayscale_array = expand_dims(dcm_file.pixel_array, 2)*mask_ones
grayscale = (sum(grayscale_array))/(mask_area+1e-7)
red = clip(image + mask, 0, 255)
green = clip(image - mask, 0, 255)
blue = clip(image - mask, 0, 255)
output = uint8(concatenate((red, green, blue), 2))
Image.fromarray(output).convert('RGB').save(r'.temp/output_dcm.jpg')
output_image_box.setPixmap(QtGui.QPixmap(r'.temp/output_dcm.jpg'))
attribute_box_surf.setText(f'Surf: {surf_area:.4f} cm²')
attribute_box_grayscale.setText(f'Mean: {grayscale:.4f}')
def dragEnterEvent(self, evn):
evn.accept()
def dropEvent(self, evn):
file = evn.mimeData().text().replace('file:///', '')
if file[-4:] != '.dcm':
return
central_widget = self.centralWidget()
input_box = central_widget.findChild(QtWidgets.QLayout, 'input_box')
output_box = central_widget.findChild(QtWidgets.QLayout, 'output_box')
attribute_box = central_widget.findChild(QtWidgets.QLayout, 'attribute_box')
input_image_box = input_box.itemAt(0).widget()
output_image_box = output_box.itemAt(0).widget()
attribute_box_item = attribute_box.itemAt(0).widget()
attribute_box_surf = attribute_box.itemAt(1).widget()
attribute_box_grayscale = attribute_box.itemAt(2).widget()
attribute_box_item.setText(f'Item: {file}')
dcm_file = dcmread(file)
space_x, space_y = dcm_file.PixelSpacing[0], dcm_file.PixelSpacing[1]
dcm_array = dcm_file.pixel_array
dcm_array = dcm_array.clip(-1000, 400)
dcm_array = dcm_array - dcm_array.min()
dcm_array = dcm_array/(dcm_array.max())
dcm_array = dcm_array*255
image = Image.fromarray(uint8(dcm_array)).convert('L')
image.save(r'.temp/input_dcm.jpg')
image = expand_dims(array(image), 2)
input_image_box.setPixmap(QtGui.QPixmap(r'.temp/input_dcm.jpg'))
data:Tensor = self.transforms(dcm_array)
data = data.unsqueeze(0).to(self.device)
if self.device != 'cpu':
data = data.type(cuda.FloatTensor)
else:
data = data.type(float32)
predicts = self.unet(data)
predicts = predicts - predicts.min()
predicts = (predicts + 1e-7)/(predicts.max() + 1e-7)
ones_ref = ones_like(predicts)
zeros_ref = zeros_like(predicts)
predicts = where(predicts >= self.threshold, ones_ref, predicts)
predicts = where(predicts < self.threshold, zeros_ref, predicts).detach().cpu()
mask = predicts.squeeze(0)
mask_area = mask.count_nonzero().item()
surf_area = mask_area*(space_x*space_y)*0.01
mask = self.topil(mask)
mask.save(r'.temp/output_mask.jpg')
mask = expand_dims(array(mask), 2)
image = int32(image)
mask = int32(mask)
mask_ones = clip(mask, 0, 1)
grayscale_array = expand_dims(dcm_file.pixel_array, 2)*mask_ones
grayscale = (sum(grayscale_array))/(mask_area+1e-7)
red = clip(image + mask, 0, 255)
green = clip(image - mask, 0, 255)
blue = clip(image - mask, 0, 255)
output = uint8(concatenate((red, green, blue), 2))
Image.fromarray(output).convert('RGB').save(r'.temp/output_dcm.jpg')
output_image_box.setPixmap(QtGui.QPixmap(r'.temp/output_dcm.jpg'))
attribute_box_surf.setText(f'Surf: {surf_area:.4f} cm²')
attribute_box_grayscale.setText(f'Mean: {grayscale:.4f}')
SubWidgets.py
from PyQt5 import QtWidgets, QtCore, QtGui
from functools import singledispatch
from numpy import ndarray
def build_introduce(width:int=1000, height:int=150, content:str='', border:QtWidgets.QFrame.Shape=QtWidgets.QFrame.Box, align:QtCore.Qt.AlignmentFlag=QtCore.Qt.AlignLeft) -> QtWidgets.QLayout:
introduce = QtWidgets.QLabel()
introduce.setMaximumSize(width, height)
introduce.resize(width, height)
if border is not None:
introduce.setFrameShape(border)
introduce.setAlignment(align)
introduce.setText(content)
introduce.setFont(QtGui.QFont('Arial', 16))
layout = QtWidgets.QHBoxLayout()
layout.addWidget(introduce)
return layout
@singledispatch
def build_image_box(data:str | ndarray, width:int=512, height:int=512) -> QtWidgets.QHBoxLayout:
raise RuntimeError(f'{type(data)} is not supported!')
@build_image_box.register(str)
def build_image_box_by_file(data:str, width:int=512, height:int=512) -> QtWidgets.QHBoxLayout:
image_box = QtWidgets.QLabel()
image_box.setMaximumSize(width, height)
image_box.resize(width, height)
image_box.setFrameShape(QtWidgets.QFrame.Box)
image_box.setAlignment(QtCore.Qt.AlignCenter)
pixmap = QtGui.QPixmap(data)
pixmap = pixmap.scaled(64, 64, QtCore.Qt.KeepAspectRatio)
image_box.setPixmap(pixmap)
layout = QtWidgets.QHBoxLayout()
layout.addWidget(image_box)
return layout
@build_image_box.register(ndarray)
def build_image_box_by_ndarray(data:ndarray, width:int=512, height:int=512) -> QtWidgets.QHBoxLayout:
image_box = QtWidgets.QLabel()
image_box.setMaximumSize(width, height)
image_box.resize(width, height)
image_box.setFrameShape(QtWidgets.QFrame.Box)
image_box.setAlignment(QtCore.Qt.AlignCenter)
image = QtGui.QImage(data, data.shape[0], data.shape[1], QtGui.QImage.Format_Grayscale8)
pixmap = QtGui.QPixmap.fromImage(image)
image_box.setPixmap(pixmap)
layout = QtWidgets.QHBoxLayout()
layout.addWidget(image_box)
return layout
def build_attribute_box(item:str = None, surf:float = None, grayscale:float = None) -> QtWidgets.QVBoxLayout:
layout = QtWidgets.QVBoxLayout()
item_label = QtWidgets.QLabel()
surf_label = QtWidgets.QLabel()
grayscale_label = QtWidgets.QLabel()
if item is not None:
item_label.setText(f'Item: {item_label}')
else:
item_label.setText(f'Item:')
item_label.setFont(QtGui.QFont('Arial', 16))
if surf is not None:
surf_label.setText(f'Surf: {surf}')
else:
surf_label.setText(f'Surf:')
surf_label.setFont(QtGui.QFont('Arial', 16))
if grayscale is not None:
grayscale_label.setText(f'Mean: {grayscale}')
else:
grayscale_label.setText(f'Mean:')
grayscale_label.setFont(QtGui.QFont('Arial', 16))
layout.addWidget(item_label)
layout.addWidget(surf_label)
layout.addWidget(grayscale_label)
return layout
def build_presentation(input_box:QtWidgets.QLayout, output_box:QtWidgets.QLayout, attribute_box:QtWidgets.QLayout) -> QtWidgets.QHBoxLayout:
layout = QtWidgets.QHBoxLayout()
layout.addLayout(input_box)
layout.addLayout(output_box)
layout.addLayout(attribute_box)
return layout