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 @@ + + + + + + \ No newline at end of file diff --git a/data/assets/export.svg b/data/assets/export.svg new file mode 100644 index 0000000..9b7cbf1 --- /dev/null +++ b/data/assets/export.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/data/assets/first.svg b/data/assets/first.svg new file mode 100644 index 0000000..165c4fb --- /dev/null +++ b/data/assets/first.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/data/assets/folder.svg b/data/assets/folder.svg new file mode 100644 index 0000000..395303d --- /dev/null +++ b/data/assets/folder.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/data/assets/ghla.png b/data/assets/ghla.png new file mode 100644 index 0000000..2d38c9a Binary files /dev/null and b/data/assets/ghla.png differ diff --git a/data/assets/ghla.svg b/data/assets/ghla.svg new file mode 100644 index 0000000..795c683 --- /dev/null +++ b/data/assets/ghla.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/assets/icon.icns b/data/assets/icon.icns index 69fd967..75ecd98 100644 Binary files a/data/assets/icon.icns and b/data/assets/icon.icns differ diff --git a/data/assets/icon.ico b/data/assets/icon.ico index 2e25305..9a4dcbe 100644 Binary files a/data/assets/icon.ico and b/data/assets/icon.ico differ diff --git a/data/assets/icon.png b/data/assets/icon.png index 76b8d9a..00121e6 100644 Binary files a/data/assets/icon.png and b/data/assets/icon.png differ diff --git a/data/assets/import.svg b/data/assets/import.svg new file mode 100644 index 0000000..99b8b89 --- /dev/null +++ b/data/assets/import.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/data/assets/last.svg b/data/assets/last.svg new file mode 100644 index 0000000..7db4eff --- /dev/null +++ b/data/assets/last.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/data/assets/mia.png b/data/assets/mia.png new file mode 100644 index 0000000..89673dc Binary files /dev/null and b/data/assets/mia.png differ diff --git a/data/assets/next.svg b/data/assets/next.svg new file mode 100644 index 0000000..8447501 --- /dev/null +++ b/data/assets/next.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/data/assets/play.svg b/data/assets/play.svg new file mode 100644 index 0000000..4098666 --- /dev/null +++ b/data/assets/play.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/data/assets/stop.svg b/data/assets/stop.svg new file mode 100644 index 0000000..64cb7d7 --- /dev/null +++ b/data/assets/stop.svg @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/data/lang/en.json b/data/lang/en.json index 3d40ef6..2d75877 100644 --- a/data/lang/en.json +++ b/data/lang/en.json @@ -28,5 +28,50 @@ "version": "Version", "details": "Details", "update_details": "Update Details", - "update_aborted": "Update aborted by user" + "update_aborted": "Update aborted by user", + "select_image": "Select a File", + "select_folder": "Select a Folder", + "import": "Import", + "drag_drop": "Drag and Drop here", + "path_placeholder": "Path to file or folder", + "about": "

Image anonymization application.

\n

Version {v}

\n

Developed 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.

\n

Versión {v}

\n

Desarrollada 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.

\n

Version {v}

\n

Dé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