big translation

This commit is contained in:
Louis Mazin 2025-09-26 17:51:43 +02:00
parent 88f32aed1f
commit 7531a1cbfa
43 changed files with 2873 additions and 67 deletions

View File

@ -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

View File

@ -1,5 +1,4 @@
from PyQt6.QtWidgets import QMessageBox
from typing import Optional
class AlertManager:

387
app/core/dicom_manager.py Normal file
View File

@ -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()

567
app/core/export_manager.py Normal file
View File

@ -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)

View File

@ -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
return self.update_manager
def get_dicom_manager(self) -> DicomManager:
return self.dicom_manager
def get_export_manager(self) -> ExportManager:
return self.export_manager

View File

@ -3,6 +3,7 @@ from typing import Callable, Dict, List, Any
class NotificationType:
THEME = 0
LANGUAGE = 1
DICOM = 2
class ObserverManager:
"""

View File

@ -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;
}}
"""

View File

@ -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

View File

@ -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"))
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)

View File

@ -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())

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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"))
for i in range(self.theme_combo.count()):
self.theme_combo.setItemText(i, self.language_manager.get_text(self.theme_combo.itemData(i)+ "_theme"))

View File

@ -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"""

30
app/utils/image_editor.py Normal file
View File

@ -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

View File

@ -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")

View File

@ -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"
}

6
data/assets/back.svg Normal file
View File

@ -0,0 +1,6 @@
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" version="1.0" width="820.000000pt" height="820.000000pt" viewBox="0 0 820.000000 820.000000" preserveAspectRatio="xMidYMid meet" fill="#5D5A5A" stroke="#5D5A5A">
<ns0:g transform="translate(0.000000,820.000000) scale(0.100000,-0.100000) rotate(180 4100 4100)" fill="#000000" stroke="none">
<ns0:path d="M2047 7723 l-477 -478 1572 -1572 1573 -1573 -1573 -1573 -1572 -1572 479 -479 479 -479 2052 2052 2052 2052 -2050 2049 c-1127 1128 -2051 2050 -2053 2050 -2 0 -219 -215 -482 -477z" fill="#5D5A5A" stroke="#5D5A5A" />
</ns0:g>
</ns0:svg>

4
data/assets/export.svg Normal file
View File

@ -0,0 +1,4 @@
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" width="800px" height="800px" viewBox="0 0 24 24" fill="#4A4A4A" stroke="#4A4A4A">
<ns0:path d="M12 14L11.2929 14.7071L12 15.4142L12.7071 14.7071L12 14ZM13 5C13 4.44772 12.5523 4 12 4C11.4477 4 11 4.44771 11 5L13 5ZM6.29289 9.70711L11.2929 14.7071L12.7071 13.2929L7.70711 8.29289L6.29289 9.70711ZM12.7071 14.7071L17.7071 9.70711L16.2929 8.29289L11.2929 13.2929L12.7071 14.7071ZM13 14L13 5L11 5L11 14L13 14Z" fill="#4A4A4A" stroke="#4A4A4A" transform="rotate(180 12 12) translate(0 4.5)" />
<ns0:path d="M5 16L5 17C5 18.1046 5.89543 19 7 19L17 19C18.1046 19 19 18.1046 19 17V16" stroke="#4A4A4A" stroke-width="2" fill="#4A4A4A" />
</ns0:svg>

7
data/assets/first.svg Normal file
View File

@ -0,0 +1,7 @@
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" version="1.0" width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000" preserveAspectRatio="xMidYMid meet" fill="#5D5A5A" stroke="#5D5A5A">
<ns0:g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000) rotate(180 2560 2560)" fill="#000000" stroke="none">
<ns0:path d="M1015 4511 c-26 -12 -128 -107 -287 -267 -265 -268 -269 -274 -253 -362 6 -33 81 -112 648 -680 l642 -642 -642 -643 c-697 -697 -666 -661 -648 -754 6 -33 43 -74 263 -295 277 -277 294 -289 379 -274 34 7 132 101 980 949 838 838 942 946 948 979 18 96 75 32 -938 1047 -544 544 -945 939 -964 947 -43 19 -80 17 -128 -5z" fill="#5D5A5A" stroke="#5D5A5A" />
<ns0:path d="M3292 4463 c-18 -9 -45 -32 -60 -51 l-27 -35 -3 -1805 c-2 -2010 -8 -1843 66 -1900 l35 -27 377 0 377 0 35 27 c74 56 68 -107 68 1888 0 1995 6 1832 -68 1888 l-35 27 -366 2 c-324 3 -370 1 -399 -14z" fill="#5D5A5A" stroke="#5D5A5A" />
</ns0:g>
</ns0:svg>

3
data/assets/folder.svg Normal file
View File

@ -0,0 +1,3 @@
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" width="800px" height="800px" viewBox="0 0 24 24" fill="#4A4A4A" stroke="#4A4A4A">
<ns0:path d="M3 8.2C3 7.07989 3 6.51984 3.21799 6.09202C3.40973 5.71569 3.71569 5.40973 4.09202 5.21799C4.51984 5 5.0799 5 6.2 5H9.67452C10.1637 5 10.4083 5 10.6385 5.05526C10.8425 5.10425 11.0376 5.18506 11.2166 5.29472C11.4184 5.4184 11.5914 5.59135 11.9373 5.93726L12.0627 6.06274C12.4086 6.40865 12.5816 6.5816 12.7834 6.70528C12.9624 6.81494 13.1575 6.89575 13.3615 6.94474C13.5917 7 13.8363 7 14.3255 7H17.8C18.9201 7 19.4802 7 19.908 7.21799C20.2843 7.40973 20.5903 7.71569 20.782 8.09202C21 8.51984 21 9.0799 21 10.2V15.8C21 16.9201 21 17.4802 20.782 17.908C20.5903 18.2843 20.2843 18.5903 19.908 18.782C19.4802 19 18.9201 19 17.8 19H6.2C5.07989 19 4.51984 19 4.09202 18.782C3.71569 18.5903 3.40973 18.2843 3.21799 17.908C3 17.4802 3 16.9201 3 15.8V8.2Z" stroke="#4A4A4A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="#4A4A4A" />
</ns0:svg>

BIN
data/assets/ghla.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

1
data/assets/ghla.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 65 KiB

4
data/assets/import.svg Normal file
View File

@ -0,0 +1,4 @@
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" width="800px" height="800px" viewBox="0 0 24 24" fill="#D1D1D6" stroke="#D1D1D6">
<ns0:path d="M12 14L11.2929 14.7071L12 15.4142L12.7071 14.7071L12 14ZM13 5C13 4.44772 12.5523 4 12 4C11.4477 4 11 4.44771 11 5L13 5ZM6.29289 9.70711L11.2929 14.7071L12.7071 13.2929L7.70711 8.29289L6.29289 9.70711ZM12.7071 14.7071L17.7071 9.70711L16.2929 8.29289L11.2929 13.2929L12.7071 14.7071ZM13 14L13 5L11 5L11 14L13 14Z" fill="#D1D1D6" stroke="#D1D1D6" />
<ns0:path d="M5 16L5 17C5 18.1046 5.89543 19 7 19L17 19C18.1046 19 19 18.1046 19 17V16" stroke="#D1D1D6" stroke-width="2" fill="#D1D1D6" />
</ns0:svg>

7
data/assets/last.svg Normal file
View File

@ -0,0 +1,7 @@
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" version="1.0" width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000" preserveAspectRatio="xMidYMid meet" fill="#5D5A5A" stroke="#5D5A5A">
<ns0:g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)" fill="#000000" stroke="none">
<ns0:path d="M1015 4511 c-26 -12 -128 -107 -287 -267 -265 -268 -269 -274 -253 -362 6 -33 81 -112 648 -680 l642 -642 -642 -643 c-697 -697 -666 -661 -648 -754 6 -33 43 -74 263 -295 277 -277 294 -289 379 -274 34 7 132 101 980 949 838 838 942 946 948 979 18 96 75 32 -938 1047 -544 544 -945 939 -964 947 -43 19 -80 17 -128 -5z" fill="#5D5A5A" stroke="#5D5A5A" />
<ns0:path d="M3292 4463 c-18 -9 -45 -32 -60 -51 l-27 -35 -3 -1805 c-2 -2010 -8 -1843 66 -1900 l35 -27 377 0 377 0 35 27 c74 56 68 -107 68 1888 0 1995 6 1832 -68 1888 l-35 27 -366 2 c-324 3 -370 1 -399 -14z" fill="#5D5A5A" stroke="#5D5A5A" />
</ns0:g>
</ns0:svg>

BIN
data/assets/mia.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

6
data/assets/next.svg Normal file
View File

@ -0,0 +1,6 @@
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" version="1.0" width="820.000000pt" height="820.000000pt" viewBox="0 0 820.000000 820.000000" preserveAspectRatio="xMidYMid meet" fill="#5D5A5A" stroke="#5D5A5A">
<ns0:g transform="translate(0.000000,820.000000) scale(0.100000,-0.100000)" fill="#000000" stroke="none">
<ns0:path d="M2047 7723 l-477 -478 1572 -1572 1573 -1573 -1573 -1573 -1572 -1572 479 -479 479 -479 2052 2052 2052 2052 -2050 2049 c-1127 1128 -2051 2050 -2053 2050 -2 0 -219 -215 -482 -477z" fill="#5D5A5A" stroke="#5D5A5A" />
</ns0:g>
</ns0:svg>

6
data/assets/play.svg Normal file
View File

@ -0,0 +1,6 @@
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" version="1.0" width="360.000000pt" height="360.000000pt" viewBox="0 0 360.000000 360.000000" preserveAspectRatio="xMidYMid meet" fill="#5D5A5A" stroke="#5D5A5A">
<ns0:g transform="translate(0.000000,360.000000) scale(0.100000,-0.100000)" fill="#000000" stroke="none">
<ns0:path d="M743 3116 l-28 -24 0 -1296 0 -1296 26 -26 c45 -45 81 -37 216 48 65 41 285 180 488 308 204 128 411 259 460 290 289 182 831 524 892 563 77 49 93 69 93 114 0 43 -22 67 -118 126 -84 53 -248 156 -1390 875 -588 371 -581 368 -639 318z" fill="#5D5A5A" stroke="#5D5A5A" />
</ns0:g>
</ns0:svg>

18
data/assets/stop.svg Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="400.000000pt" height="400.000000pt" viewBox="0 0 400.000000 400.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,400.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1012 3221 c-41 -21 -62 -42 -78 -80 -21 -50 -21 -2232 0 -2282 15
-36 37 -59 74 -78 31 -16 575 -15 606 1 31 16 55 44 72 83 22 53 21 2219 -1
2272 -18 42 -56 80 -88 88 -12 3 -143 7 -290 8 -211 2 -274 -1 -295 -12z"/>
<path d="M2399 3223 c-15 -6 -37 -21 -49 -33 -52 -52 -50 -8 -50 -1190 0
-1182 -2 -1138 50 -1190 43 -43 62 -45 358 -40 265 5 282 6 309 26 66 49 62
-28 62 1204 0 1232 4 1155 -62 1204 -27 20 -44 21 -309 26 -202 4 -289 2 -309
-7z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 888 B

View File

@ -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": "<p style='text-align:center;'>Image anonymization application.</p> \n<p style='text-align:center;'>Version {v}</p> \n<p style='text-align:center;'>Developed in Python.</p>",
"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"
}

77
data/lang/es.json Normal file
View File

@ -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": "<p style='text-align:center;'>Aplicación de anonimización de imágenes.</p> \n<p style='text-align:center;'>Versión {v}</p> \n<p style='text-align:center;'>Desarrollada en Python.</p>",
"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"
}

View File

@ -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": "<p style='text-align:center;'>Application d'anonymisation d'images.</p> \n<p style='text-align:center;'>Version {v}</p> \n<p style='text-align:center;'>Développée en Python.</p>",
"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"
}

View File

@ -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",

View File

@ -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",

13
main.py
View File

@ -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__":

View File

@ -1,4 +1,10 @@
PyQt6
pyinstaller
python-dotenv
requests
requests
reportlab
pydicom
numpy
opencv-python
pandas
openpyxl