HeshamAI commited on
Commit
682b3d5
·
verified ·
1 Parent(s): f837c03

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +732 -215
app.py CHANGED
@@ -3,279 +3,796 @@ import cv2
3
  import numpy as np
4
  import pandas as pd
5
  import pydicom
6
- import tempfile
7
- import os
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  class DicomAnalyzer:
10
  def __init__(self):
11
  self.results = []
12
- self.circle_diameter = 9
13
- self.current_image1 = None
14
- self.image_display1 = None
15
- self.dicom_data1 = None
16
- self.marks1 = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  def load_dicom(self, file):
19
  try:
20
  if file is None:
21
- return None, None, None
22
-
23
- dicom_data = pydicom.dcmread(file.name)
 
 
 
 
24
  image = dicom_data.pixel_array.astype(np.float32)
25
-
26
- # Apply rescale slope and intercept
27
  rescale_slope = getattr(dicom_data, 'RescaleSlope', 1)
28
  rescale_intercept = getattr(dicom_data, 'RescaleIntercept', 0)
29
  image = (image * rescale_slope) + rescale_intercept
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
- # Store original image for analysis
32
- original_image = image.copy()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
- # Normalize for display
35
- image_display = cv2.normalize(image, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
 
36
 
37
- # Convert to BGR for visualization
38
- if len(image_display.shape) == 2:
39
- image_display = cv2.cvtColor(image_display, cv2.COLOR_GRAY2BGR)
 
 
40
 
41
- return original_image, image_display, dicom_data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  except Exception as e:
43
- print(f"Error loading DICOM file: {str(e)}")
44
- return None, None, None
45
 
46
- def analyze_point(self, image, dicom_data, x, y):
 
 
 
47
  try:
48
- # Create a circular mask
49
- mask = np.zeros_like(image, dtype=np.uint8)
50
- y_indices, x_indices = np.ogrid[:image.shape[0], :image.shape[1]]
51
- distance_from_center = np.sqrt((x_indices - x)**2 + (y_indices - y)**2)
52
- mask[distance_from_center <= self.circle_diameter / 2] = 1
53
-
54
- # Extract pixel values within the circle
55
- pixels = image[mask == 1]
56
-
57
- # Calculate metrics
58
- area_pixels = np.sum(mask)
59
- pixel_spacing = float(dicom_data.PixelSpacing[0])
60
- area_mm2 = area_pixels * (pixel_spacing**2)
61
- mean = np.mean(pixels)
62
- stddev = np.std(pixels)
63
- min_val = np.min(pixels)
64
- max_val = np.max(pixels)
65
-
66
- return {
67
- 'Area (mm²)': f"{area_mm2:.3f}",
68
- 'Mean': f"{mean:.3f}",
69
- 'StdDev': f"{stddev:.3f}",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  'Min': f"{min_val:.3f}",
71
- 'Max': f"{max_val:.3f}"
 
72
  }
 
 
 
 
 
73
  except Exception as e:
74
- print(f"Error analyzing point: {str(e)}")
75
- return None
76
 
77
- def draw_circle(self, image, x, y):
 
 
 
78
  try:
79
- image_copy = image.copy()
80
-
81
- # Draw all previous marks
82
- for mark_x, mark_y in self.marks1:
83
- cv2.circle(image_copy,
84
- (int(mark_x), int(mark_y)),
85
- int(self.circle_diameter / 2),
86
- (255, 255, 0), 1, # Yellow outer ring (thin)
87
- lineType=cv2.LINE_AA)
88
- cv2.circle(image_copy,
89
- (int(mark_x), int(mark_y)),
90
- int(self.circle_diameter / 2) - 1,
91
- (255, 255, 255), 1, # White inner ring (thin)
92
- lineType=cv2.LINE_AA)
93
-
94
- # Draw the new mark
95
- cv2.circle(image_copy,
96
- (int(x), int(y)),
97
- int(self.circle_diameter / 2),
98
- (255, 255, 0), 1, # Yellow outer ring (thin)
99
- lineType=cv2.LINE_AA)
100
- cv2.circle(image_copy,
101
- (int(x), int(y)),
102
- int(self.circle_diameter / 2) - 1,
103
- (255, 255, 255), 1, # White inner ring (thin)
104
- lineType=cv2.LINE_AA)
105
-
106
- # Store the new mark
107
- self.marks1.append((x, y))
108
-
109
- return image_copy
110
  except Exception as e:
111
- print(f"Error drawing circle: {str(e)}")
112
- return image
113
-
114
- def process_image(self, file):
115
- image, image_display, dicom_data = self.load_dicom(file)
116
- self.current_image1 = image
117
- self.image_display1 = image_display
118
- self.dicom_data1 = dicom_data
119
- return image_display
120
-
121
- def handle_click(self, evt: gr.SelectData):
122
- if self.current_image1 is None:
123
- return self.image_display1, "Please load an image first"
124
 
 
 
 
 
 
125
  try:
126
- x, y = evt.index
127
- marked_image = self.draw_circle(self.image_display1, x, y)
128
- self.image_display1 = marked_image
129
 
130
- results = self.analyze_point(self.current_image1, self.dicom_data1, x, y)
131
- if results:
132
- results['Point'] = f"({x}, {y})"
133
- self.results.append(results)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
- return self.image_display1, self.format_results()
136
  except Exception as e:
137
- print(f"Error in handle_click: {str(e)}")
138
- return self.image_display1, f"Error: {str(e)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
  def format_results(self):
 
141
  if not self.results:
142
- return "No results yet"
143
  df = pd.DataFrame(self.results)
144
- return df.to_string()
145
-
146
- def clear_results(self):
147
- self.results = []
148
- self.marks1 = []
149
- if self.current_image1 is not None:
150
- self.image_display1 = cv2.cvtColor(
151
- cv2.normalize(self.current_image1, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8),
152
- cv2.COLOR_GRAY2BGR
153
- )
154
- return "Results cleared", self.image_display1
155
-
156
- def add_blank_row(self):
157
  self.results.append({
158
- 'Point': '',
159
- 'Area (mm²)': '',
160
- 'Mean': '',
161
- 'StdDev': '',
162
- 'Min': '',
163
- 'Max': ''
164
  })
165
- return self.format_results()
166
-
167
- def add_zero_row(self):
168
- self.results.append({
169
- 'Point': '0',
170
- 'Area (mm²)': '0',
171
- 'Mean': '0',
172
- 'StdDev': '0',
173
- 'Min': '0',
174
- 'Max': '0'
175
- })
176
- return self.format_results()
177
-
178
- def undo_last_action(self):
 
 
 
 
179
  if self.results:
180
  self.results.pop()
181
- if self.marks1:
182
- self.marks1.pop()
183
- return self.format_results()
184
-
185
- def update_circle_diameter(self, value):
186
- self.circle_diameter = value
187
- return f"Circle diameter set to {value}"
188
-
189
- def save_results(self):
190
- try:
191
- if not self.results:
192
- return None, "No results to save"
193
 
194
- df = pd.DataFrame(self.results)
195
-
196
- # Create temporary file
197
- temp_dir = tempfile.gettempdir()
198
- temp_file = os.path.join(temp_dir, "analysis_results.xlsx")
199
-
200
- # Save to Excel
201
- df.to_excel(temp_file, index=False, engine='openpyxl')
202
-
203
- return temp_file, "Results saved successfully. Click to download."
204
- except Exception as e:
205
- print(f"Error saving results: {str(e)}")
206
- return None, f"Error saving results: {str(e)}"
207
 
208
  def create_interface():
 
209
  analyzer = DicomAnalyzer()
210
-
211
- with gr.Blocks() as interface:
212
- gr.Markdown("# CT DICOM Image Analyzer")
213
-
214
  with gr.Row():
215
  with gr.Column():
216
- file1 = gr.File(label="Upload DICOM file")
217
- image1 = gr.Image(label="DICOM Image", interactive=True, type="numpy")
218
- file1.change(fn=analyzer.process_image, inputs=file1, outputs=image1)
219
-
220
- with gr.Row():
221
- circle_diameter = gr.Slider(
222
- minimum=1,
223
- maximum=20,
224
- value=9,
225
- step=1,
226
- label="Circle Diameter"
227
- )
228
-
 
 
 
 
 
 
 
 
 
 
229
  with gr.Row():
230
- clear_btn = gr.Button("Clear Results")
231
- blank_row_btn = gr.Button("Add Blank Row")
232
- zero_row_btn = gr.Button("Add '0' Row")
233
- undo_btn = gr.Button("Undo Last Action")
234
  save_btn = gr.Button("Save Results")
235
-
236
- results = gr.Textbox(label="Results", interactive=False)
 
237
  file_output = gr.File(label="Download Results")
238
- status = gr.Textbox(label="Status")
239
-
240
- # Connect events
241
- circle_diameter.change(
242
- fn=analyzer.update_circle_diameter,
243
- inputs=circle_diameter,
244
- outputs=status
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  )
246
-
247
- image1.select(
248
- fn=analyzer.handle_click,
249
- outputs=[image1, results]
250
  )
251
-
252
- clear_btn.click(
253
- fn=analyzer.clear_results,
254
- outputs=[status, image1]
 
255
  )
256
-
257
- blank_row_btn.click(
258
- fn=analyzer.add_blank_row,
259
- outputs=results
 
 
260
  )
261
-
262
- zero_row_btn.click(
263
- fn=analyzer.add_zero_row,
264
- outputs=results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  )
266
 
267
  undo_btn.click(
268
- fn=analyzer.undo_last_action,
269
- outputs=results
 
270
  )
271
-
272
  save_btn.click(
273
- fn=analyzer.save_results,
274
- outputs=[file_output, status]
275
  )
276
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  return interface
278
 
 
279
  if __name__ == "__main__":
280
- interface = create_interface()
281
- interface.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import numpy as np
4
  import pandas as pd
5
  import pydicom
6
+ import io
7
+ from PIL import Image
8
+ import openpyxl
9
+ from openpyxl.utils import get_column_letter, column_index_from_string
10
+ import logging
11
+ import time
12
+ import traceback
13
+ from functools import wraps
14
+ import sys
15
+
16
+ print("Starting imports completed...")
17
+
18
+ # Set up logging
19
+ logging.basicConfig(
20
+ level=logging.DEBUG,
21
+ format='%(asctime)s - %(levelname)s - %(message)s',
22
+ handlers=[
23
+ logging.FileHandler('dicom_analyzer_debug.log'),
24
+ logging.StreamHandler(sys.stdout)
25
+ ]
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ def debug_decorator(func):
31
+ @wraps(func)
32
+ def wrapper(*args, **kwargs):
33
+ logger.debug(f"Entering {func.__name__}")
34
+ start_time = time.time()
35
+ try:
36
+ result = func(*args, **kwargs)
37
+ logger.debug(f"Function {func.__name__} completed successfully")
38
+ return result
39
+ except Exception as e:
40
+ logger.error(f"Error in {func.__name__}: {str(e)}")
41
+ logger.error(traceback.format_exc())
42
+ raise
43
+ finally:
44
+ end_time = time.time()
45
+ logger.debug(f"Execution time: {end_time - start_time:.4f} seconds")
46
+ return wrapper
47
+
48
 
49
  class DicomAnalyzer:
50
  def __init__(self):
51
  self.results = []
52
+ self.circle_diameter = 9.0
53
+ self.zoom_factor = 1.0
54
+ self.current_image = None
55
+ self.dicom_data = None
56
+ self.display_image = None
57
+ self.marks = []
58
+ self.original_image = None
59
+ self.original_display = None
60
+ self.pan_x = 0
61
+ self.pan_y = 0
62
+ self.max_pan_x = 0
63
+ self.max_pan_y = 0
64
+ # Main circle color remains yellow:
65
+ self.CIRCLE_COLOR = (0, 255, 255) # BGR format
66
+ # Small circles inside the main circle will be black:
67
+ self.SMALL_CIRCLES_COLOR = (0, 0, 0) # BGR black
68
+ print("DicomAnalyzer initialized...")
69
+
70
+ def save_results(self):
71
+ """
72
+ Basic save function for raw results with improved error handling and logging
73
+ """
74
+ try:
75
+ if not self.results:
76
+ logger.warning("Attempted to save with no results")
77
+ return None, "No results to save"
78
+
79
+ df = pd.DataFrame(self.results)
80
+ columns_order = ['Area (mm²)', 'Mean', 'StdDev', 'Min', 'Max', 'Point']
81
+ df = df[columns_order]
82
+
83
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
84
+ output_file = f"analysis_results_{timestamp}.xlsx"
85
+
86
+ with pd.ExcelWriter(output_file, engine='openpyxl') as writer:
87
+ df.to_excel(writer, index=False, sheet_name='Results')
88
+
89
+ worksheet = writer.sheets['Results']
90
+ for idx, col in enumerate(df.columns):
91
+ max_length = max(
92
+ df[col].astype(str).apply(len).max(),
93
+ len(str(col))
94
+ ) + 2
95
+ worksheet.column_dimensions[get_column_letter(idx + 1)].width = max_length
96
+
97
+ logger.info(f"Results saved successfully to {output_file}")
98
+ return output_file, f"Results saved successfully to {output_file}"
99
+
100
+ except Exception as e:
101
+ error_msg = f"Error saving results: {str(e)}"
102
+ logger.error(error_msg)
103
+ logger.error(traceback.format_exc())
104
+ return None, error_msg
105
+
106
+ def reset_all(self, image):
107
+ self.results = []
108
+ self.marks = []
109
+ self.reset_view()
110
+ return self.update_display(), "All data has been reset"
111
 
112
  def load_dicom(self, file):
113
  try:
114
  if file is None:
115
+ return None, "No file uploaded"
116
+
117
+ if hasattr(file, 'name'):
118
+ dicom_data = pydicom.dcmread(file.name)
119
+ else:
120
+ dicom_data = pydicom.dcmread(file)
121
+
122
  image = dicom_data.pixel_array.astype(np.float32)
123
+ self.original_image = image.copy()
124
+
125
  rescale_slope = getattr(dicom_data, 'RescaleSlope', 1)
126
  rescale_intercept = getattr(dicom_data, 'RescaleIntercept', 0)
127
  image = (image * rescale_slope) + rescale_intercept
128
+
129
+ self.current_image = image
130
+ self.dicom_data = dicom_data
131
+ self.display_image = self.normalize_image(image)
132
+ self.original_display = self.display_image.copy()
133
+
134
+ self.reset_all(None)
135
+ print("DICOM file loaded successfully")
136
+
137
+ return self.display_image, "DICOM file loaded successfully"
138
+ except Exception as e:
139
+ print(f"Error loading DICOM file: {str(e)}")
140
+ return None, f"Error loading DICOM file: {str(e)}"
141
+
142
+ def normalize_image(self, image):
143
+ try:
144
+ normalized = cv2.normalize(
145
+ image,
146
+ None,
147
+ alpha=0,
148
+ beta=255,
149
+ norm_type=cv2.NORM_MINMAX,
150
+ dtype=cv2.CV_8U
151
+ )
152
+ if len(normalized.shape) == 2:
153
+ normalized = cv2.cvtColor(normalized, cv2.COLOR_GRAY2BGR)
154
+ return normalized
155
+ except Exception as e:
156
+ print(f"Error normalizing image: {str(e)}")
157
+ return None
158
 
159
+ def reset_view(self):
160
+ self.zoom_factor = 1.0
161
+ self.pan_x = 0
162
+ self.pan_y = 0
163
+ if self.original_display is not None:
164
+ return self.update_display()
165
+ return None
166
+
167
+ def zoom_in(self, image):
168
+ print("Zooming in...")
169
+ self.zoom_factor = min(20.0, self.zoom_factor + 0.5)
170
+ return self.update_display()
171
+
172
+ def zoom_out(self, image):
173
+ print("Zooming out...")
174
+ self.zoom_factor = max(1.0, self.zoom_factor - 0.5)
175
+ return self.update_display()
176
+
177
+ def handle_keyboard(self, key):
178
+ """
179
+ Handle arrow keys. Pan movement is increased to be more noticeable.
180
+ """
181
+ try:
182
+ print(f"Handling key press: {key}")
183
+ # Increase pan step for bigger movement:
184
+ pan_amount = int(10 * self.zoom_factor)
185
+
186
+ if key == 'ArrowLeft':
187
+ self.pan_x = max(0, self.pan_x - pan_amount)
188
+ elif key == 'ArrowRight':
189
+ self.pan_x = min(self.max_pan_x, self.pan_x + pan_amount)
190
+ elif key == 'ArrowUp':
191
+ self.pan_y = max(0, self.pan_y - pan_amount)
192
+ elif key == 'ArrowDown':
193
+ self.pan_y = min(self.max_pan_y, self.pan_y + pan_amount)
194
+
195
+ return self.update_display()
196
+ except Exception as e:
197
+ print(f"Error handling keyboard input: {str(e)}")
198
+ return self.display_image
199
+
200
+ def update_display(self):
201
+ """
202
+ Updates the displayed image according to the current zoom/pan and draws the circles.
203
+ The big circle is in CIRCLE_COLOR, and the small circles inside are in SMALL_CIRCLES_COLOR.
204
+ """
205
+ try:
206
+ if self.original_display is None:
207
+ return None
208
 
209
+ height, width = self.original_display.shape[:2]
210
+ new_height = int(height * self.zoom_factor)
211
+ new_width = int(width * self.zoom_factor)
212
 
213
+ zoomed = cv2.resize(
214
+ self.original_display,
215
+ (new_width, new_height),
216
+ interpolation=cv2.INTER_CUBIC
217
+ )
218
 
219
+ zoomed_bgr = cv2.cvtColor(zoomed, cv2.COLOR_RGB2BGR)
220
+
221
+ for x, y, diameter in self.marks:
222
+ zoomed_x = int(x * self.zoom_factor)
223
+ zoomed_y = int(y * self.zoom_factor)
224
+ zoomed_radius = int((diameter / 2.0) * self.zoom_factor)
225
+
226
+ # Draw the main yellow circle
227
+ cv2.circle(
228
+ zoomed_bgr,
229
+ (zoomed_x, zoomed_y),
230
+ zoomed_radius,
231
+ self.CIRCLE_COLOR,
232
+ 1,
233
+ lineType=cv2.LINE_AA
234
+ )
235
+
236
+ # Draw 8 small black circles around
237
+ num_points = 8
238
+ for i in range(num_points):
239
+ angle = 2 * np.pi * i / num_points
240
+ point_x = int(zoomed_x + zoomed_radius * np.cos(angle))
241
+ point_y = int(zoomed_y + zoomed_radius * np.sin(angle))
242
+ cv2.circle(
243
+ zoomed_bgr,
244
+ (point_x, point_y),
245
+ 1,
246
+ self.SMALL_CIRCLES_COLOR,
247
+ -1,
248
+ lineType=cv2.LINE_AA
249
+ )
250
+
251
+ zoomed = cv2.cvtColor(zoomed_bgr, cv2.COLOR_BGR2RGB)
252
+
253
+ self.max_pan_x = max(0, new_width - width)
254
+ self.max_pan_y = max(0, new_height - height)
255
+ self.pan_x = min(max(0, self.pan_x), self.max_pan_x)
256
+ self.pan_y = min(max(0, self.pan_y), self.max_pan_y)
257
+
258
+ visible = zoomed[
259
+ int(self.pan_y):int(self.pan_y + height),
260
+ int(self.pan_x):int(self.pan_x + width)
261
+ ]
262
+
263
+ return visible
264
  except Exception as e:
265
+ print(f"Error updating display: {str(e)}")
266
+ return self.original_display
267
 
268
+ def analyze_roi(self, evt: gr.SelectData):
269
+ """
270
+ Called when user clicks on the displayed image to measure an ROI.
271
+ """
272
  try:
273
+ if self.current_image is None:
274
+ return None, "No image loaded"
275
+
276
+ clicked_x = evt.index[0]
277
+ clicked_y = evt.index[1]
278
+
279
+ # Adjust by pan
280
+ x = clicked_x + self.pan_x
281
+ y = clicked_y + self.pan_y
282
+
283
+ # Adjust by zoom
284
+ if self.zoom_factor != 1.0:
285
+ x = x / self.zoom_factor
286
+ y = y / self.zoom_factor
287
+
288
+ x = int(round(x))
289
+ y = int(round(y))
290
+
291
+ height, width = self.original_image.shape[:2]
292
+ Y, X = np.ogrid[:height, :width]
293
+ radius = self.circle_diameter / 2.0
294
+ r_squared = radius * radius
295
+
296
+ dx = X - x
297
+ dy = Y - y
298
+ dist_squared = dx * dx + dy * dy
299
+
300
+ mask = np.zeros((height, width), dtype=bool)
301
+ mask[dist_squared <= r_squared] = True
302
+
303
+ roi_pixels = self.original_image[mask]
304
+
305
+ if len(roi_pixels) == 0:
306
+ return self.display_image, "Error: No pixels selected"
307
+
308
+ pixel_spacing = float(self.dicom_data.PixelSpacing[0])
309
+ n_pixels = np.sum(mask)
310
+ area = n_pixels * (pixel_spacing ** 2)
311
+
312
+ mean_value = np.mean(roi_pixels)
313
+ std_dev = np.std(roi_pixels, ddof=1)
314
+ min_val = np.min(roi_pixels)
315
+ max_val = np.max(roi_pixels)
316
+
317
+ rescale_slope = getattr(self.dicom_data, 'RescaleSlope', 1)
318
+ rescale_intercept = getattr(self.dicom_data, 'RescaleIntercept', 0)
319
+
320
+ mean_value = (mean_value * rescale_slope) + rescale_intercept
321
+ std_dev = std_dev * rescale_slope
322
+ min_val = (min_val * rescale_slope) + rescale_intercept
323
+ max_val = (max_val * rescale_slope) + rescale_intercept
324
+
325
+ result = {
326
+ 'Area (mm²)': f"{area:.3f}",
327
+ 'Mean': f"{mean_value:.3f}",
328
+ 'StdDev': f"{std_dev:.3f}",
329
  'Min': f"{min_val:.3f}",
330
+ 'Max': f"{max_val:.3f}",
331
+ 'Point': f"({x}, {y})"
332
  }
333
+
334
+ self.results.append(result)
335
+ self.marks.append((x, y, self.circle_diameter))
336
+
337
+ return self.update_display(), self.format_results()
338
  except Exception as e:
339
+ print(f"Error analyzing ROI: {str(e)}")
340
+ return self.display_image, f"Error analyzing ROI: {str(e)}"
341
 
342
+ def add_formulas_to_template(self, ws, row_pair, col_group, red_font):
343
+ """
344
+ Inserts SNR (first row) and CNR (second row) formulas with IFERROR.
345
+ """
346
  try:
347
+ base_col = col_group[1] # Mean column
348
+ std_col = col_group[2] # StdDev column
349
+
350
+ row1, row2 = row_pair
351
+
352
+ # SNR formula
353
+ formula1 = f"=IFERROR({base_col}{row1}/{std_col}{row1},\"\")"
354
+ formula_col = get_column_letter(column_index_from_string(col_group[-1]) + 1)
355
+ cell1 = ws[f"{formula_col}{row1}"]
356
+ cell1.value = formula1
357
+ cell1.font = red_font
358
+ cell1.alignment = openpyxl.styles.Alignment(horizontal='center')
359
+
360
+ # CNR formula
361
+ formula2 = f"=IFERROR(({base_col}{row1}-{base_col}{row2})/{std_col}{row2},\"\")"
362
+ cell2 = ws[f"{formula_col}{row2}"]
363
+ cell2.value = formula2
364
+ cell2.font = red_font
365
+ cell2.alignment = openpyxl.styles.Alignment(horizontal='center')
366
+
367
+ logger.debug(f"Added formulas for rows {row1},{row2} in column {formula_col}")
 
 
 
 
 
 
 
 
 
 
368
  except Exception as e:
369
+ logger.error(f"Error adding formulas: {str(e)}")
 
 
 
 
 
 
 
 
 
 
 
 
370
 
371
+ def save_formatted_results(self, output_path):
372
+ """
373
+ Creates an Excel file with results in a formatted table,
374
+ including SNR/CNR formulas and average calculations.
375
+ """
376
  try:
377
+ if not self.results:
378
+ return None, "No results to save"
 
379
 
380
+ wb = openpyxl.Workbook()
381
+ ws = wb.active
382
+ red_font = openpyxl.styles.Font(color="FF0000")
383
+ center_alignment = openpyxl.styles.Alignment(horizontal='center')
384
+
385
+ # Column group headers
386
+ headers = ['Area', 'Mean', 'StdDev', 'Min', 'Max']
387
+
388
+ column_groups = [
389
+ ('B', 'C', 'D', 'E', 'F'), ('H', 'I', 'J', 'K', 'L'),
390
+ ('N', 'O', 'P', 'Q', 'R'), ('T', 'U', 'V', 'W', 'X'),
391
+ ('Z', 'AA', 'AB', 'AC', 'AD'), ('AF', 'AG', 'AH', 'AI', 'AJ'),
392
+ ('AL', 'AM', 'AN', 'AO', 'AP'), ('AR', 'AS', 'AT', 'AU', 'AV'),
393
+ ('AX', 'AY', 'AZ', 'BA', 'BB'), ('BD', 'BE', 'BF', 'BG', 'BH'),
394
+ ('BJ', 'BK', 'BL', 'BM', 'BN'), ('BP', 'BQ', 'BR', 'BS', 'BT'),
395
+ ('BV', 'BW', 'BX', 'BY', 'BZ')
396
+ ]
397
+
398
+ for cols in column_groups:
399
+ for i, header in enumerate(headers):
400
+ cell = ws[f"{cols[i]}1"]
401
+ cell.value = header
402
+ cell.alignment = center_alignment
403
+
404
+ row_pairs = [
405
+ (2, 3), (5, 6), (8, 9), (11, 12), (14, 15),
406
+ (17, 18), (20, 21), (23, 24), (26, 27), (29, 30)
407
+ ]
408
+
409
+ phantom_sizes = [
410
+ '(7mm)', '(6.5mm)', '(6mm)', '(5.5mm)', '(5mm)',
411
+ '(4.5mm)', '(4mm)', '(3.5mm)', '(3mm)', '(2.5mm)'
412
+ ]
413
+
414
+ # Set the phantom size row labels
415
+ for i, size in enumerate(phantom_sizes):
416
+ header_cell = ws.cell(row=row_pairs[i][0]-1, column=1, value=size)
417
+ header_cell.font = red_font
418
+ header_cell.alignment = center_alignment
419
+
420
+ # Fill the data from self.results
421
+ result_idx = 0
422
+ current_col_group = 0
423
+ current_row_pair = 0
424
+
425
+ while result_idx < len(self.results):
426
+ if current_row_pair >= len(row_pairs):
427
+ break
428
+
429
+ cols = column_groups[current_col_group]
430
+ rows = row_pairs[current_row_pair]
431
+
432
+ # First row
433
+ if result_idx < len(self.results):
434
+ result = self.results[result_idx]
435
+ self._write_result_to_cells(ws, result, cols, rows[0])
436
+ result_idx += 1
437
+
438
+ # Second row
439
+ if result_idx < len(self.results):
440
+ result = self.results[result_idx]
441
+ self._write_result_to_cells(ws, result, cols, rows[1])
442
+ result_idx += 1
443
+
444
+ # Add SNR/CNR formulas
445
+ self.add_formulas_to_template(ws, rows, cols, red_font)
446
+
447
+ current_col_group += 1
448
+ if current_col_group >= len(column_groups):
449
+ current_col_group = 0
450
+ current_row_pair += 1
451
+
452
+ # Center align all data cells
453
+ for cols in column_groups:
454
+ for col in cols:
455
+ for row in range(2, 31):
456
+ cell = ws[f"{col}{row}"]
457
+ if cell.value is not None:
458
+ cell.alignment = center_alignment
459
+
460
+ # Additional tables: StdDev Averages and CNR Averages
461
+ current_row = 32
462
+
463
+ # StdDev
464
+ stddev_header = ws.cell(row=current_row, column=1, value="StdDev Averages")
465
+ stddev_header.font = red_font
466
+ stddev_header.alignment = center_alignment
467
+ current_row += 1
468
+
469
+ for i, size in enumerate(phantom_sizes):
470
+ row_number = row_pairs[i][0]
471
+ stddev_values = []
472
+
473
+ for cols in column_groups:
474
+ stddev_col = cols[2] # The StdDev column
475
+ cell_value = ws[f"{stddev_col}{row_number}"].value
476
+ if cell_value not in [0, None, '']:
477
+ stddev_values.append(float(cell_value))
478
+
479
+ size_cell = ws.cell(row=current_row, column=1, value=size)
480
+ size_cell.alignment = center_alignment
481
+
482
+ if stddev_values:
483
+ avg_stddev = sum(stddev_values) / len(stddev_values)
484
+ avg_cell = ws.cell(row=current_row, column=2, value=avg_stddev)
485
+ avg_cell.number_format = '0.000'
486
+ avg_cell.alignment = center_alignment
487
+ current_row += 1
488
+
489
+ current_row += 2
490
+
491
+ # CNR
492
+ cnr_header = ws.cell(row=current_row, column=1, value="CNR Averages")
493
+ cnr_header.font = red_font
494
+ cnr_header.alignment = center_alignment
495
+ current_row += 1
496
+
497
+ for i, size in enumerate(phantom_sizes):
498
+ row_number = row_pairs[i][1]
499
+ cnr_cells = []
500
+
501
+ for cols in column_groups:
502
+ formula_col = get_column_letter(column_index_from_string(cols[-1]) + 1)
503
+ cnr_cell_ref = f"{formula_col}{row_number}"
504
+
505
+ # Read Mean1, Mean2, Std2 to skip zeros
506
+ mean_col = cols[1]
507
+ std_col = cols[2]
508
+
509
+ mean1_val = ws[f"{mean_col}{row_pairs[i][0]}"].value
510
+ mean2_val = ws[f"{mean_col}{row_pairs[i][1]}"].value
511
+ std2_val = ws[f"{std_col}{row_pairs[i][1]}"].value
512
+
513
+ try:
514
+ mean1_val = float(mean1_val) if mean1_val not in [None, ''] else 0
515
+ mean2_val = float(mean2_val) if mean2_val not in [None, ''] else 0
516
+ std2_val = float(std2_val) if std2_val not in [None, ''] else 0
517
+ except:
518
+ mean1_val, mean2_val, std2_val = 0, 0, 0
519
+
520
+ # If not all zero, add the cell reference
521
+ if not (mean1_val == 0 and mean2_val == 0 and std2_val == 0):
522
+ cnr_cells.append(cnr_cell_ref)
523
+
524
+ size_cell = ws.cell(row=current_row, column=1, value=size)
525
+ size_cell.alignment = center_alignment
526
+
527
+ if cnr_cells:
528
+ # Using AVERAGE(...) instead of AVERAGEIF
529
+ average_formula = f'=IFERROR(AVERAGE({",".join(cnr_cells)}), "")'
530
+
531
+ avg_cell = ws.cell(row=current_row, column=2)
532
+ avg_cell.value = average_formula
533
+ avg_cell.number_format = '0.000'
534
+ avg_cell.alignment = center_alignment
535
+
536
+ current_row += 1
537
+
538
+ # Align the extra rows
539
+ for row in range(32, current_row):
540
+ for col in range(1, 3):
541
+ cell = ws.cell(row=row, column=col)
542
+ cell.alignment = center_alignment
543
+
544
+ wb.save(output_path)
545
+ return output_path, f"Results saved successfully ({len(self.results)} measurements)"
546
 
 
547
  except Exception as e:
548
+ logger.error(f"Error saving formatted results: {str(e)}")
549
+ return None, f"Error saving results: {str(e)}"
550
+
551
+ def _write_result_to_cells(self, ws, result, cols, row):
552
+ """Helper method to write a single result to worksheet cells."""
553
+ center_alignment = openpyxl.styles.Alignment(horizontal='center')
554
+
555
+ value_mapping = {
556
+ 'Area': 'Area (mm²)',
557
+ 'Mean': 'Mean',
558
+ 'StdDev': 'StdDev',
559
+ 'Min': 'Min',
560
+ 'Max': 'Max'
561
+ }
562
+
563
+ for i, (header, key) in enumerate(value_mapping.items()):
564
+ cell = ws[f"{cols[i]}{row}"]
565
+ val = result[key]
566
+ cell.value = float(val) if val not in ['', None] else ''
567
+ cell.alignment = center_alignment
568
 
569
  def format_results(self):
570
+ """Returns a string representation of self.results for display."""
571
  if not self.results:
572
+ return "No measurements yet"
573
  df = pd.DataFrame(self.results)
574
+ columns_order = ['Area (mm²)', 'Mean', 'StdDev', 'Min', 'Max', 'Point']
575
+ df = df[columns_order]
576
+ return df.to_string(index=False)
577
+
578
+ def add_zero_row(self, image):
579
+ """
580
+ Adds one row with zero-values.
581
+ """
 
 
 
 
 
582
  self.results.append({
583
+ 'Area (mm²)': '0.000',
584
+ 'Mean': '0.000',
585
+ 'StdDev': '0.000',
586
+ 'Min': '0.000',
587
+ 'Max': '0.000',
588
+ 'Point': '(0, 0)'
589
  })
590
+ return image, self.format_results()
591
+
592
+ def add_two_zero_rows(self, image):
593
+ """
594
+ Adds two consecutive rows with zero-values.
595
+ """
596
+ for _ in range(2):
597
+ self.results.append({
598
+ 'Area (mm²)': '0.000',
599
+ 'Mean': '0.000',
600
+ 'StdDev': '0.000',
601
+ 'Min': '0.000',
602
+ 'Max': '0.000',
603
+ 'Point': '(0, 0)'
604
+ })
605
+ return image, self.format_results()
606
+
607
+ def undo_last(self, image):
608
  if self.results:
609
  self.results.pop()
610
+ if self.marks:
611
+ self.marks.pop()
612
+ return self.update_display(), self.format_results()
 
 
 
 
 
 
 
 
 
613
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614
 
615
  def create_interface():
616
+ print("Creating interface...")
617
  analyzer = DicomAnalyzer()
618
+
619
+ with gr.Blocks(css="#image_display { outline: none; }") as interface:
620
+ gr.Markdown("# DICOM Image Analyzer")
621
+
622
  with gr.Row():
623
  with gr.Column():
624
+ file_input = gr.File(label="Upload DICOM file")
625
+ diameter_slider = gr.Slider(
626
+ minimum=1,
627
+ maximum=20,
628
+ value=9,
629
+ step=1,
630
+ label="ROI Diameter (pixels)"
631
+ )
632
+
633
+ with gr.Row():
634
+ zoom_in_btn = gr.Button("Zoom In (+)")
635
+ zoom_out_btn = gr.Button("Zoom Out (-)")
636
+ reset_btn = gr.Button("Reset View")
637
+ reset_all_btn = gr.Button("Reset All")
638
+
639
+ with gr.Column():
640
+ image_display = gr.Image(
641
+ label="DICOM Image",
642
+ interactive=True,
643
+ elem_id="image_display"
644
+ )
645
+
646
+ # Removed the "Add Blank Row" button
647
  with gr.Row():
648
+ zero_btn = gr.Button("Add Zero Row")
649
+ zero2_btn = gr.Button("Add Two Zero Rows")
650
+ undo_btn = gr.Button("Undo Last")
 
651
  save_btn = gr.Button("Save Results")
652
+ save_formatted_btn = gr.Button("Save Formatted Results")
653
+
654
+ results_display = gr.Textbox(label="Results", interactive=False)
655
  file_output = gr.File(label="Download Results")
656
+ key_press = gr.Textbox(visible=False, elem_id="key_press")
657
+
658
+ gr.Markdown("""
659
+ ### Controls:
660
+ - Use arrow keys to pan when zoomed in. Movement is now larger.
661
+ - Click points to measure ROI.
662
+ - Use Zoom In/Out buttons or Reset View to adjust zoom level.
663
+ - Use Reset All to clear all measurements.
664
+ - "Save Results": basic Excel with raw data.
665
+ - "Save Formatted Results": Excel with advanced formatting & formulas.
666
+ """)
667
+
668
+ def update_diameter(x):
669
+ analyzer.circle_diameter = float(x)
670
+ print(f"Diameter updated to: {x}")
671
+ return f"Diameter set to {x} pixels"
672
+
673
+ def save_formatted():
674
+ output_path = "analysis_results_formatted.xlsx"
675
+ return analyzer.save_formatted_results(output_path)
676
+
677
+ # Handlers
678
+ file_input.change(
679
+ fn=analyzer.load_dicom,
680
+ inputs=file_input,
681
+ outputs=[image_display, results_display]
682
  )
683
+
684
+ image_display.select(
685
+ fn=analyzer.analyze_roi,
686
+ outputs=[image_display, results_display]
687
  )
688
+
689
+ diameter_slider.change(
690
+ fn=update_diameter,
691
+ inputs=diameter_slider,
692
+ outputs=gr.Textbox(label="Status")
693
  )
694
+
695
+ zoom_in_btn.click(
696
+ fn=analyzer.zoom_in,
697
+ inputs=image_display,
698
+ outputs=image_display,
699
+ queue=False
700
  )
701
+
702
+ zoom_out_btn.click(
703
+ fn=analyzer.zoom_out,
704
+ inputs=image_display,
705
+ outputs=image_display,
706
+ queue=False
707
+ )
708
+
709
+ reset_btn.click(
710
+ fn=analyzer.reset_view,
711
+ outputs=image_display
712
+ )
713
+
714
+ reset_all_btn.click(
715
+ fn=analyzer.reset_all,
716
+ inputs=image_display,
717
+ outputs=[image_display, results_display]
718
+ )
719
+
720
+ key_press.change(
721
+ fn=analyzer.handle_keyboard,
722
+ inputs=key_press,
723
+ outputs=image_display
724
+ )
725
+
726
+ # Removed blank_btn
727
+ zero_btn.click(
728
+ fn=analyzer.add_zero_row,
729
+ inputs=image_display,
730
+ outputs=[image_display, results_display]
731
+ )
732
+
733
+ zero2_btn.click(
734
+ fn=analyzer.add_two_zero_rows,
735
+ inputs=image_display,
736
+ outputs=[image_display, results_display]
737
  )
738
 
739
  undo_btn.click(
740
+ fn=analyzer.undo_last,
741
+ inputs=image_display,
742
+ outputs=[image_display, results_display]
743
  )
744
+
745
  save_btn.click(
746
+ fn=analyzer.save_results,
747
+ outputs=[file_output, results_display]
748
  )
749
 
750
+ save_formatted_btn.click(
751
+ fn=save_formatted,
752
+ outputs=[file_output, results_display]
753
+ )
754
+
755
+ # JavaScript snippet to allow multiple arrow key presses
756
+ js = """
757
+ <script>
758
+ document.addEventListener('keydown', function(e) {
759
+ if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
760
+ e.preventDefault();
761
+ const keyPressElement = document.querySelector('#key_press textarea');
762
+ if (keyPressElement) {
763
+ keyPressElement.value = e.key;
764
+ keyPressElement.dispatchEvent(new Event('input'));
765
+ setTimeout(() => {
766
+ keyPressElement.value = '';
767
+ keyPressElement.dispatchEvent(new Event('input'));
768
+ }, 100);
769
+ }
770
+ }
771
+ });
772
+ </script>
773
+ """
774
+ gr.HTML(js)
775
+
776
+ print("Interface created successfully")
777
  return interface
778
 
779
+
780
  if __name__ == "__main__":
781
+ try:
782
+ print("Starting application...")
783
+ interface = create_interface()
784
+ print("Launching interface...")
785
+ interface.queue()
786
+ interface.launch(
787
+ server_name="0.0.0.0",
788
+ server_port=7860,
789
+ share=True,
790
+ debug=True,
791
+ show_error=True,
792
+ quiet=False
793
+ )
794
+ except Exception as e:
795
+ print(f"Error launching application: {str(e)}")
796
+ logger.error(f"Error launching application: {str(e)}")
797
+ logger.error(traceback.format_exc())
798
+ raise e