Skip to content

Commit 0c55e31

Browse files
authored
【修复】新增对DICOM图像的支持 (#310)
* 修复对DICOM图像加载逻辑,允许从DICOM文件读取并应用窗口设置 * 新增DICOM的测试图片 * 统一了处理入口,标准化像素值,确保三通道输出 * 修复了单通道png图无法正常显示和显示的通道图与实际图片不一致的问题
1 parent 1265e20 commit 0c55e31

File tree

5 files changed

+135
-161
lines changed

5 files changed

+135
-161
lines changed

ISAT/annotation.py

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,56 @@
66
import numpy as np
77
from json import load, dump
88
from typing import List, Union
9+
import pydicom
910

1011
__all__ = ['Object', 'Annotation']
1112

13+
14+
def get_windowed_image(ds):
15+
# Apply Rescale Slope and Rescale Intercept if they exist
16+
pixel_array = ds.pixel_array.astype(float)
17+
rescale_slope = ds.get("RescaleSlope", 1)
18+
rescale_intercept = ds.get("RescaleIntercept", 0)
19+
if rescale_slope != 1 or rescale_intercept != 0:
20+
pixel_array = pixel_array * rescale_slope + rescale_intercept
21+
22+
# Check for windowing information in the DICOM metadata
23+
window_center = ds.get("WindowCenter", None)
24+
window_width = ds.get("WindowWidth", None)
25+
26+
if window_center is None or window_width is None:
27+
# If no windowing info, use the full dynamic range
28+
window_min = pixel_array.min()
29+
window_max = pixel_array.max()
30+
else:
31+
# Handle possible multi-valued tags by taking the first value
32+
if isinstance(window_center, pydicom.multival.MultiValue):
33+
window_center = window_center[0]
34+
if isinstance(window_width, pydicom.multival.MultiValue):
35+
window_width = window_width[0]
36+
37+
window_min = window_center - window_width / 2
38+
window_max = window_center + window_width / 2
39+
40+
# Apply windowing
41+
pixel_array = np.clip(pixel_array, window_min, window_max)
42+
43+
# Normalize to 0-255
44+
if window_max > window_min:
45+
pixel_array = ((pixel_array - window_min) / (window_max - window_min)) * 255.0
46+
else: # Handle case where all pixels are the same
47+
pixel_array.fill(128)
48+
49+
# Handle Photometric Interpretation
50+
photometric_interpretation = ds.get("PhotometricInterpretation", "MONOCHROME2")
51+
if photometric_interpretation == "MONOCHROME1":
52+
pixel_array = 255.0 - pixel_array
53+
54+
# Convert to 8-bit unsigned integer
55+
image_8bit = pixel_array.astype(np.uint8)
56+
57+
return image_8bit
58+
1259
class Object:
1360
r"""A class to represent an annotation object.
1461
@@ -57,20 +104,48 @@ def __init__(self, image_path:str, label_path:str):
57104
self.img_name = img_name
58105
self.label_path = label_path
59106
self.note = ''
107+
self.image_path = image_path
108+
self._img_data = None
60109

61-
image = np.array(Image.open(image_path))
62-
if image.ndim == 3:
63-
self.height, self.width, self.depth = image.shape
64-
elif image.ndim == 2:
65-
self.height, self.width = image.shape
66-
self.depth = 0
67-
else:
68-
self.height, self.width, self.depth = image.shape[:, :3]
69-
print('Warning: Except image has 2 or 3 ndim, but get {}.'.format(image.ndim))
70-
del image
110+
# Defer image loading to get_img_data()
111+
self.width = 0
112+
self.height = 0
113+
self.depth = 0
71114

72115
self.objects:List[Object, ] = []
73116

117+
def get_img_data(self, to_rgb=False):
118+
if self._img_data is None:
119+
if self.image_path.lower().endswith('.dcm'):
120+
ds = pydicom.dcmread(self.image_path)
121+
image_data = get_windowed_image(ds)
122+
self._img_data = np.stack([image_data, image_data, image_data], axis=-1)
123+
else:
124+
self._img_data = Image.open(self.image_path)
125+
126+
if self.width == 0 or self.height == 0:
127+
if isinstance(self._img_data, np.ndarray):
128+
image = self._img_data
129+
if image.ndim == 3:
130+
self.height, self.width, self.depth = image.shape
131+
elif image.ndim == 2:
132+
self.height, self.width = image.shape
133+
self.depth = 1
134+
else: # PIL Image
135+
self.width, self.height = self._img_data.size
136+
self.depth = len(self._img_data.getbands())
137+
138+
if to_rgb:
139+
if isinstance(self._img_data, np.ndarray):
140+
return self._img_data # DICOM data is already RGB numpy array
141+
else: # PIL Image
142+
return np.array(self._img_data.convert('RGB'))
143+
else:
144+
if isinstance(self._img_data, np.ndarray):
145+
return self._img_data
146+
else: # PIL Image
147+
return np.array(self._img_data)
148+
74149
def load_annotation(self):
75150
r"""
76151
Load annotation from self.label_path

ISAT/widgets/canvas.py

Lines changed: 15 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -90,85 +90,21 @@ def load_image(self, image_path: str):
9090
if self.mainwindow.use_segment_anything:
9191
self.mainwindow.segany.reset_image()
9292

93-
if image_path.lower().endswith('.dcm'):
94-
ds = pydicom.dcmread(image_path)
95-
# Apply Rescale Slope and Rescale Intercept if they exist
96-
pixel_array = ds.pixel_array.astype(float)
97-
rescale_slope = ds.get("RescaleSlope", 1)
98-
rescale_intercept = ds.get("RescaleIntercept", 0)
99-
if rescale_slope != 1 or rescale_intercept != 0:
100-
pixel_array = pixel_array * rescale_slope + rescale_intercept
101-
102-
# Check for windowing information in the DICOM metadata
103-
window_center = ds.get("WindowCenter", None)
104-
window_width = ds.get("WindowWidth", None)
105-
106-
if window_center is None or window_width is None:
107-
# If no windowing info, use the full dynamic range
108-
window_min = pixel_array.min()
109-
window_max = pixel_array.max()
110-
else:
111-
# Handle possible multi-valued tags by taking the first value
112-
if isinstance(window_center, pydicom.multival.MultiValue):
113-
window_center = window_center[0]
114-
if isinstance(window_width, pydicom.multival.MultiValue):
115-
window_width = window_width[0]
116-
117-
window_min = window_center - window_width / 2
118-
window_max = window_center + window_width / 2
119-
120-
# Apply windowing
121-
pixel_array = np.clip(pixel_array, window_min, window_max)
122-
123-
# Normalize to 0-255
124-
if window_max > window_min:
125-
pixel_array = ((pixel_array - window_min) / (window_max - window_min)) * 255.0
126-
else: # Handle case where all pixels are the same
127-
pixel_array.fill(128)
128-
129-
# Handle Photometric Interpretation
130-
photometric_interpretation = ds.get("PhotometricInterpretation", "MONOCHROME2")
131-
if photometric_interpretation == "MONOCHROME1":
132-
pixel_array = 255.0 - pixel_array
133-
134-
# Convert to 8-bit unsigned integer
135-
image_8bit = pixel_array.astype(np.uint8)
136-
137-
# Convert to RGB if needed
138-
if len(image_8bit.shape) == 2:
139-
image_data = np.stack([image_8bit] * 3, axis=-1)
140-
else:
141-
image_data = image_8bit
142-
143-
self.image_data = image_data
144-
height, width = image_data.shape[:2]
145-
bytes_per_line = 3 * width
146-
qimage = QtGui.QImage(image_data.data, width, height, bytes_per_line, QtGui.QImage.Format_RGB888)
147-
pixmap = QtGui.QPixmap(qimage)
148-
149-
self.image_item = QtWidgets.QGraphicsPixmapItem()
150-
self.image_item.setZValue(0)
151-
self.addItem(self.image_item)
152-
self.mask_item = QtWidgets.QGraphicsPixmapItem()
153-
self.mask_item.setZValue(1)
154-
self.addItem(self.mask_item)
155-
156-
self.image_item.setPixmap(pixmap)
157-
self.setSceneRect(self.image_item.boundingRect())
158-
else:
159-
image = Image.open(image_path)
160-
if self.mainwindow.can_be_annotated:
161-
image = image.convert('RGB')
162-
self.image_data = np.array(image)
163-
self.image_item = QtWidgets.QGraphicsPixmapItem()
164-
self.image_item.setZValue(0)
165-
self.addItem(self.image_item)
166-
self.mask_item = QtWidgets.QGraphicsPixmapItem()
167-
self.mask_item.setZValue(1)
168-
self.addItem(self.mask_item)
169-
170-
self.image_item.setPixmap(QtGui.QPixmap(image_path))
171-
self.setSceneRect(self.image_item.boundingRect())
93+
self.image_data = self.mainwindow.current_label.get_img_data(to_rgb=True)
94+
95+
self.image_item = QtWidgets.QGraphicsPixmapItem()
96+
self.image_item.setZValue(0)
97+
self.addItem(self.image_item)
98+
self.mask_item = QtWidgets.QGraphicsPixmapItem()
99+
self.mask_item.setZValue(1)
100+
self.addItem(self.mask_item)
101+
102+
height, width, channel = self.image_data.shape
103+
bytes_per_line = channel * width
104+
q_image = QtGui.QImage(self.image_data.data, width, height, bytes_per_line,
105+
QtGui.QImage.Format_RGB888)
106+
self.image_item.setPixmap(QtGui.QPixmap.fromImage(q_image))
107+
self.setSceneRect(self.image_item.boundingRect())
172108
self.change_mode_to_view()
173109

174110
def unload_image(self):

ISAT/widgets/files_dock_widget.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def dropEvent(self, event):
7070
suffixs = tuple(
7171
['{}'.format(fmt.data().decode('ascii').lower()) for fmt in QtGui.QImageReader.supportedImageFormats()])
7272
for f in os.listdir(dir):
73-
if f.lower().endswith(suffixs):
73+
if f.lower().endswith(suffixs) or f.lower().endswith('.dcm'):
7474
# f = os.path.join(dir, f)
7575
files.append(f)
7676
files = sorted(files)
@@ -107,7 +107,7 @@ def dropEvent(self, event):
107107

108108
dir, file = os.path.split(path)
109109
files = []
110-
if path.lower().endswith(suffixs):
110+
if path.lower().endswith(suffixs) or path.lower().endswith('.dcm'):
111111
files = [file]
112112

113113
self.mainwindow.files_list = files

ISAT/widgets/mainwindow.py

Lines changed: 33 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -40,52 +40,7 @@
4040
import requests
4141
import orjson
4242
import pydicom
43-
44-
45-
def get_windowed_image(ds):
46-
# Apply Rescale Slope and Rescale Intercept if they exist
47-
pixel_array = ds.pixel_array.astype(float)
48-
rescale_slope = ds.get("RescaleSlope", 1)
49-
rescale_intercept = ds.get("RescaleIntercept", 0)
50-
if rescale_slope != 1 or rescale_intercept != 0:
51-
pixel_array = pixel_array * rescale_slope + rescale_intercept
52-
53-
# Check for windowing information in the DICOM metadata
54-
window_center = ds.get("WindowCenter", None)
55-
window_width = ds.get("WindowWidth", None)
56-
57-
if window_center is None or window_width is None:
58-
# If no windowing info, use the full dynamic range
59-
window_min = pixel_array.min()
60-
window_max = pixel_array.max()
61-
else:
62-
# Handle possible multi-valued tags by taking the first value
63-
if isinstance(window_center, pydicom.multival.MultiValue):
64-
window_center = window_center[0]
65-
if isinstance(window_width, pydicom.multival.MultiValue):
66-
window_width = window_width[0]
67-
68-
window_min = window_center - window_width / 2
69-
window_max = window_center + window_width / 2
70-
71-
# Apply windowing
72-
pixel_array = np.clip(pixel_array, window_min, window_max)
73-
74-
# Normalize to 0-255
75-
if window_max > window_min:
76-
pixel_array = ((pixel_array - window_min) / (window_max - window_min)) * 255.0
77-
else: # Handle case where all pixels are the same
78-
pixel_array.fill(128)
79-
80-
# Handle Photometric Interpretation
81-
photometric_interpretation = ds.get("PhotometricInterpretation", "MONOCHROME2")
82-
if photometric_interpretation == "MONOCHROME1":
83-
pixel_array = 255.0 - pixel_array
84-
85-
# Convert to 8-bit unsigned integer
86-
image_8bit = pixel_array.astype(np.uint8)
87-
88-
return image_8bit
43+
from ISAT.annotation import get_windowed_image
8944

9045

9146
class QtBoxStyleProgressBar(QtWidgets.QProgressBar):
@@ -254,7 +209,7 @@ def run(self):
254209

255210
image_path = os.path.join(self.mainwindow.image_root, self.mainwindow.files_list[index])
256211

257-
image_data = np.array(Image.open(image_path).convert('RGB'))
212+
image_data = self.mainwindow.current_label.get_img_data(to_rgb=True)
258213
try:
259214
features, original_size, input_size = self.sam_encoder(image_data)
260215
except Exception as e:
@@ -1227,17 +1182,31 @@ def show_image(self, index: int, zoomfit: bool=True):
12271182
self.plugin_manager_dialog.trigger_before_image_open(file_path)
12281183

12291184
if file_path.lower().endswith('.dcm'):
1230-
ds = pydicom.dcmread(file_path)
1231-
image_data = Image.fromarray(get_windowed_image(ds))
12321185
self.can_be_annotated = True
12331186
else:
1234-
image_data = Image.open(file_path)
1235-
png_palette = image_data.getpalette()
1236-
if png_palette is not None and file_path.endswith('.png'):
1237-
self.statusbar.showMessage('This image might be a label image in VOC format.')
1238-
self.can_be_annotated = False
1239-
else:
1240-
self.can_be_annotated = True
1187+
try:
1188+
image = Image.open(file_path)
1189+
png_palette = image.getpalette()
1190+
if png_palette is not None and file_path.endswith('.png'):
1191+
self.statusbar.showMessage('This image might be a label image in VOC format.')
1192+
self.can_be_annotated = False
1193+
else:
1194+
self.can_be_annotated = True
1195+
except Exception as e:
1196+
# stausbar show error
1197+
self.statusbar.showMessage(str(e), 5000)
1198+
return
1199+
1200+
# load label
1201+
if self.can_be_annotated:
1202+
self.current_group = 1
1203+
_, name = os.path.split(file_path)
1204+
label_path = os.path.join(self.label_root, '.'.join(name.split('.')[:-1]) + '.json')
1205+
self.current_label = Annotation(file_path, label_path)
1206+
# 载入数据
1207+
self.current_label.load_annotation()
1208+
# get image data and info
1209+
self.current_label.get_img_data()
12411210

12421211
if self.can_be_annotated:
12431212
self.actionPolygon.setEnabled(True)
@@ -1262,28 +1231,22 @@ def show_image(self, index: int, zoomfit: bool=True):
12621231
self.view.zoomfit()
12631232

12641233
# 判断图像是否旋转
1265-
exif_info = image_data.getexif()
1266-
if exif_info and exif_info.get(274, 1) != 1:
1267-
warning_info = '这幅图像包含EXIF元数据,且图像的方向已被旋转.\n建议去除EXIF信息后再进行标注\n你可以使用[菜单栏]-[工具]-[处理exif标签]功能处理图像的旋转问题。'\
1268-
if self.cfg['software']['language'] == 'zh' \
1269-
else 'This image has EXIF metadata, and the image orientation is rotated.\nSuggest labeling after removing the EXIF metadata.\nYou can use the function of [Process EXIF tag] in [Tools] in [Menu bar] to deal with the problem of images.'
1270-
QtWidgets.QMessageBox.warning(self, 'Warning', warning_info, QtWidgets.QMessageBox.Ok)
1234+
if not file_path.lower().endswith('.dcm'):
1235+
image_data = Image.open(file_path)
1236+
exif_info = image_data.getexif()
1237+
if exif_info and exif_info.get(274, 1) != 1:
1238+
warning_info = '这幅图像包含EXIF元数据,且图像的方向已被旋转.\n建议去除EXIF信息后再进行标注\n你可以使用[菜单栏]-[工具]-[处理exif标签]功能处理图像的旋转问题。'\
1239+
if self.cfg['software']['language'] == 'zh' \
1240+
else 'This image has EXIF metadata, and the image orientation is rotated.\nSuggest labeling after removing the EXIF metadata.\nYou can use the function of [Process EXIF tag] in [Tools] in [Menu bar] to deal with the problem of images.'
1241+
QtWidgets.QMessageBox.warning(self, 'Warning', warning_info, QtWidgets.QMessageBox.Ok)
12711242

12721243
if self.use_segment_anything and self.can_be_annotated:
12731244
self.segany.reset_image()
12741245
self.seganythread.index = index
12751246
self.seganythread.start()
12761247
self.SeganyEnabled()
12771248

1278-
# load label
12791249
if self.can_be_annotated:
1280-
self.current_group = 1
1281-
_, name = os.path.split(file_path)
1282-
label_path = os.path.join(self.label_root, '.'.join(name.split('.')[:-1]) + '.json')
1283-
self.current_label = Annotation(file_path, label_path)
1284-
# 载入数据
1285-
self.current_label.load_annotation()
1286-
12871250
for object in self.current_label.objects:
12881251
try:
12891252
group = int(object.group)

example/images/image-00220.dcm

515 KB
Binary file not shown.

0 commit comments

Comments
 (0)