diff --git a/.env.example b/.env.example
deleted file mode 100644
index 16bd4e2..0000000
--- a/.env.example
+++ /dev/null
@@ -1,8 +0,0 @@
-# Python Configuration
-PYTHON_PATH=C:/Path/To/Your/Python/python.exe
-
-# Email configuration for suggestion system
-EMAIL_ADDRESS=your_email@gmail.com
-EMAIL_PASSWORD=your_app_password
-EMAIL_SMTP_SERVER=smtp.gmail.com
-EMAIL_SMTP_PORT=587
diff --git a/app/core/alert_manager.py b/app/core/alert_manager.py
index c969486..edfb1c7 100644
--- a/app/core/alert_manager.py
+++ b/app/core/alert_manager.py
@@ -1,5 +1,4 @@
from PyQt6.QtWidgets import QMessageBox
-from typing import Optional
class AlertManager:
diff --git a/app/core/dicom_manager.py b/app/core/dicom_manager.py
new file mode 100644
index 0000000..2420189
--- /dev/null
+++ b/app/core/dicom_manager.py
@@ -0,0 +1,387 @@
+from numpy import clip, stack, array
+from os import path, listdir
+from pydicom import dcmread
+from PyQt6.QtGui import QImage, QIcon, QPixmap
+from pydicom.multival import MultiValue
+from pydicom.fileset import FileSet
+from app.core.export_manager import ExportCategory
+from PyQt6.QtCore import QObject, pyqtSignal
+import json, pandas
+class Dicom:
+ def __init__(self, files=None, name='', path=None):
+ self.childs = files or [] # Default to empty list
+ self.name = name
+ self.path = path
+ self.mask = None
+ self.category = ExportCategory.A
+ self.export = False # Default export status
+
+ def set_export(self, export):
+ """Set the export status of this dicom"""
+ if not isinstance(export, bool):
+ raise ValueError("Export status must be a boolean")
+ self.export = export
+
+ def get_export(self):
+ """Get the export status of this dicom"""
+ return self.export
+
+ def get_category(self):
+ """Get the category associated with this dicom"""
+ return self.category
+
+ def set_category(self, category):
+ self.category = category
+
+ def get_mask(self):
+ """Get the mask associated with this dicom"""
+ return self.mask
+
+ def set_mask(self, mask):
+ """Set the mask for this dicom"""
+ self.mask = mask
+
+ def get_path(self):
+ return self.path
+
+ def add_file_or_folder(self, file):
+ if file not in self.childs:
+ self.childs.append(file)
+
+ def add_files_or_folders(self, files):
+ self.childs.extend(file for file in files if file not in self.childs)
+
+ def get_icon(self):
+ return self.childs[0].get_icon() if self.childs else None
+
+ def get_subfolders(self):
+ return [child for child in self.childs if isinstance(child, Dicom)]
+
+ def get_files(self):
+ return [child for child in self.childs if isinstance(child, DicomFile)]
+
+ def get_children_types(self):
+ return type(self.childs[0]) if self.childs else None
+
+ def get_name(self):
+ return self.name
+
+ def is_empty(self):
+ return len(self.childs) == 0
+
+ def export_metadata_json(self, destPath):
+ dicom_file = self.get_files()[0] if self.get_files() else None
+ if dicom_file:
+ dicom_file.export_metadata_json(destPath)
+ def export_metadata_xls(self, destPath):
+ dicom_file = self.get_files()[0] if self.get_files() else None
+ if dicom_file:
+ dicom_file.export_metadata_xls(destPath)
+ def __repr__(self):
+ return f"Dicom(name={self.name}, path={self.path}, files={len(self.childs)}, mask={self.mask is not None}, export={self.export}, category={self.category})"
+
+class DicomFile:
+ def __init__(self, file_path):
+ self.file_path = file_path
+ self.name = path.basename(file_path)
+ self.image = None
+ self.icon = None
+ self.is_valid = None # Changed to None for lazy validation
+ self.error_message = None
+ self.ds = None # Don't load immediately
+ self._metadata_loaded = False
+
+ def _ensure_loaded(self):
+ """Lazy loading of DICOM dataset"""
+ if self.ds is None and self.is_valid is None:
+ try:
+ # Use more aggressive deferring
+ self.ds = dcmread(self.file_path, defer_size="1 KB", stop_before_pixels=True)
+ self.is_valid = True
+ except Exception as e:
+ self.is_valid = False
+ self.error_message = str(e)
+ self.ds = None
+
+ def _ensure_pixel_data_loaded(self):
+ """Load pixel data only when needed"""
+ if self.ds is not None and not hasattr(self.ds, 'pixel_array'):
+ try:
+ # Reload with pixel data
+ self.ds = dcmread(self.file_path, defer_size="100 MB")
+ except Exception as e:
+ pass
+
+ def get_image(self):
+ self._ensure_loaded()
+ if not self.is_valid:
+ return None
+ if self.image is None:
+ self._ensure_pixel_data_loaded()
+ self.image = self.get_scaled_image()
+ return self.image
+
+ def get_name(self):
+ return self.name
+
+ def get_icon(self):
+ self._ensure_loaded()
+ if not self.is_valid:
+ return None
+ if self.icon is None:
+ try:
+ image = self.get_image()
+ if image is not None:
+ self.icon = QIcon(QPixmap.fromImage(DicomManager().array_to_image(image)))
+ except Exception:
+ self.icon = None
+ return self.icon
+
+ def get_scaled_image(self):
+ self._ensure_loaded()
+ if not self.is_valid or self.ds is None:
+ return None
+
+ self._ensure_pixel_data_loaded()
+
+ try:
+ if not hasattr(self.ds, 'pixel_array'):
+ return None
+
+ float_array = self.ds.pixel_array.astype('float32')
+ slope = getattr(self.ds, 'RescaleSlope', 1.0)
+ intercept = getattr(self.ds, 'RescaleIntercept', 0.0)
+ float_array = float_array * slope + intercept
+
+ if self.ds.get('PhotometricInterpretation', '').upper() == 'MONOCHROME1':
+ float_array = float_array.max() - float_array
+
+ window_center = self.ds.get('WindowCenter', None)
+ window_width = self.ds.get('WindowWidth', None)
+
+ if isinstance(window_center, MultiValue): window_center = window_center[0]
+ if isinstance(window_width, MultiValue): window_width = window_width[0]
+
+ if window_center is not None and window_width is not None and float(window_width) > 0:
+ lo = float(window_center) - float(window_width) / 2
+ hi = float(window_center) + float(window_width) / 2
+ float_array = clip(float_array, lo, hi)
+ else:
+ float_array = clip(float_array, float_array.min(), float_array.max())
+
+ float_array = ((float_array - float_array.min()) / (float_array.max() - float_array.min()) * 255.0).astype('uint8')
+ if float_array.ndim == 2: # Grayscale image
+ float_array = stack([float_array, float_array, float_array], axis=-1)
+ return float_array
+ except Exception as e:
+ return None
+
+ def get_metadata(self, metadata : str):
+ return self.ds.get(metadata, "")
+
+ def get_all_metadata(self):
+ """Get all metadata with proper type conversion for JSON serialization"""
+ self._ensure_loaded()
+ if not self.ds:
+ return {}
+
+ # Load full dataset for metadata export
+ if not self._metadata_loaded:
+ try:
+ self.ds = dcmread(self.file_path, defer_size="100 MB")
+ self._metadata_loaded = True
+ except:
+ return {}
+
+ metadata = {}
+ for tag in self.ds.keys():
+ try:
+ element = self.ds[tag]
+ value = element.value
+
+ # Convert various DICOM types to JSON-serializable types
+ if hasattr(value, '__iter__') and not isinstance(value, (str, bytes)):
+ # Handle sequences and arrays
+ try:
+ value = list(value)
+ except:
+ value = str(value)
+ elif hasattr(value, 'decode') and callable(value.decode):
+ # Handle bytes
+ try:
+ value = value.decode('utf-8', errors='ignore')
+ except:
+ value = str(value)
+ else:
+ # Convert other types to string if they're not basic types
+ if not isinstance(value, (str, int, float, bool, type(None))):
+ value = str(value)
+
+ # Use tag name if available, otherwise use hex representation
+ tag_name = element.name if hasattr(element, 'name') and element.name else str(tag)
+ metadata[tag_name] = value
+
+ except Exception as e:
+ # If there's any error with a specific tag, skip it
+ continue
+
+ return metadata
+
+ def set_metadata(self, metadata, value):
+ if not isinstance(metadata, str):
+ raise ValueError("Metadata key must be a string")
+ if not isinstance(value, (str, int, float)):
+ raise ValueError("Metadata value must be a string, int, or float")
+ self.ds[metadata] = value
+
+ def export_metadata_json(self, destPath):
+ """Export metadata to JSON with proper error handling"""
+ try:
+ metadata = self.get_all_metadata()
+ json_path = path.join(destPath, f"{self.name}_metadata.json")
+
+ with open(json_path, 'w', encoding='utf-8') as json_file:
+ json.dump(metadata, json_file, indent=4, ensure_ascii=False, default=str)
+
+ except Exception as e:
+ # Re-raise with more context
+ raise Exception(f"Error exporting JSON for {self.name}: {str(e)}")
+
+ def export_metadata_xls(self, destPath):
+ """Export metadata to XLS with proper error handling"""
+ try:
+ metadata = self.get_all_metadata()
+
+ # Convert to list of tuples for pandas
+ metadata_list = [(tag, value) for tag, value in metadata.items()]
+
+ df = pandas.DataFrame(metadata_list, columns=['Tag', 'Value'])
+ xls_path = path.join(destPath, f"{self.name}_metadata.xlsx")
+
+ # Use xlsxwriter engine for better compatibility
+ with pandas.ExcelWriter(xls_path, engine='openpyxl') as writer:
+ df.to_excel(writer, sheet_name='Metadata', index=False)
+
+ except Exception as e:
+ # Re-raise with more context
+ raise Exception(f"Error exporting XLS for {self.name}: {str(e)}")
+ def extract_metadata(self, imagePath, toGet=[]):
+ if not isinstance(imagePath, str):
+ raise ValueError("Image path must be a string")
+ try:
+ file_set = FileSet(imagePath)
+ for record in file_set:
+ metadata = record.get_metadata()
+ if not metadata:
+ continue
+ # Extract only the requested metadata keys
+ if toGet:
+ return {key: metadata.get(key, "") for key in toGet}
+ else:
+ return metadata
+ except Exception as e:
+ return {}
+
+ def __repr__(self):
+ return f"DicomFile(path={self.file_path}, name={self.name})"
+
+class DicomManager(QObject):
+ progress_changed = pyqtSignal(int)
+ error_occurred = pyqtSignal(str) # New signal for errors
+
+ def __init__(self, dicoms=[]):
+ super().__init__()
+ self.dicoms = dicoms
+
+ def add_dicom(self, dicom):
+ if isinstance(dicom, Dicom):
+ self.dicoms.append(dicom)
+ elif isinstance(dicom, str):
+ self.dicoms.append(self.process_dicomdir(dicom))
+ elif isinstance(dicom, list):
+ for item in dicom:
+ self.add_dicom(item)
+ else:
+ raise TypeError("Expected Dicom or str, got {}".format(type(dicom)))
+
+ def get_dicoms(self):
+ return self.dicoms
+
+ def get_dicom_paths(self):
+ return [dicom.get_path() for dicom in self.dicoms]
+
+ def process_dicomdir(self, dicomdirPath):
+ try:
+ if dicomdirPath.endswith('DICOMDIR'):
+ image_files = []
+ subDirs = []
+ currentName = ""
+ ds = dcmread(dicomdirPath)
+ if hasattr(ds, 'DirectoryRecordSequence'):
+ basePath = path.dirname(dicomdirPath)
+ total_records = len(ds.DirectoryRecordSequence)
+
+ for i, record in enumerate(ds.DirectoryRecordSequence):
+ # Emit progress for DICOM processing
+ if i % 5 == 0: # More frequent updates
+ progress = int((i / total_records) * 100)
+ self.progress_changed.emit(progress)
+
+ if record.DirectoryRecordType == "IMAGE" and hasattr(record, 'ReferencedFileID'):
+ relativePath = path.join(*record.ReferencedFileID)
+ imagePath = path.join(basePath, relativePath)
+ name = record.ReferencedFileID[-2]
+ if name != currentName:
+ if currentName:
+ subDirs.append(Dicom(image_files,currentName))
+ currentName = name
+ image_files = []
+
+ # Create DicomFile without immediate validation
+ dicom_file = DicomFile(imagePath)
+ image_files.append(dicom_file)
+
+ if image_files: # Don't forget the last group
+ subDirs.append(Dicom(image_files,currentName))
+
+ if len(subDirs) <= 1 and len(image_files) > 0:
+ return Dicom(image_files, path.basename(path.dirname(dicomdirPath)), dicomdirPath)
+ return Dicom(subDirs, path.basename(path.dirname(dicomdirPath)), dicomdirPath)
+ elif dicomdirPath.endswith('.dcm'):
+ # Process single DCM file with lazy loading
+ dicom_file = DicomFile(dicomdirPath)
+ return Dicom([dicom_file], path.basename(dicomdirPath), dicomdirPath)
+ else:
+ # Process directory containing DCM files
+ dcm_files = []
+ file_list = [f for f in listdir(dicomdirPath) if f.lower().endswith('.dcm')]
+ total_files = len(file_list)
+
+ for i, dcm_file in enumerate(file_list):
+ # Emit progress for DCM file processing
+ if i % 10 == 0: # Update every 10 files
+ progress = int((i / total_files) * 100)
+ self.progress_changed.emit(progress)
+
+ # Create DicomFile without immediate validation
+ dicom_file = DicomFile(path.join(dicomdirPath, dcm_file))
+ dcm_files.append(dicom_file)
+
+ # Create Dicom object with all files (validation will happen later)
+ if dcm_files:
+ return Dicom(dcm_files, path.basename(dicomdirPath), dicomdirPath)
+ else:
+ return None
+
+ except Exception as e:
+ self.error_occurred.emit("load_error")
+ return None
+
+ def image_to_array(self,qimg):
+ ptr = qimg.bits()
+ ptr.setsize(qimg.width() * qimg.height() * 3)
+ return array(ptr).reshape(qimg.height(), qimg.width(), 3)
+
+ def array_to_image(self, arr):
+ h, w, ch = arr.shape
+ return QImage(arr.data, w, h, ch * w, QImage.Format.Format_RGB888).copy()
\ No newline at end of file
diff --git a/app/core/export_manager.py b/app/core/export_manager.py
new file mode 100644
index 0000000..546e8d4
--- /dev/null
+++ b/app/core/export_manager.py
@@ -0,0 +1,567 @@
+import os
+from reportlab.pdfgen import canvas
+from reportlab.lib.pagesizes import A4
+import numpy as np
+from PIL import Image
+import app.utils.image_editor as image_editor
+from PyQt6.QtCore import QObject, pyqtSignal
+import pydicom
+from pydicom.uid import generate_uid
+from datetime import datetime
+
+class ExportCategory:
+ A = (180, 0, 0)
+ B = (0, 180, 0)
+ C = (0, 0, 180)
+ D = (180, 180, 0)
+ E = (180, 0, 180)
+ F = (0, 180, 180)
+ G = (120, 60, 0)
+ H = (60, 120, 0)
+ I = (0, 120, 60)
+ J = (120, 0, 60)
+
+ # Noms lisibles pour les catégories
+ CATEGORY_NAMES = {
+ A: "Rouge",
+ B: "Vert",
+ C: "Bleu",
+ D: "Jaune",
+ E: "Magenta",
+ F: "Cyan",
+ G: "Marron",
+ H: "Olive",
+ I: "Teal",
+ J: "Bordeaux"
+ }
+
+ @classmethod
+ def list(cls):
+ return [cls.A, cls.B, cls.C, cls.D, cls.E, cls.F, cls.G, cls.H, cls.I, cls.J]
+
+ @classmethod
+ def get_name(cls, category):
+ """Get readable name for a category"""
+ return cls.CATEGORY_NAMES.get(category, f"Groupe_{category}")
+
+class ExportManager(QObject):
+ # Signaux pour le suivi de progression
+ progress_changed = pyqtSignal(int)
+ phase_changed = pyqtSignal(str)
+ export_finished = pyqtSignal(bool) # True = success, False = error
+
+ def __init__(self, dicom_manager):
+ super().__init__()
+ self.export_data = [] # Placeholder for data to export
+ self.dicom_manager = dicom_manager
+ self.export_destination = None # Destination folder for exports
+
+ def set_export_data(self, data):
+ self.export_data = data
+
+ def set_export_destination(self, destination_path):
+ """Set the destination folder for exports"""
+ self.export_destination = destination_path
+
+ def get_export_folder_name(self, root_name, category):
+ """Generate export folder name with readable category name"""
+ category_name = ExportCategory.get_name(category)
+ return f"{root_name}_{category_name}"
+
+ def get_export_dicom_list(self):
+ result = []
+ for parent in self.dicom_manager.get_dicoms():
+ if parent is None or parent.is_empty(): # Skip None or empty entries
+ continue
+
+ subfolders = parent.get_subfolders()
+ if subfolders:
+ result.append([parent, subfolders])
+ else:
+ # For single files without subfolders, create a list with the parent itself
+ result.append([parent, [parent]])
+ return result
+
+ def fetch_export_data(self):
+ result = []
+ for parent in self.dicom_manager.get_dicoms():
+ if parent is None or parent.is_empty(): # Skip None or empty entries
+ continue
+
+ subfolders = parent.get_subfolders()
+
+ # Handle case where there are no subfolders (single file DICOMs)
+ if not subfolders:
+ if parent.get_export(): # Check if the parent itself is marked for export
+ result.append([parent, [parent]])
+ continue
+
+ # Filter only checked subfolders
+ checked_subfolders = [subfolder for subfolder in subfolders if subfolder.get_export()]
+
+ if not checked_subfolders:
+ continue
+
+ # Group subfolders by category
+ categories = {}
+ for subfolder in checked_subfolders:
+ category = subfolder.get_category()
+ if category not in categories:
+ categories[category] = []
+ categories[category].append(subfolder)
+
+ # Create separate lists for each category
+ for category, category_subfolders in categories.items():
+ result.append([parent, category_subfolders])
+ return result
+
+ def export_images_as_pdf(self):
+ """Export images as PDF with progress tracking"""
+ self.phase_changed.emit("preparing_export")
+ data = self.fetch_export_data()
+ if not data:
+ self.export_finished.emit(False)
+ return False
+
+ try:
+ total_files = 0
+ # Count total files for progress calculation
+ for liste in data:
+ subfolders = liste[1]
+ for subfolder in subfolders:
+ files = subfolder.get_files()
+ total_files += len(files)
+
+ if total_files == 0:
+ self.export_finished.emit(False)
+ return False
+
+ self.phase_changed.emit("exporting_pdf")
+ processed_files = 0
+
+ for liste in data:
+ root = liste[0]
+ subfolders = liste[1]
+
+ # Use readable category name and destination
+ folder_name = self.get_export_folder_name(root.get_name(), subfolders[0].get_category())
+ base_path = self.export_destination if self.export_destination else os.getcwd()
+ pdf_path = os.path.join(base_path, f"{folder_name}.pdf")
+ c = canvas.Canvas(pdf_path)
+ first_image = True
+
+ for subfolder in subfolders:
+ files = subfolder.get_files()
+ mask = subfolder.get_mask()
+ if not files:
+ continue
+
+ for file in files:
+ image_array = file.get_image()
+ if image_array is not None:
+ # Apply mask if it exists
+ if mask is not None:
+ image_array = image_editor.apply_mask(image_array, mask)
+
+ # Convert numpy array to PIL Image
+ if isinstance(image_array, np.ndarray):
+ if not first_image:
+ c.showPage()
+ first_image = False
+
+ pil_image = Image.fromarray(image_array.astype('uint8'))
+ img_width, img_height = pil_image.size
+ c.setPageSize((img_width, img_height))
+ c.drawInlineImage(pil_image, 0, 0, width=img_width, height=img_height)
+
+ processed_files += 1
+ progress = int((processed_files / total_files) * 100)
+ self.progress_changed.emit(progress)
+
+ c.save()
+
+ self.phase_changed.emit("export_complete")
+ self.export_finished.emit(True)
+ return True
+
+ except Exception as e:
+ self.export_finished.emit(False)
+ return False
+
+ def export_images_as_png(self):
+ """Export images as PNG with progress tracking"""
+ self.phase_changed.emit("preparing_export")
+ data = self.fetch_export_data()
+ if not data:
+ self.export_finished.emit(False)
+ return False
+
+ try:
+ total_files = 0
+ # Count total files for progress calculation
+ for liste in data:
+ subfolders = liste[1]
+ for subfolder in subfolders:
+ files = subfolder.get_files()
+ total_files += len(files)
+
+ if total_files == 0:
+ self.export_finished.emit(False)
+ return False
+
+ self.phase_changed.emit("exporting_png")
+ processed_files = 0
+
+ for liste in data:
+ root = liste[0]
+ subfolders = liste[1]
+
+ # Use readable category name and destination
+ folder_name = self.get_export_folder_name(root.get_name(), subfolders[0].get_category())
+ base_path = self.export_destination if self.export_destination else os.getcwd()
+ png_folder = os.path.join(base_path, folder_name)
+ os.makedirs(png_folder, exist_ok=True)
+
+ for subfolder in subfolders:
+ files = subfolder.get_files()
+ mask = subfolder.get_mask()
+ if not files:
+ continue
+
+ for file in files:
+ image_array = file.get_image()
+ if image_array is not None:
+ # Apply mask if it exists
+ if mask is not None:
+ image_array = image_editor.apply_mask(image_array, mask)
+
+ # Convert numpy array to PIL Image
+ if isinstance(image_array, np.ndarray):
+ pil_image = Image.fromarray(image_array.astype('uint8'))
+ # Keep original filename
+ original_name = os.path.splitext(file.get_name())[0]
+ png_path = os.path.join(png_folder, f"{original_name}.png")
+ pil_image.save(png_path)
+
+ processed_files += 1
+ progress = int((processed_files / total_files) * 100)
+ self.progress_changed.emit(progress)
+
+ self.phase_changed.emit("export_complete")
+ self.export_finished.emit(True)
+ return True
+
+ except Exception as e:
+ self.export_finished.emit(False)
+ return False
+
+ def export_metadata_as_json(self):
+ """Export metadata as JSON with progress tracking"""
+ self.phase_changed.emit("preparing_export")
+ data = self.fetch_export_data()
+ if not data:
+ self.export_finished.emit(False)
+ return False
+
+ try:
+ total_subfolders = sum(len(liste[1]) for liste in data)
+ if total_subfolders == 0:
+ self.export_finished.emit(False)
+ return False
+
+ self.phase_changed.emit("exporting_json")
+ processed_subfolders = 0
+
+ for liste in data:
+ root = liste[0]
+ subfolders = liste[1]
+
+ # Use readable category name and destination
+ folder_name = self.get_export_folder_name(root.get_name(), subfolders[0].get_category())
+ base_path = self.export_destination if self.export_destination else os.getcwd()
+ json_folder = os.path.join(base_path, folder_name)
+ os.makedirs(json_folder, exist_ok=True)
+
+ for subfolder in subfolders:
+ try:
+ subfolder.export_metadata_json(json_folder)
+ except Exception as e:
+ # Continue with other subfolders even if one fails
+ continue
+
+ processed_subfolders += 1
+ progress = int((processed_subfolders / total_subfolders) * 100)
+ self.progress_changed.emit(progress)
+
+ self.phase_changed.emit("export_complete")
+ self.export_finished.emit(True)
+ return True
+
+ except Exception as e:
+ self.export_finished.emit(False)
+ return False
+
+ def export_metadata_as_xls(self):
+ """Export metadata as XLS with progress tracking"""
+ self.phase_changed.emit("preparing_export")
+ data = self.fetch_export_data()
+ if not data:
+ self.export_finished.emit(False)
+ return False
+
+ try:
+ total_subfolders = sum(len(liste[1]) for liste in data)
+ if total_subfolders == 0:
+ self.export_finished.emit(False)
+ return False
+
+ self.phase_changed.emit("exporting_xls")
+ processed_subfolders = 0
+
+ for liste in data:
+ root = liste[0]
+ subfolders = liste[1]
+
+ # Use readable category name and destination
+ folder_name = self.get_export_folder_name(root.get_name(), subfolders[0].get_category())
+ base_path = self.export_destination if self.export_destination else os.getcwd()
+ xls_folder = os.path.join(base_path, folder_name)
+ os.makedirs(xls_folder, exist_ok=True)
+
+ for subfolder in subfolders:
+ try:
+ subfolder.export_metadata_xls(xls_folder)
+ except Exception as e:
+ # Continue with other subfolders even if one fails
+ continue
+
+ processed_subfolders += 1
+ progress = int((processed_subfolders / total_subfolders) * 100)
+ self.progress_changed.emit(progress)
+
+ self.phase_changed.emit("export_complete")
+ self.export_finished.emit(True)
+ return True
+
+ except Exception as e:
+ self.export_finished.emit(False)
+ return False
+
+ def export_images_as_dicomdir(self):
+ """Export images as DICOMDIR with progress tracking"""
+ self.phase_changed.emit("preparing_export")
+ data = self.fetch_export_data()
+ if not data:
+ self.export_finished.emit(False)
+ return False
+
+ try:
+ total_files = 0
+ # Count total files for progress calculation
+ for liste in data:
+ subfolders = liste[1]
+ for subfolder in subfolders:
+ files = subfolder.get_files()
+ total_files += len(files)
+
+ if total_files == 0:
+ self.export_finished.emit(False)
+ return False
+
+ self.phase_changed.emit("exporting_dicomdir")
+ processed_files = 0
+
+ for liste in data:
+ root = liste[0]
+ subfolders = liste[1]
+
+ # Use readable category name and destination
+ folder_name = self.get_export_folder_name(root.get_name(), subfolders[0].get_category())
+ base_path = self.export_destination if self.export_destination else os.getcwd()
+ dicom_folder = os.path.join(base_path, f"{folder_name}_DICOMDIR")
+ os.makedirs(dicom_folder, exist_ok=True)
+
+ # Create DICOM directory structure with proper hierarchy
+ study_uid = generate_uid()
+
+ for i, subfolder in enumerate(subfolders):
+ files = subfolder.get_files()
+ mask = subfolder.get_mask()
+ if not files:
+ continue
+
+ # Create series folder with original subfolder name
+ series_uid = generate_uid()
+ series_folder = os.path.join(dicom_folder, subfolder.get_name())
+ os.makedirs(series_folder, exist_ok=True)
+
+ for j, file in enumerate(files):
+ try:
+ # Get original DICOM dataset
+ if not file.ds:
+ continue
+
+ # Create a deep copy of the dataset
+ new_ds = pydicom.dcmread(file.file_path)
+
+ # Apply mask to pixel data if mask exists
+ if mask is not None:
+ image_array = file.get_image()
+ if image_array is not None:
+ # Apply mask
+ masked_array = image_editor.apply_mask(image_array, mask)
+
+ # Convert RGB back to grayscale for DICOM storage
+ if len(masked_array.shape) == 3:
+ # Convert RGB to grayscale using standard weights
+ grayscale = np.dot(masked_array[...,:3], [0.2989, 0.5870, 0.1140]).astype(np.uint16)
+ else:
+ grayscale = masked_array.astype(np.uint16)
+
+ # Update pixel data and related attributes
+ new_ds.PixelData = grayscale.tobytes()
+ new_ds.Rows = int(grayscale.shape[0])
+ new_ds.Columns = int(grayscale.shape[1])
+ new_ds.BitsAllocated = 16
+ new_ds.BitsStored = 16
+ new_ds.HighBit = 15
+ new_ds.SamplesPerPixel = 1
+ new_ds.PhotometricInterpretation = 'MONOCHROME2'
+
+ # Update required DICOM attributes for DICOMDIR
+ new_ds.StudyInstanceUID = study_uid
+ new_ds.SeriesInstanceUID = series_uid
+ new_ds.SOPInstanceUID = generate_uid()
+ new_ds.InstanceNumber = str(j + 1)
+ new_ds.SeriesNumber = str(i + 1)
+
+ # Ensure required attributes exist
+ if not hasattr(new_ds, 'StudyID'):
+ new_ds.StudyID = '1'
+ if not hasattr(new_ds, 'SeriesDescription'):
+ new_ds.SeriesDescription = subfolder.get_name()
+ if not hasattr(new_ds, 'StudyDescription'):
+ new_ds.StudyDescription = root.get_name()
+ if not hasattr(new_ds, 'PatientName'):
+ new_ds.PatientName = 'Anonymous'
+ if not hasattr(new_ds, 'PatientID'):
+ new_ds.PatientID = 'ANON001'
+ if not hasattr(new_ds, 'StudyDate'):
+ new_ds.StudyDate = datetime.now().strftime('%Y%m%d')
+ if not hasattr(new_ds, 'StudyTime'):
+ new_ds.StudyTime = datetime.now().strftime('%H%M%S')
+
+
+ # Save modified DICOM file with original name
+ original_name = os.path.splitext(file.get_name())[0]
+ output_path = os.path.join(series_folder, original_name)
+
+ # Save without file extension for DICOMDIR compatibility
+ new_ds.save_as(output_path, write_like_original=False)
+
+ except Exception as e:
+ continue
+
+ processed_files += 1
+ progress = int((processed_files / total_files) * 90)
+ self.progress_changed.emit(progress)
+
+ # Create DICOMDIR file
+ self.progress_changed.emit(95)
+ success = self._create_dicomdir_file(dicom_folder)
+ if not success:
+ # If DICOMDIR creation fails, still consider export successful
+ # but create a simple index file
+ self._create_simple_index(dicom_folder)
+
+ self.phase_changed.emit("export_complete")
+ self.export_finished.emit(True)
+ return True
+
+ except Exception as e:
+ self.export_finished.emit(False)
+ return False
+
+ def _create_dicomdir_file(self, dicom_folder):
+ """Create a proper DICOMDIR file"""
+ try:
+ # Create DICOMDIR manually since pydicom's FileSet might have issues
+ from pydicom.dataset import Dataset, FileDataset
+ from pydicom import uid
+
+ # Create the DICOMDIR dataset
+ file_meta = Dataset()
+ file_meta.MediaStorageSOPClassUID = uid.MediaStorageDirectoryStorage
+ file_meta.MediaStorageSOPInstanceUID = generate_uid()
+ file_meta.ImplementationClassUID = generate_uid()
+ file_meta.TransferSyntaxUID = uid.ExplicitVRLittleEndian
+
+ # Create main dataset
+ ds = FileDataset('DICOMDIR', {}, file_meta=file_meta, preamble=b"\0" * 128)
+ ds.FileSetID = 'EXPORTED_STUDY'
+ ds.DirectoryRecordSequence = []
+
+ # Add directory records for each DICOM file
+ for root, dirs, files in os.walk(dicom_folder):
+ for file in files:
+ if not file.startswith('IM'): # Only process our DICOM files
+ continue
+
+ file_path = os.path.join(root, file)
+ try:
+ # Read the DICOM file to get metadata
+ file_ds = pydicom.dcmread(file_path)
+
+ # Create directory record
+ record = Dataset()
+ record.DirectoryRecordType = 'IMAGE'
+
+ # Set file reference
+ rel_path = os.path.relpath(file_path, dicom_folder)
+ # Convert path separators for DICOM
+ rel_path_parts = rel_path.replace('\\', '/').split('/')
+ record.ReferencedFileID = rel_path_parts
+
+ # Add essential metadata
+ if hasattr(file_ds, 'StudyInstanceUID'):
+ record.StudyInstanceUID = file_ds.StudyInstanceUID
+ if hasattr(file_ds, 'SeriesInstanceUID'):
+ record.SeriesInstanceUID = file_ds.SeriesInstanceUID
+ if hasattr(file_ds, 'SOPInstanceUID'):
+ record.ReferencedSOPInstanceUIDInFile = file_ds.SOPInstanceUID
+ if hasattr(file_ds, 'InstanceNumber'):
+ record.InstanceNumber = file_ds.InstanceNumber
+
+ ds.DirectoryRecordSequence.append(record)
+
+ except Exception as e:
+ continue
+
+ # Save DICOMDIR
+ dicomdir_path = os.path.join(dicom_folder, 'DICOMDIR')
+ ds.save_as(dicomdir_path, write_like_original=False)
+ return True
+
+ except Exception as e:
+ return False
+
+ def _create_simple_index(self, dicom_folder):
+ """Create a simple text index if DICOMDIR creation fails"""
+ try:
+ index_path = os.path.join(dicom_folder, 'INDEX.txt')
+ with open(index_path, 'w') as f:
+ f.write("DICOM Export Index\n")
+ f.write("==================\n\n")
+
+ for root, dirs, files in os.walk(dicom_folder):
+ for file in files:
+ if file.startswith('IM'):
+ rel_path = os.path.relpath(os.path.join(root, file), dicom_folder)
+ f.write(f"{rel_path}\n")
+ except:
+ pass
+
+ # Write DICOMDIR
+ dicomdir_path = os.path.join(dicom_folder, 'DICOMDIR')
+ f.write(dicomdir_path)
+
diff --git a/app/core/main_manager.py b/app/core/main_manager.py
index 439b5c4..3a51516 100644
--- a/app/core/main_manager.py
+++ b/app/core/main_manager.py
@@ -4,6 +4,8 @@ from app.core.theme_manager import ThemeManager
from app.core.settings_manager import SettingsManager
from app.core.alert_manager import AlertManager
from app.core.update_manager import UpdateManager
+from app.core.dicom_manager import DicomManager
+from app.core.export_manager import ExportManager
from typing import Optional
class MainManager:
@@ -20,7 +22,8 @@ class MainManager:
self.language_manager: LanguageManager = LanguageManager(self.settings_manager)
self.alert_manager: AlertManager = AlertManager(self.language_manager, self.theme_manager)
self.update_manager: UpdateManager = UpdateManager(self.settings_manager, self.language_manager, self.alert_manager)
-
+ self.dicom_manager: DicomManager = DicomManager()
+ self.export_manager: ExportManager = ExportManager(self.dicom_manager)
@classmethod
def get_instance(cls) -> 'MainManager':
if cls._instance is None:
@@ -43,4 +46,10 @@ class MainManager:
return self.alert_manager
def get_update_manager(self) -> UpdateManager:
- return self.update_manager
\ No newline at end of file
+ return self.update_manager
+
+ def get_dicom_manager(self) -> DicomManager:
+ return self.dicom_manager
+
+ def get_export_manager(self) -> ExportManager:
+ return self.export_manager
\ No newline at end of file
diff --git a/app/core/observer_manager.py b/app/core/observer_manager.py
index e1f8364..173880c 100644
--- a/app/core/observer_manager.py
+++ b/app/core/observer_manager.py
@@ -3,6 +3,7 @@ from typing import Callable, Dict, List, Any
class NotificationType:
THEME = 0
LANGUAGE = 1
+ DICOM = 2
class ObserverManager:
"""
diff --git a/app/core/theme_manager.py b/app/core/theme_manager.py
index 6cd9f9f..9f7f5da 100644
--- a/app/core/theme_manager.py
+++ b/app/core/theme_manager.py
@@ -155,4 +155,11 @@ class ThemeManager:
#tab_bar {{
background-color: {self.current_theme.get_color("background_color")};
}}
+ #drag_area {{
+ border: 2px dashed {self.current_theme.get_color("border_color")};
+ }}
+ #drag_indicator {{
+ border: 2px dashed #0078d7;
+ font-size: 60px;
+ }}
"""
\ No newline at end of file
diff --git a/app/core/update_manager.py b/app/core/update_manager.py
index 3eba809..159ee97 100644
--- a/app/core/update_manager.py
+++ b/app/core/update_manager.py
@@ -1,7 +1,7 @@
import requests
from packaging import version
from PyQt6.QtWidgets import QApplication
-from PyQt6.QtWidgets import QFileDialog, QDialog, QVBoxLayout, QTextEdit, QPushButton, QHBoxLayout, QLabel
+from PyQt6.QtWidgets import QFileDialog, QDialog, QVBoxLayout, QTextEdit, QPushButton
from app.core.alert_manager import AlertManager
from app.core.settings_manager import SettingsManager
from app.core.language_manager import LanguageManager
diff --git a/app/ui/main_window.py b/app/ui/main_window.py
index f5c21c4..fa68f34 100644
--- a/app/ui/main_window.py
+++ b/app/ui/main_window.py
@@ -1,10 +1,13 @@
-from PyQt6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QLabel, QFrame
+from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel
from PyQt6.QtGui import QResizeEvent, QCloseEvent
-from PyQt6.QtCore import QSize, QEvent, Qt
+from PyQt6.QtCore import QSize, QEvent
from app.core.main_manager import MainManager, NotificationType
from app.ui.widgets.tabs_widget import TabsWidget, MenuDirection, ButtonPosition, BorderSide, TabSide
from app.ui.windows.settings_window import SettingsWindow
from app.ui.windows.suggestion_window import SuggestionWindow
+from app.ui.windows.dicom_window import DicomWindow
+from app.ui.windows.export_window import ExportWindow
+from app.ui.windows.import_window import ImportWindow
import app.utils.paths as paths, shutil
from typing import Optional
@@ -19,7 +22,7 @@ class MainWindow(QMainWindow):
self.settings_manager = self.main_manager.get_settings_manager()
self.observer_manager = self.main_manager.get_observer_manager()
self.observer_manager.subscribe(NotificationType.THEME, self.update_theme)
-
+ self.observer_manager.subscribe(NotificationType.DICOM, self.goto_dicom_window)
self.is_maximizing: bool = False
self.current_size: QSize
self.previous_size: QSize
@@ -77,8 +80,6 @@ class MainWindow(QMainWindow):
self.current_size.width(),
self.current_size.height()
)
-
- # Nettoyage des icônes temporaires générées (supprime tout le dossier temp_icons à la fermeture)
try:
shutil.rmtree(paths.get_user_temp(self.settings_manager.get_config("app_name")))
except Exception:
@@ -86,18 +87,30 @@ class MainWindow(QMainWindow):
def setup_ui(self) -> None:
- self.side_menu = TabsWidget(self, MenuDirection.HORIZONTAL, 70, None, 10, 1, BorderSide.BOTTOM, TabSide.TOP)
+ self.side_menu = TabsWidget(self, MenuDirection.VERTICAL, 70, None, 10, 1, BorderSide.LEFT, TabSide.LEFT)
+
+ self.import_window = ImportWindow(self)
+ self.side_menu.add_widget(self.import_window, "", paths.get_asset_svg_path("import"), position=ButtonPosition.CENTER)
+
+ self.dicom_window = DicomWindow(self)
+ self.side_menu.add_widget(self.dicom_window, "", paths.get_asset_svg_path("folder"), position=ButtonPosition.CENTER)
+ self.export_window = ExportWindow(self)
+ self.side_menu.add_widget(self.export_window, "", paths.get_asset_svg_path("export"), position=ButtonPosition.CENTER)
self.suggestion_window = SuggestionWindow(self)
- self.side_menu.add_widget(self.suggestion_window, "", paths.get_asset_svg_path("suggestion"), position=ButtonPosition.CENTER)
+ self.side_menu.add_widget(self.suggestion_window, "", paths.get_asset_svg_path("suggestion"), position=ButtonPosition.END)
self.settings_window = SettingsWindow(self)
- self.side_menu.add_widget(self.settings_window, "", paths.get_asset_svg_path("settings"), position=ButtonPosition.CENTER)
-
+ self.side_menu.add_widget(self.settings_window, "", paths.get_asset_svg_path("settings"), position=ButtonPosition.END)
+
self.setCentralWidget(self.side_menu)
def update_theme(self) -> None:
self.setStyleSheet(self.theme_manager.get_sheet())
def update_language(self) -> None:
- self.footer_label.setText(self.language_manager.get_text("footer_text"))
\ No newline at end of file
+ self.footer_label.setText(self.language_manager.get_text("footer_text"))
+
+ def goto_dicom_window(self):
+ """Navigate to DICOM window when DICOMs are imported"""
+ self.side_menu.switch_to_tab(1)
\ No newline at end of file
diff --git a/app/ui/widgets/drag_drop_frame.py b/app/ui/widgets/drag_drop_frame.py
new file mode 100644
index 0000000..5f92dd0
--- /dev/null
+++ b/app/ui/widgets/drag_drop_frame.py
@@ -0,0 +1,59 @@
+from PyQt6.QtWidgets import QFrame, QLabel
+from PyQt6.QtCore import Qt
+
+class DragDropFrame(QFrame):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.parent_window = parent
+ self.setAcceptDrops(True)
+ self.is_dragging = False
+ self.drag_indicator = self.getDragIndicator()
+ self.drag_text = self.getDragText()
+
+ def dragEnterEvent(self, event):
+ if event.mimeData().hasUrls():
+ self.is_dragging = True
+ self.drag_text.hide()
+ self.drag_indicator.show()
+ event.acceptProposedAction()
+ else:
+ event.ignore()
+
+ def dragLeaveEvent(self, event):
+ self.is_dragging = False
+ self.drag_indicator.hide()
+ self.drag_text.show()
+ super().dragLeaveEvent(event)
+
+ def dropEvent(self, event):
+ self.is_dragging = False
+ self.drag_indicator.hide()
+ self.drag_text.show()
+ urls = event.mimeData().urls()
+ if urls and self.parent_window:
+ file_path = urls[0].toLocalFile()
+ self.parent_window.set_dicom_from_files(file_path)
+
+ def getDragIndicator(self) -> QLabel:
+ drag_indicator = QLabel(self)
+ drag_indicator.setText("+")
+ drag_indicator.setObjectName("drag_indicator")
+ drag_indicator.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ drag_indicator.show()
+ return drag_indicator
+
+ def getDragText(self) -> QLabel:
+ drag_text = QLabel(self)
+ drag_text.setObjectName("drag_area")
+ drag_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ drag_text.show()
+ return drag_text
+
+ def setDragText(self, text: str):
+ self.drag_text.setText(text)
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ # Ensure components fill the entire frame
+ self.drag_indicator.resize(self.size())
+ self.drag_text.resize(self.size())
\ No newline at end of file
diff --git a/app/ui/widgets/image_viewer.py b/app/ui/widgets/image_viewer.py
new file mode 100644
index 0000000..dcabc30
--- /dev/null
+++ b/app/ui/widgets/image_viewer.py
@@ -0,0 +1,176 @@
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QPainter, QPixmap, QPen, QImage
+from PyQt6.QtWidgets import QWidget
+from app.core.main_manager import MainManager
+import numpy as np
+
+class ImageViewer(QWidget):
+ def __init__(self, parent = None):
+ super().__init__(parent)
+ self.DicomWindow = parent
+
+ self.main_manager = MainManager.get_instance()
+ self.dicom_manager = self.main_manager.get_dicom_manager()
+
+ self.display_image = None
+ self.original_image = None
+ self.drawing = False
+ self.brush_size = 10
+ self.current_stroke = [] # Points du trait en cours
+ self.last_point = None
+ self.setMouseTracking(True)
+
+ def set_image(self, image):
+ self.original_image = image
+ self.display_image = QPixmap.fromImage(image)
+ self.update()
+
+ def paintEvent(self, event):
+ _ = event
+ if self.display_image:
+ painter = QPainter(self)
+ scaled = self.display_image.scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.FastTransformation)
+ x = (self.width() - scaled.width()) // 2
+ y = (self.height() - scaled.height()) // 2
+ painter.drawPixmap(x, y, scaled)
+
+ # Dessiner le trait en cours en temps réel avec la même taille que le brush
+ if self.drawing and len(self.current_stroke) > 1:
+ pen = QPen(Qt.GlobalColor.black, self.brush_size, Qt.PenStyle.SolidLine)
+ pen.setCapStyle(Qt.PenCapStyle.RoundCap)
+ pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
+ painter.setPen(pen)
+
+ # Dessiner des lignes entre les points pour un rendu fluide
+ for i in range(1, len(self.current_stroke)):
+ painter.drawLine(
+ int(self.current_stroke[i-1][0]), int(self.current_stroke[i-1][1]),
+ int(self.current_stroke[i][0]), int(self.current_stroke[i][1])
+ )
+
+ def mousePressEvent(self, event):
+ if event.buttons() & Qt.MouseButton.LeftButton:
+ self.drawing = True
+ point = (event.position().x(), event.position().y())
+ self.current_stroke = [point]
+ self.last_point = point
+
+ def mouseMoveEvent(self, event):
+ if self.drawing:
+ current_point = (event.position().x(), event.position().y())
+
+ # Interpoler avec moins de points pour plus de fluidité
+ if self.last_point:
+ interpolated_points = self.interpolate_points(self.last_point, current_point, max_distance=3)
+ self.current_stroke.extend(interpolated_points)
+ else:
+ self.current_stroke.append(current_point)
+
+ self.last_point = current_point
+ self.update()
+
+ def mouseReleaseEvent(self, event):
+ if self.drawing:
+ self.drawing = False
+ mask = self.create_mask_from_stroke()
+ if mask is not None:
+ self.DicomWindow.apply_brush_mask(mask)
+ self.current_stroke = []
+ self.last_point = None
+
+ def wheelEvent(self, event):
+ delta = event.angleDelta().y()
+ if delta != 0:
+ self.DicomWindow.scroll_images(1 if delta > 0 else -1)
+ event.accept()
+
+ def screen_to_image_coords(self, x, y):
+ if not self.display_image:
+ return None
+
+ scaled = self.display_image.scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.FastTransformation)
+ x_offset = (self.width() - scaled.width()) // 2
+ y_offset = (self.height() - scaled.height()) // 2
+
+ # Vérifie que le point est dans l'image affichée
+ if not (x_offset <= x <= x_offset + scaled.width()) or not (y_offset <= y <= y_offset + scaled.height()):
+ return None
+
+ # Ramène dans les coordonnées de l'image d'origine
+ img_x = (x - x_offset) * self.original_image.width() / scaled.width()
+ img_y = (y - y_offset) * self.original_image.height() / scaled.height()
+ return int(img_x), int(img_y)
+
+ def interpolate_points(self, start_point, end_point, max_distance=2):
+ """Interpole les points avec une distance maximale réduite pour plus de fluidité"""
+ x1, y1 = start_point
+ x2, y2 = end_point
+
+ # Calcule la distance entre les points
+ distance = max(abs(x2 - x1), abs(y2 - y1))
+
+ # Si la distance est petite, pas besoin d'interpoler
+ if distance <= max_distance:
+ return [end_point]
+
+ # Crée des points intermédiaires avec une granularité plus fine
+ points = []
+ steps = max(2, int(distance / max_distance))
+
+ for i in range(1, steps + 1):
+ t = i / steps
+ x = x1 + (x2 - x1) * t
+ y = y1 + (y2 - y1) * t
+ points.append((x, y))
+
+ return points
+
+ def create_mask_from_stroke(self):
+ """Crée un mask en utilisant QPainter sur une QImage temporaire"""
+ if not self.original_image or len(self.current_stroke) < 1:
+ return None
+
+ # Créer une QImage temporaire de même taille que l'image originale
+ mask_image = QImage(self.original_image.width(), self.original_image.height(), QImage.Format.Format_Grayscale8)
+ mask_image.fill(0) # Remplir de noir (0)
+
+ # Calculer les paramètres d'affichage
+ scaled = self.display_image.scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.FastTransformation)
+ x_offset = (self.width() - scaled.width()) // 2
+ y_offset = (self.height() - scaled.height()) // 2
+
+ scale_x = self.original_image.width() / scaled.width()
+ scale_y = self.original_image.height() / scaled.height()
+
+ # Dessiner sur le mask avec QPainter
+ painter = QPainter(mask_image)
+ pen = QPen(Qt.GlobalColor.white, self.brush_size * scale_x, Qt.PenStyle.SolidLine)
+ pen.setCapStyle(Qt.PenCapStyle.RoundCap)
+ pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
+ painter.setPen(pen)
+
+ # Convertir et dessiner les points
+ for i in range(1, len(self.current_stroke)):
+ # Point précédent
+ sx1, sy1 = self.current_stroke[i-1]
+ img_x1 = (sx1 - x_offset) * scale_x
+ img_y1 = (sy1 - y_offset) * scale_y
+
+ # Point actuel
+ sx2, sy2 = self.current_stroke[i]
+ img_x2 = (sx2 - x_offset) * scale_x
+ img_y2 = (sy2 - y_offset) * scale_y
+
+ # Dessiner la ligne
+ painter.drawLine(int(img_x1), int(img_y1), int(img_x2), int(img_y2))
+
+ painter.end()
+
+ # Convertir en numpy array
+ width = mask_image.width()
+ height = mask_image.height()
+ ptr = mask_image.constBits()
+ ptr.setsize(height * width)
+ mask_array = np.frombuffer(ptr, np.uint8).reshape((height, width)).copy()
+
+ return mask_array
\ No newline at end of file
diff --git a/app/ui/widgets/loading_bar.py b/app/ui/widgets/loading_bar.py
index bfc61b3..fa50439 100644
--- a/app/ui/widgets/loading_bar.py
+++ b/app/ui/widgets/loading_bar.py
@@ -5,7 +5,7 @@ class LoadingBar(QWidget):
def __init__(self, label_text: str = "", parent=None) -> None:
super().__init__(parent)
layout = QVBoxLayout(self)
- layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.setAlignment(Qt.AlignmentFlag.AlignBaseline)
self.label = QLabel(label_text, self)
self.progress = QProgressBar(self)
self.progress.setMinimum(0)
@@ -18,3 +18,17 @@ class LoadingBar(QWidget):
def set_progress(self, value: int) -> None:
self.progress.setValue(value)
+
+ def set_progress_range(self, value: int, min_range: int, max_range: int) -> None:
+ """Set progress within a specific range"""
+ if min_range >= max_range:
+ return
+ # Clamp value to range
+ clamped_value = max(min_range, min(max_range, value))
+ # Convert to 0-100 scale
+ progress = int(((clamped_value - min_range) / (max_range - min_range)) * 100)
+ self.progress.setValue(progress)
+
+ def get_progress(self) -> int:
+ """Get current progress value"""
+ return self.progress.value()
diff --git a/app/ui/widgets/subfolder_widget.py b/app/ui/widgets/subfolder_widget.py
new file mode 100644
index 0000000..b4ea8bd
--- /dev/null
+++ b/app/ui/widgets/subfolder_widget.py
@@ -0,0 +1,107 @@
+from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QScrollArea,
+ QFrame, QHBoxLayout, QLabel)
+from PyQt6.QtCore import pyqtSignal, Qt
+
+class SelectableWidgetItem(QFrame):
+ clicked = pyqtSignal(object)
+
+ def __init__(self, dicom=None, parent=None):
+ super().__init__(parent)
+ self.selected = False
+ self.dicom = dicom
+ self.setup_ui(dicom.get_name(), dicom.get_icon())
+
+ def setup_ui(self, label_text, icon=None):
+ self.setFrameStyle(QFrame.Shape.Box)
+ self.setLineWidth(1)
+
+ layout = QHBoxLayout(self)
+
+ if icon:
+ try:
+ icon_label = QLabel()
+ icon_label.setPixmap(icon.pixmap(32, 32))
+ layout.addWidget(icon_label)
+ except Exception:
+ # Skip icon if there's an error
+ pass
+
+ if label_text:
+ label = QLabel(label_text)
+ layout.addWidget(label)
+
+ def mousePressEvent(self, event):
+ if event.button() == Qt.MouseButton.LeftButton:
+ self.clicked.emit(self)
+ super().mousePressEvent(event)
+
+ def set_selected(self, selected):
+ self.selected = selected
+class SubfolderWidget(QWidget):
+ widgetSelected = pyqtSignal(object)
+
+ def __init__(self, parent=None, dicoms=None):
+ super().__init__(parent)
+ self.parent = parent
+ self.widget_items = []
+ self.selected_item = None
+ self.setup_ui()
+ if dicoms:
+ self.create_and_add_widgets(dicoms)
+
+ def setup_ui(self):
+ layout = QVBoxLayout(self)
+
+ self.scroll_area = QScrollArea()
+ self.scroll_area.setWidgetResizable(True)
+
+ self.content_widget = QWidget()
+ self.content_layout = QVBoxLayout(self.content_widget)
+ self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+
+ self.scroll_area.setWidget(self.content_widget)
+ layout.addWidget(self.scroll_area)
+
+ def add_widget(self, dicom):
+ """Add a widget to the list"""
+ item = SelectableWidgetItem(dicom, self)
+ item.clicked.connect(self.on_item_selected)
+
+ self.widget_items.append(item)
+ self.content_layout.addWidget(item)
+
+ def on_item_selected(self, item):
+ """Handle item selection"""
+ if self.selected_item:
+ self.selected_item.set_selected(False)
+ self.selected_item = None
+
+ self.selected_item = item
+ item.set_selected(True)
+
+ self.widgetSelected.emit(item.dicom)
+
+ def get_selected_widget(self):
+ """Get the currently selected widget"""
+ if self.selected_item:
+ return self.selected_item
+ elif self.widget_items:
+ self.selected_item = self.widget_items[0]
+ return self.selected_item
+ return None
+
+ def clear_widgets(self):
+ """Remove all widgets from the list"""
+ for item in self.widget_items:
+ self.content_layout.removeWidget(item)
+ item.deleteLater()
+
+ self.widget_items.clear()
+ self.selected_item = None
+
+ def create_and_add_widgets(self, dicoms):
+ for dicom in dicoms:
+ widget = SelectableWidgetItem(dicom, self)
+ widget.clicked.connect(self.on_item_selected)
+ self.widget_items.append(widget)
+ self.content_layout.addWidget(widget)
\ No newline at end of file
diff --git a/app/ui/windows/dicom_window.py b/app/ui/windows/dicom_window.py
new file mode 100644
index 0000000..dc4ad0f
--- /dev/null
+++ b/app/ui/windows/dicom_window.py
@@ -0,0 +1,507 @@
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QComboBox,
+ QLabel, QSlider, QSizePolicy, QStackedWidget, QGridLayout
+)
+from PyQt6.QtGui import QImage, QIcon
+from PyQt6.QtCore import Qt, QTimer, QSize, pyqtSignal
+import app.utils.paths as paths
+import app.utils.image_editor as image_editor
+from app.core.main_manager import MainManager, NotificationType
+from app.ui.widgets.subfolder_widget import SubfolderWidget
+from app.ui.widgets.image_viewer import ImageViewer
+import os
+
+class DicomWindow(QWidget):
+ setup_progress = pyqtSignal(int)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self.main_manager = MainManager.get_instance()
+ self.dicom_manager = self.main_manager.get_dicom_manager()
+ self.language_manager = self.main_manager.get_language_manager()
+ self.settings_manager = self.main_manager.get_settings_manager()
+
+ self.observer_manager = self.main_manager.get_observer_manager()
+ self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_texts)
+ self.observer_manager.subscribe(NotificationType.DICOM, self.on_dicoms_imported)
+
+ self.current_dicom_count = 0 # Track how many DICOMs we have displayed
+ self.current_image_index = 0
+ self.timer = QTimer(self)
+ self.timer.timeout.connect(self.go_to_next)
+ self.selected_dicom = None
+ self.is_brush_enabled = False
+ self.init_ui()
+
+ def init_ui(self):
+ self.main_layout = QVBoxLayout()
+ self.main_layout.setSpacing(5)
+ self.main_layout.setContentsMargins(10, 10, 10, 10)
+ self.setLayout(self.main_layout)
+
+ # Create stacked widget for switching between states
+ self.stacked_widget = QStackedWidget()
+ self.main_layout.addWidget(self.stacked_widget)
+ self.create_no_dicom_widget()
+ self.create_dicom_viewer_widget()
+
+ # Start with no dicom message
+ self.stacked_widget.setCurrentIndex(0)
+
+ def on_dicoms_imported(self):
+ """Called when new DICOMs are imported"""
+ current_dicoms = self.dicom_manager.get_dicoms()
+
+ # Check if we have new DICOMs to add
+ if len(current_dicoms) > self.current_dicom_count:
+ new_dicoms = current_dicoms[self.current_dicom_count:]
+ self.add_new_dicoms(new_dicoms)
+ self.current_dicom_count = len(current_dicoms)
+
+ # Select the LAST imported DICOM (not the first)
+ if self.dicom_combobox.count() > 0:
+ self.stacked_widget.setCurrentIndex(1)
+ # Select the last item in the combobox (most recently added)
+ last_index = self.dicom_combobox.count() - 1
+ self.dicom_combobox.setCurrentIndex(last_index)
+ self.on_dicom_selected(last_index)
+
+ def add_new_dicoms(self, new_dicoms):
+ """Add only the new DICOMs to the combobox"""
+ if not new_dicoms:
+ return
+
+ # Filter out invalid DICOMs
+ valid_dicoms = [d for d in new_dicoms if d is not None and not d.is_empty()]
+
+ if not valid_dicoms:
+ return
+
+ # Build set of existing DICOM paths (normalized)
+ existing_paths = set()
+ for i in range(self.dicom_combobox.count()):
+ existing_dicom = self.dicom_combobox.itemData(i)
+ if existing_dicom and existing_dicom.get_path():
+ existing_paths.add(os.path.normpath(existing_dicom.get_path()))
+
+ # Filter out DICOMs with paths that already exist in combobox
+ unique_dicoms = []
+ for dicom in valid_dicoms:
+ if dicom.get_path():
+ normalized_path = os.path.normpath(dicom.get_path())
+ if normalized_path not in existing_paths:
+ unique_dicoms.append(dicom)
+ existing_paths.add(normalized_path) # Add to set to avoid duplicates within this batch
+
+ if not unique_dicoms:
+ return
+
+ # Temporarily disconnect the signal to avoid triggering during batch add
+ try:
+ self.dicom_combobox.currentIndexChanged.disconnect()
+ except:
+ pass
+
+ # Add each unique DICOM to the combobox
+ for dicom in unique_dicoms:
+ try:
+ icon = dicom.get_icon()
+ if icon is not None:
+ self.dicom_combobox.addItem(icon, dicom.get_name(), dicom)
+ else:
+ self.dicom_combobox.addItem(dicom.get_name(), dicom)
+ except Exception as e:
+ continue
+
+ # Reconnect the signal
+ self.dicom_combobox.currentIndexChanged.connect(self.on_dicom_selected)
+
+ def setup_ui(self):
+ """Legacy method - now just calls on_dicoms_imported for compatibility"""
+ self.on_dicoms_imported()
+
+ def empty_dicom_combobox(self):
+ """Empty the DICOM combobox and reset state"""
+ try:
+ self.dicom_combobox.currentIndexChanged.disconnect()
+ except:
+ pass
+ self.dicom_combobox.clear()
+ self.selected_dicom = None
+ self.current_dicom_count = 0
+
+ def create_no_dicom_widget(self):
+ """Create widget shown when no DICOM files are available"""
+ no_dicom_widget = QWidget()
+ no_dicom_layout = QVBoxLayout(no_dicom_widget)
+ no_dicom_layout.setContentsMargins(0, 0, 0, 0)
+ no_dicom_layout.setSpacing(0)
+
+ self.no_dicom_label = QLabel(self.language_manager.get_text("no_dicom_files"))
+ self.no_dicom_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ no_dicom_layout.addWidget(self.no_dicom_label)
+
+ self.stacked_widget.addWidget(no_dicom_widget)
+
+ def create_dicom_viewer_widget(self):
+ """Create widget shown when DICOM files are available"""
+ main_widget = QWidget()
+ main_layout = QHBoxLayout(main_widget)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.setSpacing(10)
+
+ left_layout = QVBoxLayout()
+ left_layout.setContentsMargins(0, 0, 0, 0)
+ left_layout.setSpacing(0)
+
+ self.dicom_combobox = QComboBox()
+ self.dicom_combobox.setMaximumWidth(200)
+ left_layout.addWidget(self.dicom_combobox)
+
+ self.subfolder_widget = SubfolderWidget(self)
+ self.subfolder_widget.widgetSelected.connect(self.process_dicom)
+ self.subfolder_widget.setMaximumWidth(200)
+ left_layout.addWidget(self.subfolder_widget)
+
+ main_layout.addLayout(left_layout)
+ dicom_layout = QVBoxLayout()
+ dicom_layout.setContentsMargins(0, 0, 0, 0)
+ dicom_layout.setSpacing(0)
+
+ self.controls_layout = QGridLayout()
+ self.first_button = self.create_icon_button(paths.get_asset_svg_path("first"), self.go_to_first)
+ self.controls_layout.addWidget(self.first_button, 0, 0)
+ self.previous_button = self.create_icon_button(paths.get_asset_svg_path("back"), self.go_to_previous)
+ self.controls_layout.addWidget(self.previous_button, 0, 1)
+ self.play_button = self.create_icon_button(paths.get_asset_svg_path("play"), self.start_stop_playback)
+ self.controls_layout.addWidget(self.play_button, 0, 2)
+ self.next_button = self.create_icon_button(paths.get_asset_svg_path("next"), self.go_to_next)
+ self.controls_layout.addWidget(self.next_button, 0, 3)
+ self.last_button = self.create_icon_button(paths.get_asset_svg_path("last"), self.go_to_last)
+ self.controls_layout.addWidget(self.last_button, 0, 4)
+
+ self.slider = QSlider(Qt.Orientation.Horizontal)
+ self.slider.setMinimum(0)
+ self.slider.setValue(0)
+ self.slider.setTickInterval(1)
+ self.slider.valueChanged.connect(self.slider_changed)
+ self.controls_layout.addWidget(self.slider, 0, 5, 1, 5)
+
+ self.index_label = QLabel("0 / 0")
+ self.index_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.controls_layout.addWidget(self.index_label, 0, 10)
+
+ # Add the controls menu to the layout
+ dicom_layout.addLayout(self.controls_layout)
+
+ # Image viewer
+ self.image_viewer = ImageViewer(self)
+ self.image_viewer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+ dicom_layout.addWidget(self.image_viewer, 1)
+
+ # Bottom filename label
+ self.bottom_filename_label = QLabel()
+ self.bottom_filename_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ dicom_layout.addWidget(self.bottom_filename_label, 0)
+
+ # Bottom controls
+ self.bottom_layout = QHBoxLayout()
+ self.bottom_layout.setContentsMargins(0, 0, 0, 0)
+ self.bottom_layout.setSpacing(5)
+
+ self.brush_button = QPushButton(self.language_manager.get_text("eraser"))
+ self.brush_button.clicked.connect(self.enable_brush)
+ self.bottom_layout.addWidget(self.brush_button)
+
+ self.clear_button = QPushButton(self.language_manager.get_text("rebuild"))
+ self.clear_button.clicked.connect(self.clear_all_masks)
+ self.bottom_layout.addWidget(self.clear_button)
+
+ dicom_layout.addLayout(self.bottom_layout, 0)
+
+
+ main_layout.addLayout(dicom_layout)
+
+ self.stacked_widget.addWidget(main_widget)
+
+ def create_icon_button(self, icon_path, callback):
+ btn = QPushButton() # Define the QPushButton instance
+ btn.setIconSize(QSize(20, 20))
+ btn.setIcon(QIcon(icon_path))
+ btn.setFlat(True)
+ btn.setStyleSheet("QPushButton { background: transparent; border: none; margin: 0 2px; }")
+ btn.clicked.connect(callback)
+ return btn
+
+ def update_texts(self):
+ self.brush_button.setText(self.language_manager.get_text("quit_eraser") if self.is_brush_enabled else self.language_manager.get_text("eraser"))
+ self.clear_button.setText(self.language_manager.get_text("clear_mask"))
+ self.no_dicom_label.setText(self.language_manager.get_text("no_dicom_files"))
+
+ def on_dicom_selected(self, index):
+ """Handle DICOM selection from combobox"""
+ if index < 0 or index >= self.dicom_combobox.count():
+ return
+
+ dicom = self.dicom_combobox.itemData(index)
+ self.combobox_change(dicom)
+
+ def combobox_change(self, dicom):
+ """Handle DICOM combobox change event"""
+ # Add null check to prevent AttributeError
+ if dicom is None:
+ self.bottom_filename_label.setText(self.language_manager.get_text("no_images_found"))
+ self.selected_dicom = None
+ self.current_image_index = 0
+ return
+
+ if dicom.get_subfolders() or dicom.get_files():
+ self.selected_dicom = dicom
+ self.current_image_index = 0 # Reset image index when changing DICOM
+ self.set_subfolder_widget()
+ self.process_dicom()
+ else:
+ self.bottom_filename_label.setText(self.language_manager.get_text("no_images_found"))
+ self.selected_dicom = None
+ self.current_image_index = 0
+
+ def process_dicom(self):
+ """Process DICOM selection and update UI accordingly"""
+ if self.get_displayed_dicom().get_files():
+ self.current_image_index = 0
+ self.update_slider_range()
+ self.load_image()
+ else:
+ self.set_subfolder_widget()
+
+ def update_slider_range(self):
+ """Update slider range when new DICOM is loaded"""
+ if not self.selected_dicom:
+ self.slider.setMaximum(0)
+ self.slider.setValue(0)
+ self.index_label.setText("0 / 0")
+ return
+ dicom = self.get_displayed_dicom()
+ files = dicom.get_files() if dicom else []
+ self.slider.setValue(0)
+ self.slider.setMaximum(len(files)-1)
+
+ def load_image(self):
+ # Verify image viewer is still valid before using it
+ if not hasattr(self, 'image_viewer') or not self.image_viewer:
+ return
+
+ try:
+ # Test if the widget is still valid by calling a simple method
+ self.image_viewer.parentWidget()
+ except RuntimeError:
+ # Widget has been deleted, skip loading
+ return
+
+ dicom = self.get_displayed_dicom()
+ if not dicom:
+ return
+
+ mask = dicom.get_mask()
+ used_list = dicom.get_files()
+
+ if not used_list or self.current_image_index >= len(used_list):
+ return
+
+ current_image = used_list[self.current_image_index]
+
+ # Check if the current image is valid
+ if not hasattr(current_image, 'is_valid') or not current_image.is_valid:
+ self.bottom_filename_label.setText(f"{current_image.get_name()} - Error loading")
+ return
+
+ try:
+ pixel_array = current_image.get_scaled_image()
+ if pixel_array is None:
+ self.bottom_filename_label.setText(f"{current_image.get_name()} - No image data")
+ return
+
+ height, width, channel = pixel_array.shape
+ bytes_per_line = channel * width
+ qt_image = QImage(pixel_array.data, width, height, bytes_per_line, QImage.Format.Format_RGB888)
+
+ # Apply existing mask if it exists for this subfolder
+ if mask is not None:
+ pixel_array_for_mask = self.dicom_manager.image_to_array(qt_image)
+ masked_array = image_editor.apply_mask(pixel_array_for_mask, mask)
+ qt_image = self.dicom_manager.array_to_image(masked_array)
+
+ self.image_viewer.set_image(qt_image)
+
+ self.bottom_filename_label.setText(current_image.get_name())
+ self.slider.setValue(self.current_image_index)
+ self.index_label.setText(f"{self.current_image_index + 1} / {len(used_list)}")
+
+ except Exception as e:
+ self.bottom_filename_label.setText(self.language_manager.get_text("error_loading_image").replace("{x}", current_image.get_name()))
+
+ def slider_changed(self, value):
+ self.current_image_index = value
+ self.load_image()
+
+ def scroll_images(self, delta):
+ new_index = self.current_image_index + delta
+ if 0 <= new_index < len(self.get_displayed_dicom().get_files()):
+ self.current_image_index = new_index
+ self.load_image()
+
+ def go_to_first(self):
+ if self.get_displayed_dicom():
+ self.current_image_index = 0
+ self.load_image()
+
+ def go_to_previous(self):
+ if self.current_image_index > 0:
+ self.current_image_index -= 1
+ self.load_image()
+
+ def go_to_next(self):
+ # Get the correct file list
+ dicom = self.get_displayed_dicom()
+ if dicom:
+ files = dicom.get_files()
+ else:
+ files = []
+
+ if self.current_image_index < len(files) - 1:
+ self.current_image_index += 1
+ self.load_image()
+ elif self.timer.isActive():
+ self.current_image_index = 0
+ self.load_image()
+
+ def go_to_last(self):
+ if self.selected_dicom:
+ dicom = self.get_displayed_dicom()
+ if dicom:
+ files = dicom.get_files()
+ self.current_image_index = len(files) - 1
+ self.load_image()
+
+ def start_stop_playback(self):
+ if self.selected_dicom:
+ if self.timer.isActive():
+ # Update the play button icon in the menu
+ self.play_button.setIcon(QIcon(paths.get_asset_svg_path("play")))
+ self.timer.stop()
+ else:
+ # Update the play button icon in the menu
+ self.play_button.setIcon(QIcon(paths.get_asset_svg_path("stop")))
+ if self.current_image_index > len(self.selected_dicom.get_files()) - 1:
+ self.current_image_index = 0
+ self.slider.setValue(0)
+ self.timer.start(100)
+
+ def enable_brush(self):
+ self.is_brush_enabled = not self.is_brush_enabled # Bascule l'état
+ self.brush_button.setText(self.language_manager.get_text("quit_eraser") if self.is_brush_enabled else self.language_manager.get_text("eraser")) # Met à jour le texte du bouton
+
+ # Change cursor when brush is enabled/disabled
+ if self.is_brush_enabled:
+ self.image_viewer.setCursor(Qt.CursorShape.CrossCursor)
+ else:
+ self.image_viewer.setCursor(Qt.CursorShape.ArrowCursor)
+
+ def apply_brush(self, screen_points):
+ if not self.is_brush_enabled: # Vérifie si le mode "masque" est activé
+ return
+
+ img = self.image_viewer.original_image
+ if img is None:
+ return
+
+ img_shape = (img.height(), img.width(), 3)
+ brush_coords = []
+
+ for sx, sy in screen_points:
+ res = self.image_viewer.screen_to_image_coords(sx, sy)
+ if res:
+ x, y = res
+ if 0 <= x < img_shape[1] and 0 <= y < img_shape[0]:
+ brush_coords.append((x, y))
+
+ # Get the current subfolder path instead of filename
+ dicom = self.get_displayed_dicom()
+
+ # Get existing mask or create new one
+ existing_mask = dicom.get_mask()
+
+ # Create new mask from brush coords
+ new_mask = image_editor.get_mask(brush_coords, img_shape)
+
+ # Combine with existing mask if it exists
+ if existing_mask is not None:
+ combined_mask = image_editor.combine_masks(existing_mask, new_mask)
+ else:
+ combined_mask = new_mask
+
+ # Update mask for current subfolder
+ dicom.set_mask(combined_mask)
+
+ # Refresh the display to show the updated mask
+ self.load_image()
+
+ def apply_brush_mask(self, new_mask):
+ """Applique un mask numpy directement"""
+ if not self.is_brush_enabled:
+ return
+
+ # Get the current subfolder
+ dicom = self.get_displayed_dicom()
+
+ # Get existing mask or create new one
+ existing_mask = dicom.get_mask()
+
+ # Combine with existing mask if it exists
+ if existing_mask is not None:
+ combined_mask = image_editor.combine_masks(existing_mask, new_mask)
+ else:
+ combined_mask = new_mask
+ # Update mask for current subfolder
+ dicom.set_mask(combined_mask)
+
+ # Refresh the display to show the updated mask
+ self.load_image()
+
+ def clear_all_masks(self):
+ self.get_displayed_dicom().set_mask(None)
+ self.load_image()
+
+ def hideEvent(self, event):
+ if self.timer.isActive():
+ self.start_stop_playback()
+ super().hideEvent(event)
+
+ def showEvent(self, event):
+ """Called when the widget is shown"""
+ # Only update if we have a mismatch between displayed and actual DICOMs
+ current_dicoms = self.dicom_manager.get_dicoms()
+ if len(current_dicoms) != self.current_dicom_count:
+ self.on_dicoms_imported()
+ super().showEvent(event)
+
+ def set_subfolder_widget(self):
+ """Update the existing subfolder widget with the current DICOM's subfolders"""
+ if hasattr(self, 'subfolder_widget') and self.subfolder_widget:
+ # Clear existing widgets
+ self.subfolder_widget.clear_widgets()
+ # Always show the subfolder widget, but only add widgets if subfolders exist
+ if self.selected_dicom and self.selected_dicom.get_subfolders():
+ self.subfolder_widget.create_and_add_widgets(self.selected_dicom.get_subfolders())
+
+ def get_displayed_dicom(self):
+ """Return the currently displayed DICOM object"""
+ if self.selected_dicom is None:
+ return None
+
+ if self.selected_dicom.get_subfolders():
+ selected_widget = self.subfolder_widget.get_selected_widget()
+ if selected_widget:
+ return selected_widget.dicom
+ return self.selected_dicom
\ No newline at end of file
diff --git a/app/ui/windows/export_window.py b/app/ui/windows/export_window.py
new file mode 100644
index 0000000..25dd71a
--- /dev/null
+++ b/app/ui/windows/export_window.py
@@ -0,0 +1,370 @@
+from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton, QComboBox, QDialog, QTreeWidget, QTreeWidgetItem, QHeaderView, QGridLayout, QFileDialog
+from PyQt6.QtCore import Qt, QEvent, QThread, pyqtSignal
+from app.core.main_manager import MainManager, NotificationType
+from app.core.export_manager import ExportCategory
+from app.ui.widgets.loading_bar import LoadingBar
+
+class ExportWorker(QThread):
+ progress_changed = pyqtSignal(int)
+ phase_changed = pyqtSignal(str)
+ export_finished = pyqtSignal(bool)
+
+ def __init__(self, export_manager, export_type, export_format):
+ super().__init__()
+ self.export_manager = export_manager
+ self.export_type = export_type
+ self.export_format = export_format
+
+ def run(self):
+ try:
+ # Connect export manager signals
+ self.export_manager.progress_changed.connect(self.progress_changed.emit)
+ self.export_manager.phase_changed.connect(self.phase_changed.emit)
+ self.export_manager.export_finished.connect(self.export_finished.emit)
+
+ # Determine which export method to call
+ success = False
+ if self.export_type == 0: # Images
+ if self.export_format == 0: # PDF
+ success = self.export_manager.export_images_as_pdf()
+ elif self.export_format == 1: # PNG
+ success = self.export_manager.export_images_as_png()
+ elif self.export_format == 2: # DICOMDIR
+ success = self.export_manager.export_images_as_dicomdir()
+ elif self.export_format == 3: # DCM
+ success = self.export_manager.export_images_as_dcm()
+ elif self.export_type == 1: # Metadata
+ if self.export_format == 0: # JSON
+ success = self.export_manager.export_metadata_as_json()
+ elif self.export_format == 1: # XLS
+ success = self.export_manager.export_metadata_as_xls()
+
+ # Emit final result if not already emitted by export manager
+ if not success:
+ self.export_finished.emit(False)
+
+ except Exception as e:
+ self.export_finished.emit(False)
+ finally:
+ # Disconnect signals
+ try:
+ self.export_manager.progress_changed.disconnect()
+ self.export_manager.phase_changed.disconnect()
+ self.export_manager.export_finished.disconnect()
+ except:
+ pass
+
+class ExportWindow(QWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.main_manager = MainManager.get_instance()
+ self.language_manager = self.main_manager.get_language_manager()
+ self.export_manager = self.main_manager.get_export_manager()
+ self.observer_manager = self.main_manager.get_observer_manager()
+ self.alert_manager = self.main_manager.get_alert_manager()
+ self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_texts)
+
+ self.export_type_combo = None
+ self.export_format_combo = None
+ self.export_worker = None
+ self.progress_dialog = None
+
+ self.setup_ui()
+
+ def setup_ui(self):
+ layout = QVBoxLayout(self)
+ layout.setAlignment(Qt.AlignmentFlag.AlignTop)
+ layout.setSpacing(20)
+ layout.setContentsMargins(20, 20, 20, 20)
+
+ self.export_label = QLabel(self.language_manager.get_text("export_label"), self)
+ layout.addWidget(self.export_label)
+
+ # Add combo box for export type
+ self.export_type_combo = QComboBox(self)
+ self.export_type_combo.addItems([self.language_manager.get_text("export_type_images"), self.language_manager.get_text("export_type_metadata")])
+ self.export_type_combo.currentIndexChanged.connect(self.update_export_formats)
+ layout.addWidget(self.export_type_combo)
+
+ # Add combo box for export format
+ self.export_format_combo = QComboBox(self)
+ self.update_export_formats() # Initialize formats based on default type
+ layout.addWidget(self.export_format_combo)
+
+ self.export_button = QPushButton(self.language_manager.get_text("export_button"), self)
+ self.export_button.clicked.connect(self.show_dicom_selection_popup)
+ layout.addWidget(self.export_button)
+
+ def update_texts(self):
+ self.export_label.setText(self.language_manager.get_text("export_label"))
+ self.export_button.setText(self.language_manager.get_text("export_button"))
+ self.export_type_combo.setItemText(0, self.language_manager.get_text("export_type_images"))
+ self.export_type_combo.setItemText(1, self.language_manager.get_text("export_type_metadata"))
+
+ def update_export_formats(self):
+ self.export_format_combo.clear()
+ if self.export_type_combo.currentIndex() < 1: # Images
+ self.export_format_combo.addItems(["PDF", "PNG", "DICOMDIR", "DCM"])
+ else: # Metadata
+ self.export_format_combo.addItems(["JSON", "XLS"])
+
+ def show_dicom_selection_popup(self):
+ dicom_list = self.export_manager.get_export_dicom_list()
+ if not dicom_list:
+ return
+
+ popup = QDialog(self)
+ popup.setWindowTitle(self.language_manager.get_text("select_dicom_to_export"))
+ popup.setLayout(QVBoxLayout())
+ popup.resize(400, 600) # Set initial size with increased height
+
+ tree = QTreeWidget(popup)
+ tree.setHeaderHidden(False)
+ tree.setColumnCount(2)
+ tree.setHeaderLabels([self.language_manager.get_text("dicom_name"),
+ self.language_manager.get_text("group")])
+ # disable automatic stretch and fit col 1 to its contents
+ tree.header().setStretchLastSection(False)
+ tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
+ popup.layout().addWidget(tree)
+
+ for dicom, sub_dicoms in dicom_list:
+ parent_item = QTreeWidgetItem([dicom.get_name()])
+ parent_item.setCheckState(0, Qt.CheckState.Unchecked) # Add checkbox to parent item
+ parent_item.setData(0, Qt.ItemDataRole.UserRole, dicom) # Link parent item to its original object
+ tree.addTopLevelItem(parent_item)
+
+ # Handle case where there are no subfolders (direct files)
+ if not sub_dicoms and dicom.get_files():
+ # Create a single child for the DICOM itself
+ child_item = QTreeWidgetItem([dicom.get_name()])
+ child_item.setCheckState(0, Qt.CheckState.Unchecked)
+ child_item.setData(0, Qt.ItemDataRole.UserRole, dicom)
+ parent_item.addChild(child_item)
+
+ # Create color selection button
+ color_button = QPushButton(parent=popup)
+ color_button.setFixedSize(20, 20)
+ colors = ExportCategory.list()
+
+ # Set initial color
+ color_button.setStyleSheet(f"""
+ QPushButton {{
+ background-color: rgb{dicom.get_category()};
+ border: none;
+ padding: 0px;
+ margin: 0px;
+ }}
+ """)
+ color_button.clicked.connect(lambda _, btn=color_button, item=child_item, cols=colors:
+ self.show_color_dialog(item, btn, cols))
+ tree.setItemWidget(child_item, 1, color_button)
+ else:
+ # Handle subfolders as before
+ for sub_dicom in sub_dicoms:
+ child_item = QTreeWidgetItem([sub_dicom.get_name()])
+ child_item.setCheckState(0, Qt.CheckState.Unchecked) # Add checkbox to child item
+ child_item.setData(0, Qt.ItemDataRole.UserRole, sub_dicom) # Link child item to its original object
+ parent_item.addChild(child_item)
+
+ # Create color selection button instead of combo box
+ color_button = QPushButton(parent=popup)
+ color_button.setFixedSize(20, 20)
+ colors = ExportCategory.list()
+
+ # Set initial color
+ color_button.setStyleSheet(f"""
+ QPushButton {{
+ background-color: rgb{sub_dicom.get_category()};
+ border: none;
+ padding: 0px;
+ margin: 0px;
+ }}
+ """)
+ # Connect to custom color dialog
+ color_button.clicked.connect(lambda _, btn=color_button, item=child_item, cols=colors:
+ self.show_color_dialog(item, btn, cols))
+ tree.setItemWidget(child_item, 1, color_button)
+
+ # single connection to handle both directions
+ tree.itemChanged.connect(self.on_item_changed)
+ tree.resizeColumnToContents(0) # Resize first column to fit content
+ tree.setColumnWidth(0, max(200, tree.columnWidth(0))) # Ensure minimum 200px for first column
+ tree.setColumnWidth(1, 20) # Exact width for button
+ # Adjust dialog size to fit content
+ popup.adjustSize()
+ popup.setMinimumHeight(600) # Ensure minimum height
+
+ confirm_button = QPushButton(self.language_manager.get_text("confirm"), popup)
+ confirm_button.clicked.connect(lambda: (self.export_data(), popup.accept()))
+ popup.layout().addWidget(confirm_button)
+
+ popup.exec()
+
+ def toggle_child_items(self, item):
+ if item.childCount() > 0: # Check if the item has children
+ for i in range(item.childCount()):
+ child = item.child(i)
+ child.setCheckState(0, item.checkState(0)) # Set child state to match parent state
+
+ def show_color_dialog(self, item, button, colors):
+ dialog = QDialog(self)
+ dialog.setWindowFlags(Qt.WindowType.Popup)
+ dialog.setFixedSize(200, 40) # Increased height for labels
+
+ # Position dialog to the left of the button
+ button_pos = button.mapToGlobal(button.rect().topLeft())
+ dialog.move(button_pos.x() - 200, button_pos.y())
+
+ layout = QGridLayout(dialog)
+ layout.setSpacing(0)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ for i, color in enumerate(colors):
+ color_btn = QPushButton()
+ color_btn.setFixedSize(20, 20)
+ color_btn.setStyleSheet(f"""
+ QPushButton {{
+ background-color: rgb{color};
+ border: none;
+ padding: 0px;
+ margin: 0px;
+ }}
+ """)
+ # Add tooltip with category name
+ color_btn.setToolTip(ExportCategory.get_name(color))
+ color_btn.clicked.connect(lambda _, c=color: self.select_color(item, button, c, dialog))
+ layout.addWidget(color_btn, 0, i)
+
+ # Add label below button
+ label = QLabel(ExportCategory.get_name(color)[:3]) # First 3 letters
+ label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ label.setStyleSheet("font-size: 8px; color: black;")
+ layout.addWidget(label, 1, i)
+
+ dialog.exec()
+
+ def select_color(self, item, button, color, dialog):
+ button.setStyleSheet(f"""
+ QPushButton {{
+ background-color: rgb{color};
+ border: none;
+ padding: 0px;
+ margin: 0px;
+ }}
+ """)
+ item.setData(1, Qt.ItemDataRole.UserRole, color)
+ item.data(0, Qt.ItemDataRole.UserRole).set_category(color)
+ dialog.accept()
+
+ def export_data(self):
+ """Start the export process with progress tracking"""
+ # Check if there's data to export
+ data = self.export_manager.fetch_export_data()
+ if not data:
+ self.alert_manager.show_error("no_data_to_export")
+ return
+
+ # Choose export destination
+ destination = QFileDialog.getExistingDirectory(
+ self,
+ self.language_manager.get_text("choose_export_destination"),
+ ""
+ )
+
+ if not destination:
+ return # User cancelled
+
+ # Set export destination
+ self.export_manager.set_export_destination(destination)
+
+ # Create and show progress dialog
+ self.progress_dialog = QDialog(self)
+ self.progress_dialog.setWindowTitle(self.language_manager.get_text("export_label"))
+ self.progress_dialog.setModal(True)
+ self.progress_dialog.setFixedSize(400, 120)
+
+ layout = QVBoxLayout(self.progress_dialog)
+ self.progress_bar = LoadingBar(self.language_manager.get_text("preparing_export"), self.progress_dialog)
+ layout.addWidget(self.progress_bar)
+
+ # Cancel button
+ cancel_button = QPushButton(self.language_manager.get_text("cancel"), self.progress_dialog)
+ cancel_button.clicked.connect(self.cancel_export)
+ layout.addWidget(cancel_button)
+
+ self.progress_dialog.show()
+
+ # Start export worker
+ export_type = self.export_type_combo.currentIndex()
+ export_format = self.export_format_combo.currentIndex()
+
+ self.export_worker = ExportWorker(self.export_manager, export_type, export_format)
+ self.export_worker.progress_changed.connect(self.progress_bar.set_progress)
+ self.export_worker.phase_changed.connect(self.update_export_phase)
+ self.export_worker.export_finished.connect(self.on_export_finished)
+ self.export_worker.start()
+
+ def update_export_phase(self, phase):
+ """Update the progress bar label based on current phase"""
+ if hasattr(self, 'progress_bar') and self.progress_bar:
+ self.progress_bar.set_label(self.language_manager.get_text(phase))
+
+ def cancel_export(self):
+ """Cancel the export process"""
+ if self.export_worker and self.export_worker.isRunning():
+ self.export_worker.terminate()
+ self.export_worker.wait()
+
+ if self.progress_dialog:
+ self.progress_dialog.close()
+ self.progress_dialog = None
+
+ def on_export_finished(self, success):
+ """Handle export completion"""
+ if self.progress_dialog:
+ self.progress_dialog.close()
+ self.progress_dialog = None
+
+ if self.export_worker:
+ self.export_worker.deleteLater()
+ self.export_worker = None
+
+ if success:
+ self.alert_manager.show_success("export_success")
+ else:
+ self.alert_manager.show_error("export_error")
+
+ def eventFilter(self, source, event):
+ if isinstance(source, QComboBox) and event.type() == QEvent.Type.Wheel:
+ return True
+ return super().eventFilter(source, event)
+
+ def on_item_changed(self, item, column):
+ if column != 0:
+ return
+ tree = item.treeWidget()
+ tree.blockSignals(True)
+
+ # Set export status based on check state
+ is_checked = item.checkState(0) == Qt.CheckState.Checked
+ item.data(0, Qt.ItemDataRole.UserRole).set_export(is_checked)
+
+ # if parent toggled → toggle all children
+ if item.childCount() > 0:
+ state = item.checkState(0)
+ for i in range(item.childCount()):
+ child = item.child(i)
+ child.setCheckState(0, state)
+ # Set export status for children too
+ child.data(0, Qt.ItemDataRole.UserRole).set_export(state == Qt.CheckState.Checked)
+ # if child toggled → update parent
+ parent = item.parent()
+ if parent:
+ all_checked = all(parent.child(i).checkState(0) == Qt.CheckState.Checked
+ for i in range(parent.childCount()))
+ parent.setCheckState(0,
+ Qt.CheckState.Checked if all_checked else Qt.CheckState.Unchecked)
+ # Set export status for parent
+ parent.data(0, Qt.ItemDataRole.UserRole).set_export(all_checked)
+ tree.blockSignals(False)
\ No newline at end of file
diff --git a/app/ui/windows/import_window.py b/app/ui/windows/import_window.py
new file mode 100644
index 0000000..6a0b63f
--- /dev/null
+++ b/app/ui/windows/import_window.py
@@ -0,0 +1,330 @@
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
+ QFileDialog
+)
+from PyQt6.QtCore import Qt, QThread, pyqtSignal
+from os import walk, path as Path
+import os
+from app.core.main_manager import MainManager, NotificationType
+from app.utils.paths import get_current_dir
+from app.ui.widgets.drag_drop_frame import DragDropFrame
+from app.ui.widgets.loading_bar import LoadingBar
+
+
+
+class ImportWorker(QThread):
+ progress_changed = pyqtSignal(int)
+ phase_changed = pyqtSignal(str)
+ import_finished = pyqtSignal()
+ error_occurred = pyqtSignal(str)
+ dicoms_imported = pyqtSignal(int) # Signal for number of imported DICOMs
+
+ def __init__(self, dicom_manager, dicom_paths):
+ super().__init__()
+ self.dicom_manager = dicom_manager
+ self.dicom_paths = dicom_paths
+ self.current_progress = 0
+
+ def run(self):
+ total_paths = len(self.dicom_paths)
+
+ # Phase 1: Path processing (0-20%)
+ self.phase_changed.emit("processing_paths")
+ for i, dicom_path in enumerate(self.dicom_paths):
+ self.current_progress = int((i / total_paths) * 20)
+ self.progress_changed.emit(self.current_progress)
+
+ # Phase 2: DICOM object creation (20-70%) - Now much faster
+ self.phase_changed.emit("creating_dicoms")
+
+ self.dicom_manager.error_occurred.connect(self.error_occurred.emit)
+
+ successfully_added = 0
+ for i, dicom_path in enumerate(self.dicom_paths):
+ try:
+ dicom = self.dicom_manager.process_dicomdir(dicom_path)
+
+ if dicom is not None and not dicom.is_empty():
+ self.dicom_manager.dicoms.append(dicom)
+ successfully_added += 1
+
+ except Exception as e:
+ self.error_occurred.emit("load_error")
+
+ self.current_progress = 20 + int((i / total_paths) * 50)
+ self.progress_changed.emit(self.current_progress)
+
+ try:
+ self.dicom_manager.error_occurred.disconnect()
+ except:
+ pass
+
+ self.dicoms_imported.emit(successfully_added)
+
+ # Phase 3: Background validation (70-90%)
+ self.phase_changed.emit("validating_files")
+ self._validate_files_in_background()
+
+ # Phase 4: UI preparation (90-100%)
+ self.phase_changed.emit("preparing_display")
+ for i in range(10):
+ self.current_progress = 90 + i + 1
+ self.progress_changed.emit(self.current_progress)
+ self.msleep(10)
+
+ self.phase_changed.emit("import_complete")
+ self.import_finished.emit()
+
+ def _validate_files_in_background(self):
+ """Validate DICOM files in background without blocking UI"""
+ all_files = []
+
+ # Collect all DicomFile objects
+ for dicom in self.dicom_manager.dicoms:
+ if dicom is None:
+ continue
+ all_files.extend(dicom.get_files())
+ for subfolder in dicom.get_subfolders():
+ all_files.extend(subfolder.get_files())
+
+ total_files = len(all_files)
+ invalid_count = 0
+
+ for i, dicom_file in enumerate(all_files):
+ # Trigger lazy loading/validation
+ dicom_file._ensure_loaded()
+ if not dicom_file.is_valid:
+ invalid_count += 1
+
+ # Update progress
+ if i % 10 == 0:
+ progress = 70 + int((i / total_files) * 20)
+ self.progress_changed.emit(progress)
+
+ # Log validation results if needed
+ if invalid_count > 0:
+ print(f"Warning: {invalid_count} invalid DICOM files found during validation")
+
+class ImportWindow(QWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.main_manager = MainManager.get_instance()
+ self.language_manager = self.main_manager.get_language_manager()
+ self.dicom_manager = self.main_manager.get_dicom_manager()
+ self.alert_manager = self.main_manager.get_alert_manager()
+ self.observer_manager = self.main_manager.get_observer_manager()
+ self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_texts)
+
+ self.dicom_paths = None
+ self.setup_ui()
+ self.update_texts()
+
+ self.import_worker = None
+
+ def setup_ui(self):
+ layout = QVBoxLayout(self)
+ layout.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter)
+ self.drag_area = DragDropFrame(self)
+ self.drag_area.setMinimumHeight(100)
+ self.drag_area.setMaximumHeight(200)
+ layout.addWidget(self.drag_area)
+
+
+ self.file_btn = QPushButton()
+ self.file_btn.clicked.connect(self.select_file)
+ self.folder_btn = QPushButton()
+ self.folder_btn.clicked.connect(self.select_folder)
+ file_layout = QHBoxLayout()
+ file_layout.addWidget(self.file_btn)
+ file_layout.addWidget(self.folder_btn)
+ layout.addLayout(file_layout)
+
+ self.input_entry = QLabel()
+ self.input_entry.setText(self.language_manager.get_text("path_placeholder"))
+ layout.addWidget(self.input_entry)
+
+ layout.addStretch()
+
+ self.progress_bar = LoadingBar()
+ layout.addWidget(self.progress_bar)
+
+ layout.addStretch()
+
+ self.import_btn = QPushButton()
+ self.import_btn.clicked.connect(self.start_import)
+ layout.addWidget(self.import_btn, alignment=Qt.AlignmentFlag.AlignCenter)
+
+ def update_texts(self):
+ self.drag_area.setDragText(self.language_manager.get_text("drag_drop"))
+ if ":" not in self.input_entry.text():
+ self.input_entry.setText(self.language_manager.get_text("path_placeholder"))
+ self.file_btn.setText(self.language_manager.get_text("select_image"))
+ self.folder_btn.setText(self.language_manager.get_text("select_folder"))
+ self.import_btn.setText(self.language_manager.get_text("import"))
+
+ def start_import(self):
+ self.find_dicoms_from_files(self.input_entry.text())
+ self.import_dicoms()
+
+ def import_dicoms(self):
+ if not self.dicom_paths:
+ self.alert_manager.show_error("no_dicom_found")
+ return
+
+ # Build a comprehensive set of all existing file paths (normalized)
+ existing_file_paths = set()
+ existing_dicom_paths = set()
+
+ for dicom in self.dicom_manager.get_dicoms():
+ if dicom is None:
+ continue
+
+ # Add the DICOM's main path (normalized)
+ if dicom.get_path():
+ existing_dicom_paths.add(os.path.normpath(dicom.get_path()))
+
+ # Add all individual file paths within this DICOM (normalized)
+ files = dicom.get_files()
+ for file in files:
+ if hasattr(file, 'file_path') and file.file_path:
+ existing_file_paths.add(os.path.normpath(file.file_path))
+
+ # Add files from subfolders
+ subfolders = dicom.get_subfolders()
+ for subfolder in subfolders:
+ subfolder_files = subfolder.get_files()
+ for file in subfolder_files:
+ if hasattr(file, 'file_path') and file.file_path:
+ existing_file_paths.add(os.path.normpath(file.file_path))
+
+ # Filter out duplicates
+ dicom_paths_filtered = []
+ for path in self.dicom_paths:
+ normalized_path = os.path.normpath(path)
+ should_skip = False
+
+ # Check if this exact path is already imported
+ if normalized_path in existing_dicom_paths:
+ continue
+
+ # For single DCM files, check if already part of existing DICOM
+ if path.endswith('.dcm'):
+ if normalized_path in existing_file_paths:
+ continue
+ # Also check if parent directory is already imported as DICOM
+ parent_dir = os.path.normpath(os.path.dirname(path))
+ if parent_dir in existing_dicom_paths:
+ continue
+
+ # For directories, check more thoroughly
+ elif Path.isdir(path):
+ # Check if this directory path is already imported
+ if normalized_path in existing_dicom_paths:
+ continue
+
+ # Check if any parent directory is already imported
+ current_path = normalized_path
+ while current_path != os.path.dirname(current_path): # Until we reach root
+ if current_path in existing_dicom_paths:
+ should_skip = True
+ break
+ current_path = os.path.dirname(current_path)
+
+ if should_skip:
+ continue
+
+ # Check if any child directories are already imported
+ for existing_path in existing_dicom_paths:
+ if existing_path.startswith(normalized_path + os.sep):
+ should_skip = True
+ break
+
+ if should_skip:
+ continue
+
+ # If we get here, the path is not a duplicate
+ dicom_paths_filtered.append(path)
+
+ if not dicom_paths_filtered:
+ self.alert_manager.show_info("Tous les DICOMs sont déjà importés")
+ return
+
+ # Show loading bar and start import
+ self.progress_bar.set_label(self.language_manager.get_text("importing"))
+ self.progress_bar.set_progress(0)
+
+ # Disable buttons during import
+ self.file_btn.setEnabled(False)
+ self.folder_btn.setEnabled(False)
+ self.import_btn.setEnabled(False)
+
+ # Start import worker
+ self.import_worker = ImportWorker(self.dicom_manager, dicom_paths_filtered)
+ self.import_worker.progress_changed.connect(self.progress_bar.set_progress)
+ self.import_worker.phase_changed.connect(self.update_phase_label)
+ self.import_worker.error_occurred.connect(self.handle_import_error)
+ self.import_worker.import_finished.connect(self.on_import_finished)
+ self.import_worker.start()
+
+ def handle_import_error(self, error_key):
+ """Handle errors during import"""
+ self.alert_manager.show_error(error_key)
+
+ def update_phase_label(self, phase):
+ """Update the loading bar label based on current phase"""
+ self.progress_bar.set_label(self.language_manager.get_text(phase))
+
+ def on_import_finished(self):
+
+ # Re-enable buttons
+ self.import_btn.setEnabled(True)
+ self.file_btn.setEnabled(True)
+ self.folder_btn.setEnabled(True)
+
+ # Reset paths
+ self.dicom_paths = None
+ self.input_entry.setText(self.language_manager.get_text("path_placeholder"))
+
+ # Clean up worker
+ if self.import_worker:
+ self.import_worker.deleteLater()
+ self.import_worker = None
+
+ # Notify observers
+ self.observer_manager.notify(NotificationType.DICOM)
+
+ def select_file(self):
+ self.input_entry.setText(QFileDialog.getOpenFileName(self, self.language_manager.get_text("select_image"), get_current_dir(), "DICOMDIR Files (DICOMDIR) ;; DICOM Files (*.dcm)")[0])
+
+ def select_folder(self):
+ self.input_entry.setText(QFileDialog.getExistingDirectory(self, self.language_manager.get_text("select_folder"), get_current_dir()))
+
+ def find_dicoms_from_files(self, path):
+ if isinstance(path, tuple):
+ path = path[0] # Extract the file path from the tuple if needed
+ if not path:
+ return
+ if Path.isdir(path):
+ if path:
+ fichiers_dicomdir = []
+ for racine, _, fichiers in walk(path):
+ for fichier in fichiers:
+ if fichier == 'DICOMDIR':
+ chemin_complet = Path.join(racine, fichier)
+ fichiers_dicomdir.append(chemin_complet)
+ if fichier.endswith('.dcm'):
+ chemin_complet = Path.join(racine, fichier)
+ parent_folder = Path.dirname(chemin_complet)
+ if parent_folder not in fichiers_dicomdir:
+ fichiers_dicomdir.append(parent_folder)
+ if fichiers_dicomdir:
+ self.dicom_paths = fichiers_dicomdir
+ self.input_entry.setText(path)
+ else:
+ if path.endswith('DICOMDIR'):
+ self.dicom_paths = [path]
+ self.input_entry.setText(path)
+ elif path.endswith('.dcm'):
+ # For single DCM file, use the file path directly, not the parent folder
+ self.dicom_paths = [path]
+ self.input_entry.setText(path)
\ No newline at end of file
diff --git a/app/ui/windows/settings_window.py b/app/ui/windows/settings_window.py
index 50b8d13..f8bd67d 100644
--- a/app/ui/windows/settings_window.py
+++ b/app/ui/windows/settings_window.py
@@ -1,4 +1,4 @@
-from PyQt6.QtWidgets import QWidget, QVBoxLayout, QComboBox, QLabel, QHBoxLayout, QSizePolicy
+from PyQt6.QtWidgets import QWidget, QVBoxLayout, QComboBox, QLabel, QHBoxLayout
from PyQt6.QtCore import Qt
from app.core.main_manager import MainManager, NotificationType
from typing import Optional
@@ -16,12 +16,12 @@ class SettingsWindow(QWidget):
# Type hints for UI elements
self.language_layout: QHBoxLayout
- self.languageLabel: QLabel
- self.languageCombo: QComboBox
+ self.language_label: QLabel
+ self.language_combo: QComboBox
self.theme_layout: QHBoxLayout
- self.themeLabel: QLabel
- self.themeCombo: QComboBox
-
+ self.theme_label: QLabel
+ self.theme_combo: QComboBox
+
self.setup_ui()
def setup_ui(self) -> None:
@@ -34,12 +34,12 @@ class SettingsWindow(QWidget):
self.language_layout = QHBoxLayout()
# Paramètres de langue
- self.languageLabel = QLabel(self.language_manager.get_text("language"),self)
- self.languageLabel.setFixedWidth(120) # Largeur fixe pour l'alignement
- self.language_layout.addWidget(self.languageLabel)
+ self.language_label = QLabel(self.language_manager.get_text("language"),self)
+ self.language_label.setFixedWidth(120) # Largeur fixe pour l'alignement
+ self.language_layout.addWidget(self.language_label)
- self.languageCombo = self.createLanguageSelector()
- self.language_layout.addWidget(self.languageCombo)
+ self.language_combo = self.createLanguageSelector()
+ self.language_layout.addWidget(self.language_combo)
layout.addLayout(self.language_layout)
@@ -48,12 +48,12 @@ class SettingsWindow(QWidget):
# Paramètres de thème
self.theme_layout = QHBoxLayout()
- self.themeLabel = QLabel(self.language_manager.get_text("theme"), self)
- self.themeLabel.setFixedWidth(120) # Même largeur fixe pour l'alignement
- self.theme_layout.addWidget(self.themeLabel)
+ self.theme_label = QLabel(self.language_manager.get_text("theme"), self)
+ self.theme_label.setFixedWidth(120) # Même largeur fixe pour l'alignement
+ self.theme_layout.addWidget(self.theme_label)
- self.themeCombo = self.createThemeSelector()
- self.theme_layout.addWidget(self.themeCombo)
+ self.theme_combo = self.createThemeSelector()
+ self.theme_layout.addWidget(self.theme_combo)
layout.addLayout(self.theme_layout)
@@ -62,12 +62,12 @@ class SettingsWindow(QWidget):
def createLanguageSelector(self) -> QComboBox:
combo: QComboBox = QComboBox()
# Ajouter toutes les langues disponibles
- for langCode, langData in self.language_manager.translations.items():
- combo.addItem(langData["lang_name"], langCode)
-
+ for lang_code, lang_data in self.language_manager.translations.items():
+ combo.addItem(lang_data["lang_name"], lang_code)
+
# Sélectionner la langue actuelle
- currentIndex = combo.findData(self.settings_manager.get_language())
- combo.setCurrentIndex(currentIndex)
+ current_index = combo.findData(self.settings_manager.get_language())
+ combo.setCurrentIndex(current_index)
combo.currentIndexChanged.connect(self.change_language)
return combo
@@ -79,23 +79,23 @@ class SettingsWindow(QWidget):
combo.addItem(self.language_manager.get_text(theme.name+"_theme"), theme.name)
# Sélectionner le thème actuel
- currentIndex = combo.findData(self.settings_manager.get_theme())
- combo.setCurrentIndex(currentIndex)
+ current_index = combo.findData(self.settings_manager.get_theme())
+ combo.setCurrentIndex(current_index)
combo.currentIndexChanged.connect(self.change_theme)
return combo
def change_language(self, index: int) -> None:
- self.settings_manager.set_language(self.languageCombo.itemData(index))
+ self.settings_manager.set_language(self.language_combo.itemData(index))
def change_theme(self, index: int) -> None:
- theme: str = self.themeCombo.itemData(index)
+ theme: str = self.theme_combo.itemData(index)
self.settings_manager.set_theme(theme)
def update_language(self) -> None:
- self.languageLabel.setText(self.language_manager.get_text("language"))
- self.themeLabel.setText(self.language_manager.get_text("theme"))
-
+ self.language_label.setText(self.language_manager.get_text("language"))
+ self.theme_label.setText(self.language_manager.get_text("theme"))
+
# Mettre à jour les textes dans la combo de thème
- for i in range(self.themeCombo.count()):
- self.themeCombo.setItemText(i, self.language_manager.get_text(self.themeCombo.itemData(i)+ "_theme"))
\ No newline at end of file
+ for i in range(self.theme_combo.count()):
+ self.theme_combo.setItemText(i, self.language_manager.get_text(self.theme_combo.itemData(i)+ "_theme"))
\ No newline at end of file
diff --git a/app/ui/windows/splash_screen.py b/app/ui/windows/splash_screen.py
index 3bcc02a..459752e 100644
--- a/app/ui/windows/splash_screen.py
+++ b/app/ui/windows/splash_screen.py
@@ -122,8 +122,8 @@ class SplashScreen(QWidget):
if hasattr(self, 'spinner'):
self.spinner.stop()
self.timer.stop()
- self.hide()
self.finished.emit()
+ self.close()
def show_splash(self):
"""Affiche le splash screen"""
diff --git a/app/utils/image_editor.py b/app/utils/image_editor.py
new file mode 100644
index 0000000..223bd04
--- /dev/null
+++ b/app/utils/image_editor.py
@@ -0,0 +1,30 @@
+from numpy import uint8, logical_or
+
+def combine_masks(mask1, mask2):
+ """Combine two masks using logical OR operation"""
+ if mask1 is None:
+ return mask2
+ if mask2 is None:
+ return mask1
+
+ # S'assurer que les masks ont la même forme
+ if mask1.shape != mask2.shape:
+ return mask2 # En cas de problème, utiliser le nouveau mask
+
+ return logical_or(mask1 > 0, mask2 > 0).astype(uint8) * 255
+
+def apply_mask(image_array, mask):
+ if mask is None or image_array is None:
+ return image_array
+
+ # Vérifier que les dimensions correspondent
+ if len(image_array.shape) != 3 or len(mask.shape) != 2:
+ return image_array
+
+ if image_array.shape[:2] != mask.shape:
+ return image_array
+
+ result = image_array.copy()
+ result[mask > 0] = [0, 0, 0] # Pixel noir
+
+ return result
diff --git a/app/utils/paths.py b/app/utils/paths.py
index 991026b..f0d649a 100644
--- a/app/utils/paths.py
+++ b/app/utils/paths.py
@@ -17,6 +17,8 @@ def resource_path(relative_path: str) -> str:
return path.join(base_path, relative_path)
+def get_current_dir() -> str:
+ return str(Path.cwd())
def get_data_dir() -> str:
return resource_path("data")
diff --git a/config.json b/config.json
index 2735aaa..f5f3370 100644
--- a/config.json
+++ b/config.json
@@ -1,10 +1,10 @@
{
- "app_name": "Application",
+ "app_name": "HoDA Radio",
"app_os": "Windows",
"app_version": "1.0.0",
"architecture": "x64",
"icon_path": "data/assets/icon.ico",
"splash_image": "splash",
"main_script": "main.py",
- "git_repo": "https://gitea.louismazin.ovh/LouisMazin/PythonApplicationTemplate"
+ "git_repo": "https://gitea.louismazin.ovh/LouisMazin/HoDA_Radio"
}
\ No newline at end of file
diff --git a/data/assets/back.svg b/data/assets/back.svg
new file mode 100644
index 0000000..fc53d81
--- /dev/null
+++ b/data/assets/back.svg
@@ -0,0 +1,6 @@
+
Image anonymization application.
\nVersion {v}
\nDeveloped in Python.
", + "export_label": "Export Data", + "export_button": "Export", + "export_type_images": "Export Images", + "export_type_metadata": "Export Metadata", + "no_dicom_files": "No files loaded", + "eraser": "Eraser", + "quit_eraser": "Quit Eraser", + "rebuild": "Rebuild", + "dicom_name": "Radio Name", + "select_dicom_to_export": "Select radios to export", + "confirm": "Confirm", + "cancel": "Cancel", + "group": "Group", + "importing": "Importing...", + "import_complete": "Import complete", + "processing_paths": "Searching for DICOMs...", + "creating_dicoms": "Creating DICOM objects...", + "preparing_display": "Preparing display...", + "validating_files": "Validating files...", + "no_dicom_found": "No DICOM files found in the selected path.", + "no_images_found": "No images found.", + "all_dicoms_already_imported": "Founded DICOM files are already imported.", + "clear_mask": "Clear mask", + "load_error": "Error loading DICOM files. If this problem persists, feel free to contact me via the dedicated tab.", + "preparing_export": "Preparing export...", + "exporting_pdf": "Exporting to PDF...", + "exporting_png": "Exporting to PNG...", + "exporting_json": "Exporting JSON metadata...", + "exporting_xls": "Exporting XLS metadata...", + "exporting_dicomdir": "Exporting to DICOMDIR...", + "exporting_dcm": "Exporting to DCM...", + "export_complete": "Export complete", + "export_success": "Export successful!", + "export_error": "Error during export.", + "no_data_to_export": "No data selected for export.", + "error_loading_image": "Error loading image : {x}.", + "export_metadata_error": "Error exporting metadata. Some files may have been exported.", + "export_partial_success": "Export partially successful. Some files could not be exported.", + "choose_export_destination": "Choose export destination folder" } \ No newline at end of file diff --git a/data/lang/es.json b/data/lang/es.json new file mode 100644 index 0000000..557bc1f --- /dev/null +++ b/data/lang/es.json @@ -0,0 +1,77 @@ +{ + "lang_name": "Español", + "language": "Idioma :", + "theme": "Tema :", + "dark_theme": "Tema Oscuro", + "light_theme": "Tema Claro", + "yes": "Sí", + "no": "No", + "confirmation": "Confirmación", + "information": "Información", + "close": "Cerrar", + "suggestion_text": "¿Tienes una pregunta o una idea para mejorar esta aplicación? ¡Envíame un mensaje!", + "suggestion_placeholder": "Escribe tu mensaje aquí...", + "send_suggestion": "Enviar", + "sending": "Enviando...", + "success": "Éxito", + "error": "Error", + "suggestion_sent_success": "¡Tu mensaje ha sido enviado con éxito!", + "suggestion_send_error": "Error al enviar el mensaje. Inténtalo de nuevo más tarde.", + "email_credentials_error": "Credenciales de correo electrónico no configuradas o mal configuradas. Por favor, establece tu correo y contraseña en el archivo .env.", + "suggestion_too_short": "El mensaje debe tener al menos 15 caracteres.", + "update_found": "Nueva versión disponible: {latest_tag} \n¿Quieres instalar la actualización?", + "choose_update_folder": "Elige la carpeta de destino", + "downloading_update": "Descargando actualización...", + "update_downloaded": "Actualización descargada en {local_path}", + "update_download_error": "Error al descargar la actualización", + "update": "Actualizar", + "version": "Versión", + "details": "Detalles", + "update_details": "Detalles de la actualización", + "update_aborted": "Actualización abortada por el usuario", + "select_image": "Seleccionar un Archivo", + "select_folder": "Seleccionar una Carpeta", + "import": "Importar", + "drag_drop": "Arrastra y suelta aquí", + "path_placeholder": "Ruta al archivo o carpeta", + "about": "Aplicación de anonimización de imágenes.
\nVersión {v}
\nDesarrollada en Python.
", + "export_label": "Exportar Datos", + "export_button": "Exportar", + "export_type_images": "Exportar Imágenes", + "export_type_metadata": "Exportar Metadatos", + "no_dicom_files": "No hay archivos cargados", + "eraser": "Borrador", + "quit_eraser": "Salir del Borrador", + "rebuild": "Reconstruir", + "dicom_name": "Nombre Radio", + "select_dicom_to_export": "Selecciona los radios para exportar", + "confirm": "Confirmar", + "cancel": "Cancelar", + "group": "Grupo", + "importing": "Importando...", + "import_complete": "Importación completa", + "processing_paths": "Buscando DICOMs...", + "creating_dicoms": "Creando objetos DICOM...", + "preparing_display": "Preparando visualización...", + "validating_files": "Validando archivos...", + "no_dicom_found": "No se encontraron archivos DICOM en la ruta seleccionada.", + "no_images_found": "No se encontraron imágenes.", + "all_dicoms_already_imported": "Los archivos DICOM encontrados ya están importados.", + "clear_mask": "Limpiar máscara", + "load_error": "Error al cargar archivos DICOM. Si este problema persiste, no dudes en contactarme a través de la pestaña dedicada.", + "preparing_export": "Preparando exportación...", + "exporting_pdf": "Exportando a PDF...", + "exporting_png": "Exportando a PNG...", + "exporting_json": "Exportando metadatos JSON...", + "exporting_xls": "Exportando metadatos XLS...", + "exporting_dicomdir": "Exportando a DICOMDIR...", + "exporting_dcm": "Exportando a DCM...", + "export_complete": "Exportación completa", + "export_success": "¡Exportación exitosa!", + "export_error": "Error durante la exportación.", + "no_data_to_export": "No se seleccionaron datos para exportar.", + "error_loading_image": "Error al cargar la imagen: {x}.", + "export_metadata_error": "Error al exportar metadatos. Algunos archivos pueden haber sido exportados.", + "export_partial_success": "Exportación parcialmente exitosa. Algunos archivos no pudieron ser exportados.", + "choose_export_destination": "Elige la carpeta de destino para la exportación" +} \ No newline at end of file diff --git a/data/lang/fr.json b/data/lang/fr.json index cd713fc..805b6d3 100644 --- a/data/lang/fr.json +++ b/data/lang/fr.json @@ -28,5 +28,50 @@ "version": "Version", "details": "Détails", "update_details": "Détails de la mise à jour", - "update_aborted": "Mise à jour annulée par l'utilisateur" + "update_aborted": "Mise à jour annulée par l'utilisateur", + "select_image": "Sélectionner un Fichier", + "select_folder": "Sélectionner un Dossier", + "import": "Importer", + "drag_drop": "Glisser-Déposer ici", + "path_placeholder": "Chemin vers le fichier ou dossier", + "about": "Application d'anonymisation d'images.
\nVersion {v}
\nDéveloppée en Python.
", + "export_label": "Exporter les Données", + "export_button": "Exporter", + "export_type_images": "Exporter les Images", + "export_type_metadata": "Exporter les Métadonnées", + "no_dicom_files": "Aucun fichier chargé", + "eraser": "Gomme", + "quit_eraser": "Quitter la Gomme", + "rebuild": "Reconstruire", + "dicom_name": "Nom Radio", + "select_dicom_to_export": "Sélectionnez les radios à exporter", + "confirm": "Confirmer", + "cancel": "Annuler", + "group": "Groupe", + "importing": "Importation...", + "import_complete": "Importation terminée", + "processing_paths": "Recherche des DICOMs...", + "creating_dicoms": "Création des objets DICOM...", + "preparing_display": "Préparation de l'affichage...", + "validating_files": "Validation des fichiers...", + "no_dicom_found": "Aucun fichier DICOM trouvé dans le chemin sélectionné.", + "no_images_found": "Aucune image trouvée.", + "all_dicoms_already_imported": "Les fichiers DICOM trouvés ont déjà été importés.", + "clear_mask": "Effacer le masque", + "load_error": "Erreur lors du chargement des fichiers DICOM. Si ce problème persiste, n'hésitez pas à me contacter via l'onglet dédié.", + "preparing_export": "Préparation de l'exportation...", + "exporting_pdf": "Exportation en PDF...", + "exporting_png": "Exportation en PNG...", + "exporting_json": "Exportation des métadonnées JSON...", + "exporting_xls": "Exportation des métadonnées XLS...", + "exporting_dicomdir": "Exportation en DICOMDIR...", + "exporting_dcm": "Exportation en DCM...", + "export_complete": "Exportation terminée", + "export_success": "Exportation réussie !", + "export_error": "Erreur lors de l'exportation.", + "no_data_to_export": "Aucune donnée sélectionnée pour l'exportation.", + "error_loading_image": "Erreur lors du chargement de l'image : {x}.", + "export_metadata_error": "Erreur lors de l'exportation des métadonnées. Certains fichiers ont pu être exportés.", + "export_partial_success": "Exportation partiellement réussie. Certains fichiers n'ont pas pu être exportés.", + "choose_export_destination": "Choisissez le dossier de destination pour l'exportation" } \ No newline at end of file diff --git a/data/themes/dark.json b/data/themes/dark.json index 4ee5cd8..47ac691 100644 --- a/data/themes/dark.json +++ b/data/themes/dark.json @@ -6,7 +6,7 @@ "background_tertiary_color": "#4A4A4A", "border_color": "#3C3C3E", "text_color": "#D1D1D6", - "primary_color": "#0A84FF", + "primary_color": "#4A4A4A", "primary_hover_color": "#007AFF", "icon_selected_color": "#D1D1D6", "icon_unselected_color": "#4A4A4A", diff --git a/data/themes/light.json b/data/themes/light.json index 4fe80b3..aae582a 100644 --- a/data/themes/light.json +++ b/data/themes/light.json @@ -6,7 +6,7 @@ "background_tertiary_color": "#E0E0E0", "border_color": "#1f1f20", "text_color": "#000000", - "primary_color": "#0A84FF", + "primary_color": "#5D5A5A", "primary_hover_color": "#007AFF", "icon_selected_color": "#000000", "icon_unselected_color": "#5D5A5A", diff --git a/main.py b/main.py index 29bf4bd..7e71c1e 100644 --- a/main.py +++ b/main.py @@ -21,26 +21,27 @@ def main() -> int: splash_image_path = paths.get_asset_path(settings_manager.get_config("splash_image")) use_splash = splash_image_path and paths.Path(splash_image_path).exists() - # Créer la fenêtre principale - window: MainWindow = MainWindow() - if use_splash: # Créer et afficher le splash screen splash = SplashScreen(duration=1500) splash.show_splash() - # Connecter le signal finished pour afficher la fenêtre principale et vérifier les mises à jour + # Connecter le signal finished pour créer et afficher la fenêtre principale def show_main_window(): if update_manager.check_for_update(): - return 0 + app.quit() + return + window: MainWindow = MainWindow() window.show() - splash.finished.connect(lambda: show_main_window()) + splash.finished.connect(show_main_window) else: # Pas de splash screen, vérifier les mises à jour puis afficher la fenêtre principale if update_manager.check_for_update(): return 0 + window: MainWindow = MainWindow() window.show() + return app.exec() if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index 390d828..03f9b39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,10 @@ PyQt6 pyinstaller python-dotenv -requests \ No newline at end of file +requests +reportlab +pydicom +numpy +opencv-python +pandas +openpyxl \ No newline at end of file