| import gradio as gr |
| import cv2 |
| import numpy as np |
| import pandas as pd |
| import pydicom |
| import io |
| from PIL import Image |
| import openpyxl |
| from openpyxl.utils import get_column_letter, column_index_from_string |
| import logging |
| import time |
| import traceback |
| from functools import wraps |
| import sys |
|
|
| print("Starting imports completed...") |
|
|
| |
| logging.basicConfig( |
| level=logging.DEBUG, |
| format='%(asctime)s - %(levelname)s - %(message)s', |
| handlers=[ |
| logging.FileHandler('dicom_analyzer_debug.log'), |
| logging.StreamHandler(sys.stdout) |
| ] |
| ) |
|
|
| logger = logging.getLogger(__name__) |
|
|
| def debug_decorator(func): |
| @wraps(func) |
| def wrapper(*args, **kwargs): |
| logger.debug(f"Entering {func.__name__}") |
| start_time = time.time() |
| try: |
| result = func(*args, **kwargs) |
| logger.debug(f"Function {func.__name__} completed successfully") |
| return result |
| except Exception as e: |
| logger.error(f"Error in {func.__name__}: {str(e)}") |
| logger.error(traceback.format_exc()) |
| raise |
| finally: |
| end_time = time.time() |
| logger.debug(f"Execution time: {end_time - start_time:.4f} seconds") |
| return wrapper |
|
|
|
|
| class DicomAnalyzer: |
| def __init__(self): |
| self.results = [] |
| self.circle_diameter = 9.0 |
| self.zoom_factor = 1.0 |
| self.current_image = None |
| self.dicom_data = None |
| self.display_image = None |
| self.marks = [] |
| self.original_image = None |
| self.original_display = None |
| self.pan_x = 0 |
| self.pan_y = 0 |
| self.max_pan_x = 0 |
| self.max_pan_y = 0 |
| self.CIRCLE_COLOR = (0, 255, 255) |
| self.SMALL_CIRCLES_COLOR = (255, 255, 255) |
| |
| print("DicomAnalyzer initialized...") |
|
|
| def save_results(self): |
| """ |
| Basic method to save raw results to an Excel sheet (one sheet, no formatting). |
| """ |
| try: |
| if not self.results: |
| logger.warning("Attempted to save with no results") |
| return None, "No results to save" |
|
|
| df = pd.DataFrame(self.results) |
| columns_order = ['Area (mm²)', 'Mean', 'StdDev', 'Min', 'Max', 'Point'] |
| df = df[columns_order] |
|
|
| timestamp = time.strftime("%Y%m%d_%H%M%S") |
| output_file = f"analysis_results_{timestamp}.xlsx" |
|
|
| with pd.ExcelWriter(output_file, engine='openpyxl') as writer: |
| df.to_excel(writer, index=False, sheet_name='Results') |
| |
| worksheet = writer.sheets['Results'] |
| for idx, col in enumerate(df.columns): |
| max_length = max( |
| df[col].astype(str).apply(len).max(), |
| len(str(col)) |
| ) + 2 |
| worksheet.column_dimensions[get_column_letter(idx + 1)].width = max_length |
|
|
| logger.info(f"Results saved successfully to {output_file}") |
| return output_file, f"Results saved successfully to {output_file}" |
|
|
| except Exception as e: |
| error_msg = f"Error saving results: {str(e)}" |
| logger.error(error_msg) |
| logger.error(traceback.format_exc()) |
| return None, error_msg |
|
|
| def reset_all(self, image): |
| self.results = [] |
| self.marks = [] |
| self.reset_view() |
| return self.update_display(), "All data has been reset" |
|
|
| def load_dicom(self, file): |
| try: |
| if file is None: |
| return None, "No file uploaded" |
| |
| if hasattr(file, 'name'): |
| dicom_data = pydicom.dcmread(file.name) |
| else: |
| dicom_data = pydicom.dcmread(file) |
| |
| image = dicom_data.pixel_array.astype(np.float32) |
| self.original_image = image.copy() |
| |
| rescale_slope = getattr(dicom_data, 'RescaleSlope', 1) |
| rescale_intercept = getattr(dicom_data, 'RescaleIntercept', 0) |
| image = (image * rescale_slope) + rescale_intercept |
| |
| self.current_image = image |
| self.dicom_data = dicom_data |
| self.display_image = self.normalize_image(image) |
| self.original_display = self.display_image.copy() |
| |
| self.reset_all(None) |
| print("DICOM file loaded successfully") |
| |
| return self.display_image, "DICOM file loaded successfully" |
| except Exception as e: |
| print(f"Error loading DICOM file: {str(e)}") |
| return None, f"Error loading DICOM file: {str(e)}" |
|
|
| def normalize_image(self, image): |
| """ |
| Normalizes raw pixel data to [0..255], and ensures 3-channel BGR for display. |
| """ |
| try: |
| normalized = cv2.normalize( |
| image, |
| None, |
| alpha=0, |
| beta=255, |
| norm_type=cv2.NORM_MINMAX, |
| dtype=cv2.CV_8U |
| ) |
| if len(normalized.shape) == 2: |
| normalized = cv2.cvtColor(normalized, cv2.COLOR_GRAY2BGR) |
| return normalized |
| except Exception as e: |
| print(f"Error normalizing image: {str(e)}") |
| return None |
|
|
| def reset_view(self): |
| self.zoom_factor = 1.0 |
| self.pan_x = 0 |
| self.pan_y = 0 |
| if self.original_display is not None: |
| return self.update_display() |
| return None |
|
|
| def zoom_in(self, image): |
| print("Zooming in...") |
| self.zoom_factor = min(20.0, self.zoom_factor + 0.5) |
| return self.update_display() |
|
|
| def zoom_out(self, image): |
| print("Zooming out...") |
| self.zoom_factor = max(1.0, self.zoom_factor - 0.5) |
| return self.update_display() |
|
|
| def handle_keyboard(self, key): |
| """ |
| Pans the zoomed image with arrow keys. |
| """ |
| try: |
| print(f"Handling key press: {key}") |
| pan_amount = int(10 * self.zoom_factor) |
| |
| if key == 'ArrowLeft': |
| self.pan_x = max(0, self.pan_x - pan_amount) |
| elif key == 'ArrowRight': |
| self.pan_x = min(self.max_pan_x, self.pan_x + pan_amount) |
| elif key == 'ArrowUp': |
| self.pan_y = max(0, self.pan_y - pan_amount) |
| elif key == 'ArrowDown': |
| self.pan_y = min(self.max_pan_y, self.pan_y + pan_amount) |
| |
| return self.update_display() |
| except Exception as e: |
| print(f"Error handling keyboard input: {str(e)}") |
| return self.display_image |
|
|
| def update_display(self): |
| """ |
| Returns a version of self.original_display that is zoomed/panned |
| and shows ROI circles. |
| """ |
| try: |
| if self.original_display is None: |
| return None |
|
|
| height, width = self.original_display.shape[:2] |
| new_height = int(height * self.zoom_factor) |
| new_width = int(width * self.zoom_factor) |
|
|
| zoomed = cv2.resize( |
| self.original_display, |
| (new_width, new_height), |
| interpolation=cv2.INTER_CUBIC |
| ) |
|
|
| zoomed_bgr = cv2.cvtColor(zoomed, cv2.COLOR_RGB2BGR) |
|
|
| |
| for x, y, diameter in self.marks: |
| zoomed_x = int(x * self.zoom_factor) |
| zoomed_y = int(y * self.zoom_factor) |
| zoomed_radius = int((diameter / 2.0) * self.zoom_factor) |
| |
| |
| cv2.circle( |
| zoomed_bgr, |
| (zoomed_x, zoomed_y), |
| zoomed_radius, |
| self.CIRCLE_COLOR, |
| 1, |
| lineType=cv2.LINE_AA |
| ) |
| |
| |
| num_points = 8 |
| for i in range(num_points): |
| angle = 2 * np.pi * i / num_points |
| point_x = int(zoomed_x + zoomed_radius * np.cos(angle)) |
| point_y = int(zoomed_y + zoomed_radius * np.sin(angle)) |
| cv2.circle( |
| zoomed_bgr, |
| (point_x, point_y), |
| 1, |
| self.SMALL_CIRCLES_COLOR, |
| -1, |
| lineType=cv2.LINE_AA |
| ) |
|
|
| zoomed = cv2.cvtColor(zoomed_bgr, cv2.COLOR_BGR2RGB) |
|
|
| self.max_pan_x = max(0, new_width - width) |
| self.max_pan_y = max(0, new_height - height) |
| self.pan_x = min(max(0, self.pan_x), self.max_pan_x) |
| self.pan_y = min(max(0, self.pan_y), self.max_pan_y) |
|
|
| visible = zoomed[ |
| int(self.pan_y):int(self.pan_y + height), |
| int(self.pan_x):int(self.pan_x + width) |
| ] |
|
|
| return visible |
| except Exception as e: |
| print(f"Error updating display: {str(e)}") |
| return self.original_display |
|
|
| def analyze_roi(self, evt: gr.SelectData): |
| """ |
| Called when a user clicks on the DICOM image. |
| We create a circular ROI, gather stats, store the results, and draw. |
| """ |
| try: |
| if self.current_image is None: |
| return None, "No image loaded" |
|
|
| clicked_x = evt.index[0] |
| clicked_y = evt.index[1] |
| |
| x = clicked_x + self.pan_x |
| y = clicked_y + self.pan_y |
|
|
| if self.zoom_factor != 1.0: |
| x = x / self.zoom_factor |
| y = y / self.zoom_factor |
| |
| x = int(round(x)) |
| y = int(round(y)) |
| |
| height, width = self.original_image.shape[:2] |
| Y, X = np.ogrid[:height, :width] |
| radius = self.circle_diameter / 2.0 |
| r_squared = radius * radius |
| |
| dx = X - x |
| dy = Y - y |
| dist_squared = dx * dx + dy * dy |
| |
| mask = np.zeros((height, width), dtype=bool) |
| mask[dist_squared <= r_squared] = True |
| |
| roi_pixels = self.original_image[mask] |
| |
| if len(roi_pixels) == 0: |
| return self.display_image, "Error: No pixels selected" |
|
|
| pixel_spacing = float(self.dicom_data.PixelSpacing[0]) |
| n_pixels = np.sum(mask) |
| area = n_pixels * (pixel_spacing ** 2) |
| |
| mean_value = np.mean(roi_pixels) |
| std_dev = np.std(roi_pixels, ddof=1) |
| min_val = np.min(roi_pixels) |
| max_val = np.max(roi_pixels) |
|
|
| rescale_slope = getattr(self.dicom_data, 'RescaleSlope', 1) |
| rescale_intercept = getattr(self.dicom_data, 'RescaleIntercept', 0) |
| |
| mean_value = (mean_value * rescale_slope) + rescale_intercept |
| std_dev = std_dev * rescale_slope |
| min_val = (min_val * rescale_slope) + rescale_intercept |
| max_val = (max_val * rescale_slope) + rescale_intercept |
|
|
| result = { |
| 'Area (mm²)': f"{area:.3f}", |
| 'Mean': f"{mean_value:.3f}", |
| 'StdDev': f"{std_dev:.3f}", |
| 'Min': f"{min_val:.3f}", |
| 'Max': f"{max_val:.3f}", |
| 'Point': f"({x}, {y})" |
| } |
| |
| self.results.append(result) |
| self.marks.append((x, y, self.circle_diameter)) |
|
|
| return self.update_display(), self.format_results() |
| except Exception as e: |
| print(f"Error analyzing ROI: {str(e)}") |
| return self.display_image, f"Error analyzing ROI: {str(e)}" |
|
|
|
|
| def format_results(self): |
| """ |
| Returns a simple text version of self.results for the UI. |
| """ |
| if not self.results: |
| return "No measurements yet" |
| df = pd.DataFrame(self.results) |
| columns_order = ['Area (mm²)', 'Mean', 'StdDev', 'Min', 'Max', 'Point'] |
| df = df[columns_order] |
| return df.to_string(index=False) |
|
|
| def add_zero_row(self, image): |
| """ |
| For testing. Adds a zero row to self.results. |
| """ |
| self.results.append({ |
| 'Area (mm²)': '0.000', |
| 'Mean': '0.000', |
| 'StdDev': '0.000', |
| 'Min': '0.000', |
| 'Max': '0.000', |
| 'Point': '(0, 0)' |
| }) |
| return image, self.format_results() |
|
|
| def add_two_zero_rows(self, image): |
| """ |
| For testing. Adds two zero rows to self.results. |
| """ |
| for _ in range(2): |
| self.results.append({ |
| 'Area (mm²)': '0.000', |
| 'Mean': '0.000', |
| 'StdDev': '0.000', |
| 'Min': '0.000', |
| 'Max': '0.000', |
| 'Point': '(0, 0)' |
| }) |
| return image, self.format_results() |
|
|
| def undo_last(self, image): |
| """ |
| Undoes the last measurement or zero row. |
| If it was a real measurement, remove its circle too. |
| """ |
| if not self.results: |
| return self.update_display(), self.format_results() |
| |
| last_result = self.results[-1] |
| is_measurement = last_result['Point'] != '(0, 0)' |
| |
| self.results.pop() |
| if is_measurement and self.marks: |
| self.marks.pop() |
| |
| return self.update_display(), self.format_results() |
|
|
|
|
| @debug_decorator |
| def save_formatted_results(self, output_path): |
| """ |
| 1) Writes the raw data from self.results into rows (2,3,5,6,8,9,...). |
| 2) Builds the final table at rows 35..45 with merges & red headers, reading |
| from those raw cells to compute AVG MEAN, AVG STDDEV, and AVG CNR. |
| """ |
| try: |
| if not self.results: |
| return None, "No results to save" |
|
|
| |
| wb = openpyxl.Workbook() |
| ws = wb.active |
|
|
| |
| |
| row_pairs = [ |
| (2,3), (5,6), (8,9), (11,12), (14,15), |
| (17,18), (20,21), (23,24), (26,27), (29,30) |
| ] |
|
|
| |
| |
| |
| column_groups = [ |
| ('B','C','D') |
| ] |
|
|
| |
| |
| |
| |
|
|
| result_idx = 0 |
| pair_idx = 0 |
|
|
| |
| while result_idx < len(self.results) and pair_idx < len(row_pairs): |
| |
| area_col, mean_col, stddev_col = column_groups[0] |
|
|
| |
| |
| row1 = row_pairs[pair_idx][0] |
| if result_idx < len(self.results): |
| r = self.results[result_idx] |
| self._write_single_result(ws, r, area_col, mean_col, stddev_col, row1) |
| result_idx += 1 |
|
|
| |
| row2 = row_pairs[pair_idx][1] |
| if result_idx < len(self.results): |
| r = self.results[result_idx] |
| self._write_single_result(ws, r, area_col, mean_col, stddev_col, row2) |
| result_idx += 1 |
|
|
| pair_idx += 1 |
|
|
| |
| red_font = openpyxl.styles.Font(color="FF0000") |
| center_alignment = openpyxl.styles.Alignment(horizontal='center', vertical='center') |
|
|
| start_row = 35 |
| |
| |
| ws['C35'] = "1-AVG" |
| ws['C35'].alignment = center_alignment |
| |
| |
| ws.merge_cells('D35:E35') |
| ws.merge_cells('F35:G35') |
| ws.merge_cells('H35:I35') |
| |
| headers = { |
| 'D35': 'AVG MEAN', |
| 'F35': 'AVG STDDEV', |
| 'H35': 'AVG CNR' |
| } |
| |
| for cell_ref, hdr_text in headers.items(): |
| ws[cell_ref] = hdr_text |
| ws[cell_ref].alignment = center_alignment |
| ws[cell_ref].font = red_font |
|
|
| |
| phantom_sizes = [ |
| '(7.0mm)', '(6.5mm)', '(6.0mm)', '(5.5mm)', '(5.0mm)', |
| '(4.5mm)', '(4.0mm)', '(3.5mm)', '(3.0mm)', '(2.5mm)' |
| ] |
| |
| for i, size_label in enumerate(phantom_sizes): |
| row = start_row + i + 1 |
|
|
| |
| ws.merge_cells(f'D{row}:E{row}') |
| ws.merge_cells(f'F{row}:G{row}') |
| ws.merge_cells(f'H{row}:I{row}') |
|
|
| c_cell = ws[f'C{row}'] |
| c_cell.value = size_label |
| c_cell.font = red_font |
| c_cell.alignment = center_alignment |
|
|
| |
| |
| |
| |
| if i < len(row_pairs): |
| (raw_row1, raw_row2) = row_pairs[i] |
| else: |
| |
| continue |
|
|
| |
| |
| (area_col, mean_col, stddev_col) = column_groups[0] |
|
|
| |
| mean1_val = ws[f"{mean_col}{raw_row1}"].value |
| mean2_val = ws[f"{mean_col}{raw_row2}"].value |
| stddev2_val = ws[f"{stddev_col}{raw_row2}"].value |
|
|
| |
| try: |
| mean1_val = float(mean1_val) if mean1_val not in [None, ''] else None |
| mean2_val = float(mean2_val) if mean2_val not in [None, ''] else None |
| stddev2_val = float(stddev2_val) if stddev2_val not in [None, ''] else None |
| except: |
| mean1_val, mean2_val, stddev2_val = None, None, None |
|
|
| |
| if (mean1_val is not None) and (mean2_val is not None) and (stddev2_val is not None) and (stddev2_val != 0): |
| avg_mean = mean1_val |
| avg_std = stddev2_val |
| cnr = (mean1_val - mean2_val)/ stddev2_val |
| else: |
| avg_mean, avg_std, cnr = None, None, None |
|
|
| |
| if avg_mean is not None: |
| ws[f'D{row}'].value = avg_mean |
| ws[f'D{row}'].alignment = center_alignment |
| ws[f'D{row}'].number_format = '0.0000' |
| |
| if avg_std is not None: |
| ws[f'F{row}'].value = avg_std |
| ws[f'F{row}'].alignment = center_alignment |
| ws[f'F{row}'].number_format = '0.0000' |
| |
| if cnr is not None: |
| ws[f'H{row}'].value = cnr |
| ws[f'H{row}'].alignment = center_alignment |
| ws[f'H{row}'].number_format = '0.0000' |
|
|
| |
| thin_side = openpyxl.styles.Side(style='thin') |
| border = openpyxl.styles.Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side) |
| |
| for r in range(35, 46): |
| for c in ['C','D','E','F','G','H','I']: |
| ws[f'{c}{r}'].border = border |
|
|
| wb.save(output_path) |
| return output_path, "Results saved successfully with formatted table" |
| except Exception as e: |
| logger.error(f"Error saving formatted results: {str(e)}") |
| logger.error(traceback.format_exc()) |
| return None, f"Error saving results: {str(e)}" |
|
|
| def _write_single_result(self, ws, result, area_col, mean_col, stddev_col, row): |
| """ |
| Helper to write one measurement to a given row in columns for Area, Mean, StdDev, etc. |
| """ |
| |
| def as_float(v): |
| try: |
| return float(v) |
| except: |
| return None |
| |
| area_val = as_float(result.get('Area (mm²)', None)) |
| mean_val = as_float(result.get('Mean', None)) |
| stddev_val = as_float(result.get('StdDev', None)) |
| |
| if area_val is not None: |
| ws[f"{area_col}{row}"].value = area_val |
| if mean_val is not None: |
| ws[f"{mean_col}{row}"].value = mean_val |
| if stddev_val is not None: |
| ws[f"{stddev_col}{row}"].value = stddev_val |
| |
|
|
| def create_interface(): |
| print("Creating interface...") |
| analyzer = DicomAnalyzer() |
| |
| with gr.Blocks(css="#image_display { outline: none; }") as interface: |
| gr.Markdown("# DICOM Image Analyzer") |
| |
| with gr.Row(): |
| with gr.Column(): |
| file_input = gr.File(label="Upload DICOM file") |
| diameter_slider = gr.Slider( |
| minimum=1, |
| maximum=20, |
| value=9, |
| step=1, |
| label="ROI Diameter (pixels)" |
| ) |
| |
| with gr.Row(): |
| zoom_in_btn = gr.Button("Zoom In (+)") |
| zoom_out_btn = gr.Button("Zoom Out (-)") |
| reset_btn = gr.Button("Reset View") |
| reset_all_btn = gr.Button("Reset All") |
| |
| with gr.Column(): |
| image_display = gr.Image( |
| label="DICOM Image", |
| interactive=True, |
| elem_id="image_display" |
| ) |
| |
| with gr.Row(): |
| zero_btn = gr.Button("Add Zero Row") |
| zero2_btn = gr.Button("Add Two Zero Rows") |
| undo_btn = gr.Button("Undo Last") |
| save_btn = gr.Button("Save Results") |
| save_formatted_btn = gr.Button("Save Formatted Results") |
| |
| results_display = gr.Textbox(label="Results", interactive=False) |
| file_output = gr.File(label="Download Results") |
| key_press = gr.Textbox(visible=False, elem_id="key_press") |
| |
| gr.Markdown(""" |
| ### Controls: |
| - Use arrow keys to pan when zoomed in. Movement is now larger. |
| - Click points to measure ROI. |
| - Use Zoom In/Out buttons or Reset View to adjust zoom level. |
| - Use Reset All to clear all measurements. |
| - "Save Results": basic Excel with raw data. |
| - "Save Formatted Results": Excel with advanced formatting & formulas. |
| """) |
|
|
| def update_diameter(x): |
| analyzer.circle_diameter = float(x) |
| print(f"Diameter updated to: {x}") |
| return f"Diameter set to {x} pixels" |
|
|
| def save_formatted(): |
| output_path = "analysis_results_formatted.xlsx" |
| return analyzer.save_formatted_results(output_path) |
|
|
| file_input.change( |
| fn=analyzer.load_dicom, |
| inputs=file_input, |
| outputs=[image_display, results_display] |
| ) |
| |
| image_display.select( |
| fn=analyzer.analyze_roi, |
| outputs=[image_display, results_display] |
| ) |
| |
| diameter_slider.change( |
| fn=update_diameter, |
| inputs=diameter_slider, |
| outputs=gr.Textbox(label="Status") |
| ) |
| |
| zoom_in_btn.click( |
| fn=analyzer.zoom_in, |
| inputs=image_display, |
| outputs=image_display, |
| queue=False |
| ) |
| |
| zoom_out_btn.click( |
| fn=analyzer.zoom_out, |
| inputs=image_display, |
| outputs=image_display, |
| queue=False |
| ) |
| |
| reset_btn.click( |
| fn=analyzer.reset_view, |
| outputs=image_display |
| ) |
| |
| reset_all_btn.click( |
| fn=analyzer.reset_all, |
| inputs=image_display, |
| outputs=[image_display, results_display] |
| ) |
| |
| key_press.change( |
| fn=analyzer.handle_keyboard, |
| inputs=key_press, |
| outputs=image_display |
| ) |
| |
| zero_btn.click( |
| fn=analyzer.add_zero_row, |
| inputs=image_display, |
| outputs=[image_display, results_display] |
| ) |
| |
| zero2_btn.click( |
| fn=analyzer.add_two_zero_rows, |
| inputs=image_display, |
| outputs=[image_display, results_display] |
| ) |
|
|
| undo_btn.click( |
| fn=analyzer.undo_last, |
| inputs=image_display, |
| outputs=[image_display, results_display] |
| ) |
| |
| save_btn.click( |
| fn=analyzer.save_results, |
| outputs=[file_output, results_display] |
| ) |
|
|
| save_formatted_btn.click( |
| fn=save_formatted, |
| outputs=[file_output, results_display] |
| ) |
|
|
| |
| js = """ |
| <script> |
| document.addEventListener('keydown', function(e) { |
| if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { |
| e.preventDefault(); |
| const keyPressElement = document.querySelector('#key_press textarea'); |
| if (keyPressElement) { |
| keyPressElement.value = e.key; |
| keyPressElement.dispatchEvent(new Event('input')); |
| setTimeout(() => { |
| keyPressElement.value = ''; |
| keyPressElement.dispatchEvent(new Event('input')); |
| }, 100); |
| } |
| } |
| }); |
| </script> |
| """ |
| gr.HTML(js) |
| |
| print("Interface created successfully") |
| return interface |
|
|
|
|
| if __name__ == "__main__": |
| try: |
| print("Starting application...") |
| interface = create_interface() |
| print("Launching interface...") |
| interface.queue() |
| interface.launch( |
| server_name="0.0.0.0", |
| server_port=7860, |
| share=True, |
| debug=True, |
| show_error=True, |
| quiet=False |
| ) |
| except Exception as e: |
| print(f"Error launching application: {str(e)}") |
| logger.error(f"Error launching application: {str(e)}") |
| logger.error(traceback.format_exc()) |
| raise e |
|
|