big translation
@ -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
|
@ -1,5 +1,4 @@
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
from typing import Optional
|
||||
|
||||
class AlertManager:
|
||||
|
||||
|
387
app/core/dicom_manager.py
Normal 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
@ -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)
|
||||
|
@ -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
|
@ -3,6 +3,7 @@ from typing import Callable, Dict, List, Any
|
||||
class NotificationType:
|
||||
THEME = 0
|
||||
LANGUAGE = 1
|
||||
DICOM = 2
|
||||
|
||||
class ObserverManager:
|
||||
"""
|
||||
|
@ -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;
|
||||
}}
|
||||
"""
|
@ -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
|
||||
|
@ -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)
|
59
app/ui/widgets/drag_drop_frame.py
Normal 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())
|
176
app/ui/widgets/image_viewer.py
Normal 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
|
@ -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()
|
||||
|
107
app/ui/widgets/subfolder_widget.py
Normal 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)
|
507
app/ui/windows/dicom_window.py
Normal 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
|
370
app/ui/windows/export_window.py
Normal 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)
|
330
app/ui/windows/import_window.py
Normal 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)
|
@ -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"))
|
@ -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
@ -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
|
@ -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")
|
||||
|
||||
|
@ -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
@ -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
@ -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
@ -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
@ -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
After Width: | Height: | Size: 55 KiB |
1
data/assets/ghla.svg
Normal file
After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 65 KiB |
4
data/assets/import.svg
Normal 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
@ -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
After Width: | Height: | Size: 53 KiB |
6
data/assets/next.svg
Normal 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
@ -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
@ -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 |
@ -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
@ -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"
|
||||
}
|
@ -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"
|
||||
}
|
@ -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",
|
||||
|
@ -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
@ -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__":
|
||||
|
@ -1,4 +1,10 @@
|
||||
PyQt6
|
||||
pyinstaller
|
||||
python-dotenv
|
||||
requests
|
||||
requests
|
||||
reportlab
|
||||
pydicom
|
||||
numpy
|
||||
opencv-python
|
||||
pandas
|
||||
openpyxl
|