#Imports import os import sys from dataclasses import dataclass from typing import Dict, List, Optional, Tuple os.environ["TRANSFORMERS_OFFLINE"] = "1" os.environ["HF_DATASETS_OFFLINE"] = "1" os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" import numpy as np import torch import torch.nn as nn from lime import lime_image from PIL import Image from skimage.segmentation import mark_boundaries from torchvision import transforms from transformers import ViTForImageClassification, ViTConfig from PyQt6.QtCore import Qt, QThread, pyqtSignal from PyQt6.QtGui import QAction, QImage, QPixmap from PyQt6.QtWidgets import ( QApplication, QFileDialog, QHBoxLayout, QLabel, QMainWindow, QMessageBox, QPushButton, QScrollArea, QStatusBar, QVBoxLayout, QWidget, QButtonGroup, ) #Constants IMG_SIZE = 512 IMG_SIZE_VIT = 384 MODELS_DIR = "models_up" DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") IMAGE_EXTENSIONS = ("*.jpg", "*.jpeg", "*.png", "*.bmp", "*.webp", "*.tiff") LABELS_MAP = { "binary": {0: "Artificial", 1: "Natural"}, "trinary": {0: "Drawings", 1: "Games", 2: "Nature"}, } DISPLAY_MODES = { "normal": "Normal image", "binary": "Explanation binary", "trinary": "Explanation trinary", } #Przełącznik trybów class SegmentedControl(QWidget): modeChanged = pyqtSignal() def __init__(self, options: List[Tuple[str, str]]): super().__init__() layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(6) self.group = QButtonGroup(self) self.group.setExclusive(True) for idx, (label, mode) in enumerate(options): btn = QPushButton(label) btn.setCheckable(True) btn.setProperty("mode", mode) layout.addWidget(btn) self.group.addButton(btn, id=idx) first = self.group.buttons()[0] if self.group.buttons() else None if first: first.setChecked(True) self.group.buttonClicked.connect(lambda _btn: self.modeChanged.emit()) def currentMode(self) -> str: for btn in self.group.buttons(): if btn.isChecked(): return btn.property("mode") return "normal" def make_label(text: str) -> QLabel: lbl = QLabel(text) lbl.setWordWrap(True) return lbl #Vision Transformer - ViT vit_transform = transforms.Compose( [ transforms.ToPILImage(), transforms.Resize((IMG_SIZE_VIT, IMG_SIZE_VIT)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ] ) class ViTWrapper(nn.Module): def __init__(self, num_labels: int): super().__init__() config = ViTConfig( num_labels=num_labels, image_size=IMG_SIZE_VIT, patch_size=16, ) self.vit = ViTForImageClassification(config) def forward(self, x): return self.vit(pixel_values=x).logits @dataclass class AnalysisResult: image_path: str image_name: str image_array: np.ndarray display_array: np.ndarray display_mode: str binary_probs: np.ndarray trinary_probs: np.ndarray binary_label: str trinary_label: str binary_confidence: float trinary_confidence: float explanation_array: Optional[np.ndarray] = None explanation_label: Optional[str] = None #Wczytanie modeli def load_state_dict_robust(model: nn.Module, path: str) -> None: state = torch.load(path, map_location=DEVICE) if isinstance(state, dict) and "state_dict" in state: state = state["state_dict"] if isinstance(state, dict): cleaned = { (k[len("module."):] if k.startswith("module.") else k): v for k, v in state.items() } model.load_state_dict(cleaned) return model.load_state_dict(state) def load_vit_models() -> Dict[str, Tuple[nn.Module, str]]: models: Dict[str, Tuple[nn.Module, str]] = {} candidates = { "binary": os.path.join(MODELS_DIR, "binary_vit", "model_best.pth"), "trinary": os.path.join(MODELS_DIR, "trinary_vit", "model_best.pth"), } for task, path in candidates.items(): if not os.path.exists(path): raise FileNotFoundError(f"Nie znaleziono wag dla modelu {task} ViT: {path}") num_labels = 1 if task == "binary" else 3 model = ViTWrapper(num_labels=num_labels) load_state_dict_robust(model, path) model.to(DEVICE) model.eval() models[task] = (model, path) return models #Wykrywanie obrazów def list_image_files(input_path: str) -> List[str]: if os.path.isdir(input_path): paths: List[str] = [] allowed_extensions = tuple(extension.replace("*", "").lower() for extension in IMAGE_EXTENSIONS) for root, _, files in os.walk(input_path): for file_name in files: if file_name.lower().endswith(allowed_extensions): paths.append(os.path.join(root, file_name)) return sorted(set(paths)) if os.path.isfile(input_path): return [input_path] raise FileNotFoundError(input_path) def load_image(path: str) -> np.ndarray: image = Image.open(path).convert("RGB").resize((IMG_SIZE, IMG_SIZE)) return np.array(image) def preprocess_vit(images: List[np.ndarray]) -> torch.Tensor: tensors = [vit_transform(image) for image in images] return torch.stack(tensors).to(DEVICE) def predict_batch(model: nn.Module, images: List[np.ndarray], task: str) -> np.ndarray: batch = preprocess_vit(images) with torch.no_grad(): logits = model(batch) if task == "binary": logits = logits.squeeze(-1) probability_1 = torch.sigmoid(logits).cpu().numpy() probability_0 = 1.0 - probability_1 return np.column_stack((probability_0, probability_1)) return torch.softmax(logits, dim=1).cpu().numpy() def class_name(task: str, label: int) -> str: return LABELS_MAP[task].get(label, str(label)) def to_uint8_rgb(image_array: np.ndarray) -> np.ndarray: clipped = np.clip(image_array, 0, 255) if clipped.dtype != np.uint8: clipped = clipped.astype(np.uint8) return clipped #LIME Explanation def make_explanation_overlay( image_array: np.ndarray, probs: np.ndarray, task: str, explainer: lime_image.LimeImageExplainer, ) -> Tuple[np.ndarray, str]: prob_vector = np.asarray(probs, dtype=np.float32) if prob_vector.ndim == 0: prob_vector = prob_vector.reshape(1) if prob_vector.ndim == 1: prob_vector = np.tile(prob_vector, (1, 1)) def predict_fn(images: List[np.ndarray]) -> np.ndarray: return np.repeat(prob_vector, len(images), axis=0) explanation = explainer.explain_instance( image_array.astype(np.double), predict_fn, top_labels=1, hide_color=0, num_samples=35, ) label = int(np.argmax(probs)) temp, mask = explanation.get_image_and_mask( label, positive_only=True, num_features=10, hide_rest=False, ) temp = temp / (temp.max() if temp.max() > 0 else 1) overlay = mark_boundaries(temp, mask) overlay = np.clip(overlay * 255.0, 0, 255).astype(np.uint8) return overlay, class_name(task, label) #Analiza obrazu def analyse_image( image_path: str, models: Dict[str, Tuple[nn.Module, str]], display_mode: str, explainer: lime_image.LimeImageExplainer, ) -> AnalysisResult: image_array = load_image(image_path) binary_model = models["binary"][0] trinary_model = models["trinary"][0] binary_probs = predict_batch(binary_model, [image_array], "binary")[0] trinary_probs = predict_batch(trinary_model, [image_array], "trinary")[0] binary_index = int(np.argmax(binary_probs)) trinary_index = int(np.argmax(trinary_probs)) explanation_array = None explanation_label = None if display_mode == "binary": explanation_array, explanation_label = make_explanation_overlay( image_array, binary_probs, "binary", explainer, ) display_array = explanation_array elif display_mode == "trinary": explanation_array, explanation_label = make_explanation_overlay( image_array, trinary_probs, "trinary", explainer, ) display_array = explanation_array else: display_array = image_array return AnalysisResult( image_path=image_path, image_name=os.path.basename(image_path), image_array=image_array, display_array=display_array, display_mode=display_mode, binary_probs=binary_probs, trinary_probs=trinary_probs, binary_label=class_name("binary", binary_index), binary_confidence=float(binary_probs[binary_index]), trinary_label=class_name("trinary", trinary_index), trinary_confidence=float(trinary_probs[trinary_index]), explanation_array=explanation_array, explanation_label=explanation_label, ) def image_to_pixmap(image_array: np.ndarray, max_width: int = 1000, max_height: int = 700) -> QPixmap: rgb = to_uint8_rgb(image_array) height, width, channels = rgb.shape bytes_per_line = channels * width image = QImage(rgb.data, width, height, bytes_per_line, QImage.Format.Format_RGB888) pixmap = QPixmap.fromImage(image.copy()) return pixmap.scaled(max_width, max_height, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) #Funkcja wstępnego wyliczania klas i wyjaśnień przy wczytywaniu obrazów class PrecomputeWorker(QThread): precomputed = pyqtSignal(str, str, object) progress = pyqtSignal(int, int) finished_all = pyqtSignal() def __init__(self, image_paths: List[str], models: Dict[str, Tuple[nn.Module, str]]): super().__init__() self.image_paths = image_paths self.models = models def run(self): explainer = lime_image.LimeImageExplainer() modes = list(DISPLAY_MODES.keys()) total = len(self.image_paths) * len(modes) count = 0 for p in self.image_paths: for mode in modes: try: result = analyse_image(p, self.models, mode, explainer) self.precomputed.emit(p, mode, result) except Exception: pass count += 1 self.progress.emit(count, total) self.finished_all.emit() #Główne okno PyQT6 class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("LZSI - ViT Image Inspector") self.resize(1320, 860) self.models = load_vit_models() self.image_paths: List[str] = [] self.current_index = 0 self.current_result: Optional[AnalysisResult] = None self.result_cache: Dict[Tuple[str, str], AnalysisResult] = {} self._build_ui() self._set_navigation_enabled(False) self.display_mode_control.setEnabled(False) self.statusBar().showMessage("Wczytaj pojedynczy obraz albo katalog obrazów.") def _build_ui(self) -> None: central = QWidget() root_layout = QHBoxLayout(central) controls_panel = QWidget() controls_layout = QVBoxLayout(controls_panel) self.open_file_button = QPushButton("Otwórz obraz") self.open_dir_button = QPushButton("Otwórz katalog") self.prev_button = QPushButton("Poprzedni") self.next_button = QPushButton("Następny") self.prev_button.clicked.connect(self.show_previous_image) self.next_button.clicked.connect(self.show_next_image) self.open_file_button.clicked.connect(self.open_image_file) self.open_dir_button.clicked.connect(self.open_image_directory) controls_layout.addWidget(self.open_file_button) controls_layout.addWidget(self.open_dir_button) controls_layout.addStretch(1) self.path_label = make_label("Brak wczytanego pliku.") self.counter_label = QLabel("") self.counter_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.binary_label = make_label("Binary: -") self.trinary_label = make_label("Trinary: -") self.explanation_label = make_label("Explanation: wyłączone") info_panel = QWidget() info_layout = QVBoxLayout(info_panel) info_layout.addWidget(self.path_label) info_layout.addWidget(self.binary_label) info_layout.addWidget(self.trinary_label) info_layout.addWidget(self.explanation_label) info_layout.addStretch(1) self.image_label = QLabel("Wybierz obraz lub katalog obrazów.") self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.image_label.setMinimumSize(720, 540) self.image_label.setStyleSheet("background: #111827; color: #e5e7eb; border: 1px solid #374151; border-radius: 10px;") scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setWidget(self.image_label) right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.addWidget(scroll_area, stretch=1) self.display_mode_control = SegmentedControl([ ("Normal", "normal"), ("Binary", "binary"), ("Trinary", "trinary"), ]) bottom_controls = QWidget() bottom_layout = QHBoxLayout(bottom_controls) bottom_layout.setContentsMargins(0, 0, 0, 0) bottom_layout.addWidget(self.display_mode_control) self.prev_button.setText('◀') self.next_button.setText('▶') self.display_mode_control.modeChanged.connect(self.render_current_image) bottom_layout.addStretch(1) bottom_layout.addWidget(self.prev_button) bottom_layout.addWidget(self.counter_label) bottom_layout.addWidget(self.next_button) right_layout.addWidget(bottom_controls) right_layout.addWidget(info_panel) root_layout.addWidget(controls_panel, stretch=0) root_layout.addWidget(right_panel, stretch=1) self.setCentralWidget(central) self.status_bar = QStatusBar() self.setStatusBar(self.status_bar) open_action = QAction("Otwórz obraz", self) open_action.triggered.connect(self.open_image_file) open_dir_action = QAction("Otwórz katalog", self) open_dir_action.triggered.connect(self.open_image_directory) self.menuBar().addAction(open_action) self.menuBar().addAction(open_dir_action) def _set_navigation_enabled(self, enabled: bool) -> None: self.prev_button.setEnabled(enabled) self.next_button.setEnabled(enabled) def _current_display_mode(self) -> str: return self.display_mode_control.currentMode() def open_image_file(self) -> None: path, _ = QFileDialog.getOpenFileName( self, "Wybierz obraz", "", "Images (*.jpg *.jpeg *.png *.bmp *.webp *.tiff)", ) if path: self.load_path(path) def open_image_directory(self) -> None: path = QFileDialog.getExistingDirectory(self, "Wybierz katalog") if path: self.load_path(path) def load_path(self, input_path: str) -> None: try: self.image_paths = list_image_files(input_path) except FileNotFoundError: QMessageBox.warning(self, "Błąd", f"Nie można odnaleźć: {input_path}") return if not self.image_paths: QMessageBox.information(self, "Brak obrazów", "Nie znaleziono obsługiwanych plików graficznych.") return self.current_index = 0 self.current_result = None self.result_cache.clear() self._set_navigation_enabled(False) self.display_mode_control.setEnabled(False) self.status_bar.showMessage("Generowanie wyjaśnień: 0/0") first_image = load_image(self.image_paths[0]) self.image_label.setPixmap(image_to_pixmap(first_image)) self.precompute_worker = PrecomputeWorker(self.image_paths, self.models) self.precompute_worker.precomputed.connect(self.on_precomputed_item) self.precompute_worker.progress.connect(self.on_precompute_progress) self.precompute_worker.finished_all.connect(self.on_precompute_finished) self.precompute_worker.start() def show_previous_image(self) -> None: if not self.image_paths: return self.current_index = (self.current_index - 1) % len(self.image_paths) self.render_current_image() def show_next_image(self) -> None: if not self.image_paths: return self.current_index = (self.current_index + 1) % len(self.image_paths) self.render_current_image() def render_current_image(self, *_args) -> None: if not self.image_paths: return image_path = self.image_paths[self.current_index] display_mode = self._current_display_mode() cache_key = (image_path, display_mode) cached_result = self.result_cache.get(cache_key) if cached_result is not None: self.apply_result(cached_result) def on_precomputed_item(self, image_path: str, mode: str, result: AnalysisResult) -> None: self.result_cache[(image_path, mode)] = result if self.image_paths: current_path = self.image_paths[self.current_index] if image_path == current_path and mode == self._current_display_mode(): self.apply_result(result) def on_precompute_progress(self, count: int, total: int) -> None: self.status_bar.showMessage(f"Generowanie wyjaśnień: {count}/{total}") def on_precompute_finished(self) -> None: self._set_navigation_enabled(len(self.image_paths) > 1) self.display_mode_control.setEnabled(True) self.status_bar.showMessage("Wczytywanie zakończone") self.render_current_image() def apply_result(self, result: AnalysisResult) -> None: self.current_result = result self.path_label.setText(f"Plik: {result.image_path}") total = len(self.image_paths) current = self.current_index + 1 if self.image_paths else 0 self.counter_label.setText(f"{current} z {total}") self.binary_label.setText( "Binary: {label} ({confidence:.2f}) | Artificial {p0:.2f} / Natural {p1:.2f}".format( label=result.binary_label, confidence=result.binary_confidence, p0=float(result.binary_probs[0]), p1=float(result.binary_probs[1]), ) ) self.trinary_label.setText( "Trinary: {label} ({confidence:.2f}) | Drawings {p0:.2f} / Games {p1:.2f} / Nature {p2:.2f}".format( label=result.trinary_label, confidence=result.trinary_confidence, p0=float(result.trinary_probs[0]), p1=float(result.trinary_probs[1]), p2=float(result.trinary_probs[2]), ) ) if result.display_mode == "normal": self.explanation_label.setText("Widok: zwykły obraz") else: self.explanation_label.setText( "Widok: {mode} -> {label}".format( mode=DISPLAY_MODES.get(result.display_mode, result.display_mode), label=result.explanation_label, ) ) display_array = result.display_array self.image_label.setPixmap(image_to_pixmap(display_array)) self.status_bar.showMessage(f"Gotowe: {result.image_name}") def main() -> int: app = QApplication(sys.argv) app.setStyleSheet( """ QMainWindow { background: #0f172a; color: #e5e7eb; } QWidget { font-size: 13px; } QPushButton, QCheckBox { padding: 8px; } QPushButton { background: #1f2937; color: #f9fafb; border: 1px solid #374151; border-radius: 8px; } QPushButton:hover { background: #273449; } QLabel { color: #e5e7eb; } QStatusBar { color: #e5e7eb; } """ ) try: window = MainWindow() except Exception as exc: QMessageBox.critical(None, "Błąd startu", str(exc)) return 1 window.show() return app.exec() if __name__ == "__main__": raise SystemExit(main())