Download app.py from LZSI2026-2/explainer_and_app: direct link, hf CLI and curl.
- Browser
- Download file 18.2 kB
-
https://huggingface.co/LZSI2026-2/explainer_and_app/resolve/main/app.py
- Command line
-
hf download hf://LZSI2026-2/explainer_and_app/app.py
-
curl -L -o app.py https://huggingface.co/LZSI2026-2/explainer_and_app/resolve/main/app.py
18.2 kB
| #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 | |
| 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()) | |