Last active
December 3, 2025 16:33
-
-
Save kezzyhko/e11e6da0de014989a476f2d256b8de27 to your computer and use it in GitHub Desktop.
.npy image viewer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/python3 | |
| # https://gist.github.com/kezzyhko/e11e6da0de014989a476f2d256b8de27 | |
| from PIL import Image, ImageTk, PngImagePlugin | |
| import sys | |
| import numpy as np | |
| import tkinter as tk | |
| from tkinter import ttk, filedialog, simpledialog | |
| from pathlib import Path | |
| import subprocess | |
| import os | |
| import re | |
| from collections import defaultdict | |
| class ImageViewer: | |
| def __init__(self, root, folder_path=None): | |
| self.root = root | |
| self.root.title("NPY Image Viewer") | |
| # Set window size - smaller and resizable | |
| self.root.geometry("900x700") | |
| self.root.minsize(700, 500) | |
| # Store original image for responsive resizing | |
| self.original_image = None | |
| self.resize_job = None | |
| # Track all active tooltips for cleanup | |
| self.active_tooltips = [] | |
| # Configure style for UI elements - will be adjusted responsively | |
| style = ttk.Style() | |
| self.base_font_size = 12 | |
| self.button_font_size = 11 | |
| style.configure('Large.TButton', font=('Arial', self.button_font_size), padding=8) | |
| # If no folder path provided, prompt user to select one | |
| if folder_path is None: | |
| folder_path = filedialog.askdirectory( | |
| title="Select folder containing .npy files", | |
| initialdir=os.getcwd() | |
| ) | |
| if not folder_path: | |
| print("No folder selected. Exiting...") | |
| sys.exit(0) | |
| self.folder_path = folder_path | |
| # Find all .npy and .png files recursively | |
| self.npy_files = sorted(Path(folder_path).rglob("*.npy")) | |
| self.png_files = sorted(Path(folder_path).rglob("*.png")) | |
| self.all_files = sorted(list(self.npy_files) + list(self.png_files)) | |
| if not self.all_files: | |
| print(f"No .npy or .png files found in {folder_path}") | |
| sys.exit(1) | |
| # File selection/marking system | |
| self.marked_files = set() # Set of file paths that are marked | |
| # Build image version groups (for toggling between versions like color/depth) | |
| self.build_version_groups() | |
| # Current logical image index and current version | |
| self.current_image_index = 0 | |
| self.current_version = None | |
| # Set initial version if available | |
| if self.logical_images: | |
| first_image = self.logical_images[0] | |
| self.current_version = first_image['versions'][0]['name'] | |
| # Top frame for filename and actions | |
| top_frame = tk.Frame(root, pady=15) | |
| top_frame.pack(fill=tk.X, padx=20) | |
| # Filename label - clickable to open location | |
| self.filename_font_size = 13 | |
| self.filename_label = tk.Label( | |
| top_frame, | |
| text="", | |
| font=("Arial", self.filename_font_size, "bold"), | |
| fg="blue", | |
| cursor="hand2", | |
| wraplength=800, | |
| justify=tk.LEFT | |
| ) | |
| self.filename_label.pack(anchor=tk.W) | |
| self.filename_label.bind("<Button-1>", self.open_file_location) | |
| # Tooltip for filename | |
| self.create_tooltip(self.filename_label, "Click to open file location") | |
| # Action buttons frame (Copy Path, Refresh) - allow wrapping | |
| action_frame = tk.Frame(top_frame) | |
| action_frame.pack(anchor=tk.W, pady=10, fill=tk.X) | |
| self.copy_button = ttk.Button( | |
| action_frame, | |
| text="📋 Copy Full Path", | |
| command=self.copy_path, | |
| style='Large.TButton' | |
| ) | |
| self.copy_button.pack(side=tk.LEFT, padx=5) | |
| self.create_tooltip(self.copy_button, "Copy the full file path to clipboard") | |
| self.refresh_button = ttk.Button( | |
| action_frame, | |
| text="🔄 Refresh", | |
| command=self.refresh_files, | |
| style='Large.TButton' | |
| ) | |
| self.refresh_button.pack(side=tk.LEFT, padx=5) | |
| self.create_tooltip(self.refresh_button, "Manually rescan folder for new .npy files") | |
| self.change_folder_button = ttk.Button( | |
| action_frame, | |
| text="📁 Change Folder", | |
| command=self.change_folder, | |
| style='Large.TButton' | |
| ) | |
| self.change_folder_button.pack(side=tk.LEFT, padx=5) | |
| self.create_tooltip(self.change_folder_button, "Select a different folder to view") | |
| self.export_button = ttk.Button( | |
| action_frame, | |
| text="💾 Export as PNG", | |
| command=self.export_folder, | |
| style='Large.TButton' | |
| ) | |
| self.export_button.pack(side=tk.LEFT, padx=5) | |
| self.create_tooltip(self.export_button, "Export all .npy files as PNG images") | |
| self.mark_button = ttk.Button( | |
| action_frame, | |
| text="✓ Mark File", | |
| command=self.toggle_mark_current_file, | |
| style='Large.TButton' | |
| ) | |
| self.mark_button.pack(side=tk.LEFT, padx=5) | |
| self.create_tooltip(self.mark_button, "Mark/unmark current file for export (or press 'M')") | |
| self.export_marked_button = ttk.Button( | |
| action_frame, | |
| text="💾 Export Marked as NPY", | |
| command=self.export_marked_files, | |
| style='Large.TButton' | |
| ) | |
| self.export_marked_button.pack(side=tk.LEFT, padx=5) | |
| self.create_tooltip(self.export_marked_button, f"Export {len(self.marked_files)} marked file(s) as .npy") | |
| # Marked files counter | |
| self.marked_count_label = tk.Label( | |
| action_frame, | |
| text="", | |
| font=("Arial", 11), | |
| fg="blue" | |
| ) | |
| self.marked_count_label.pack(side=tk.LEFT, padx=10) | |
| self.update_marked_count() | |
| # Status label for feedback | |
| self.status_font_size = 11 | |
| self.status_label = tk.Label( | |
| action_frame, | |
| text="", | |
| font=("Arial", self.status_font_size), | |
| fg="green" | |
| ) | |
| self.status_label.pack(side=tk.LEFT, padx=15) | |
| # Create a container for image and controls to ensure proper space allocation | |
| content_frame = tk.Frame(root) | |
| content_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) | |
| # Image display area | |
| self.image_label = tk.Label(content_frame, bg="#f0f0f0") | |
| self.image_label.pack(padx=10, pady=10, fill=tk.BOTH, expand=True) | |
| # Navigation buttons frame | |
| button_frame = tk.Frame(content_frame) | |
| button_frame.pack(pady=10, fill=tk.X) | |
| # Left side: navigation buttons | |
| nav_buttons_frame = tk.Frame(button_frame) | |
| nav_buttons_frame.pack(side=tk.LEFT, expand=True) | |
| self.prev_button = ttk.Button( | |
| nav_buttons_frame, | |
| text="← Previous", | |
| command=self.show_previous, | |
| style='Large.TButton', | |
| width=15 | |
| ) | |
| self.prev_button.pack(side=tk.LEFT, padx=5) | |
| self.next_button = ttk.Button( | |
| nav_buttons_frame, | |
| text="Next →", | |
| command=self.show_next, | |
| style='Large.TButton', | |
| width=15 | |
| ) | |
| self.next_button.pack(side=tk.LEFT, padx=5) | |
| # Right side: Jump to image controls | |
| jump_frame = tk.Frame(button_frame) | |
| jump_frame.pack(side=tk.RIGHT, padx=10) | |
| self.jump_font_size = 11 | |
| self.jump_label = tk.Label(jump_frame, text="Jump to:", font=("Arial", self.jump_font_size)) | |
| self.jump_label.pack(side=tk.LEFT, padx=5) | |
| self.jump_entry = tk.Entry(jump_frame, width=8, font=("Arial", self.jump_font_size)) | |
| self.jump_entry.pack(side=tk.LEFT, padx=5) | |
| self.jump_entry.bind("<Return>", lambda e: self.jump_to_image()) | |
| # Allow arrow keys to work even when entry has focus (for navigation) | |
| def handle_left(e): | |
| self.show_previous() | |
| return "break" | |
| def handle_right(e): | |
| self.show_next() | |
| return "break" | |
| def handle_up(e): | |
| self.next_version() | |
| return "break" | |
| def handle_down(e): | |
| self.previous_version() | |
| return "break" | |
| self.jump_entry.bind("<Left>", handle_left) | |
| self.jump_entry.bind("<Right>", handle_right) | |
| self.jump_entry.bind("<Up>", handle_up) | |
| self.jump_entry.bind("<Down>", handle_down) | |
| jump_button = ttk.Button( | |
| jump_frame, | |
| text="Go", | |
| command=self.jump_to_image, | |
| style='Large.TButton', | |
| width=6 | |
| ) | |
| jump_button.pack(side=tk.LEFT, padx=5) | |
| self.create_tooltip(jump_button, "Jump to specific image number (or press Enter)") | |
| # Counter and resolution label - ensure it's always visible at bottom | |
| info_frame = tk.Frame(root) | |
| info_frame.pack(pady=(5, 10), fill=tk.X, padx=10, side=tk.BOTTOM) | |
| # Set minimum height to ensure content is always visible (2 rows + padding) | |
| info_frame.config(height=80) | |
| info_frame.pack_propagate(False) # Prevent frame from shrinking | |
| # Top row: counter and resolution - allow wrapping | |
| info_top_frame = tk.Frame(info_frame) | |
| info_top_frame.pack(fill=tk.X, pady=3, padx=5) | |
| self.counter_font_size = 12 | |
| self.counter_label = tk.Label( | |
| info_top_frame, | |
| text="", | |
| font=("Arial", self.counter_font_size, "bold"), | |
| anchor=tk.W, | |
| justify=tk.LEFT, | |
| wraplength=300 | |
| ) | |
| self.counter_label.pack(side=tk.LEFT, padx=5, anchor=tk.W) | |
| self.resolution_font_size = 11 | |
| self.resolution_label = tk.Label( | |
| info_top_frame, | |
| text="", | |
| font=("Arial", self.resolution_font_size), | |
| fg="#0066cc", | |
| anchor=tk.W, | |
| justify=tk.LEFT, | |
| wraplength=400 | |
| ) | |
| self.resolution_label.pack(side=tk.LEFT, padx=5, anchor=tk.W) | |
| # Bottom row: Version switcher (can wrap if needed) | |
| self.version_frame = tk.Frame(info_frame) | |
| self.version_frame.pack(fill=tk.X, pady=3, padx=5) | |
| self.version_font_size = 11 | |
| self.version_label = tk.Label( | |
| self.version_frame, | |
| text="", | |
| font=("Arial", self.version_font_size, "bold"), | |
| fg="#ff6600", | |
| anchor=tk.W, | |
| justify=tk.LEFT | |
| ) | |
| self.version_label.pack(side=tk.LEFT, padx=5, anchor=tk.W) | |
| self.version_buttons_frame = tk.Frame(self.version_frame) | |
| self.version_buttons_frame.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True) | |
| # This will hold dynamically created version buttons | |
| self.version_buttons = {} | |
| # Keyboard bindings - use bind_all for Windows compatibility | |
| # This ensures bindings work even when other widgets have focus | |
| self.root.bind_all("<Left>", lambda e: self.show_previous()) | |
| self.root.bind_all("<Right>", lambda e: self.show_next()) | |
| self.root.bind_all("<Up>", lambda e: self.next_version()) | |
| self.root.bind_all("<Down>", lambda e: self.previous_version()) | |
| self.root.bind_all("<KeyPress-r>", lambda e: self.refresh_files()) | |
| self.root.bind_all("<KeyPress-R>", lambda e: self.refresh_files()) | |
| self.root.bind_all("<KeyPress-c>", lambda e: self.copy_path()) | |
| self.root.bind_all("<KeyPress-C>", lambda e: self.copy_path()) | |
| self.root.bind_all("<KeyPress-f>", lambda e: self.change_folder()) | |
| self.root.bind_all("<KeyPress-F>", lambda e: self.change_folder()) | |
| self.root.bind_all("<KeyPress-e>", lambda e: self.export_folder()) | |
| self.root.bind_all("<KeyPress-E>", lambda e: self.export_folder()) | |
| self.root.bind_all("<KeyPress-m>", lambda e: self.toggle_mark_current_file()) | |
| self.root.bind_all("<KeyPress-M>", lambda e: self.toggle_mark_current_file()) | |
| # Also bind to root window for additional compatibility | |
| self.root.bind("<Left>", lambda e: self.show_previous()) | |
| self.root.bind("<Right>", lambda e: self.show_next()) | |
| self.root.bind("<Up>", lambda e: self.next_version()) | |
| self.root.bind("<Down>", lambda e: self.previous_version()) | |
| self.root.bind("<KeyPress-r>", lambda e: self.refresh_files()) | |
| self.root.bind("<KeyPress-R>", lambda e: self.refresh_files()) | |
| self.root.bind("<KeyPress-c>", lambda e: self.copy_path()) | |
| self.root.bind("<KeyPress-C>", lambda e: self.copy_path()) | |
| self.root.bind("<KeyPress-f>", lambda e: self.change_folder()) | |
| self.root.bind("<KeyPress-F>", lambda e: self.change_folder()) | |
| self.root.bind("<KeyPress-e>", lambda e: self.export_folder()) | |
| self.root.bind("<KeyPress-E>", lambda e: self.export_folder()) | |
| self.root.bind("<KeyPress-m>", lambda e: self.toggle_mark_current_file()) | |
| self.root.bind("<KeyPress-M>", lambda e: self.toggle_mark_current_file()) | |
| # Ensure root window can receive focus for keyboard events | |
| self.root.focus_set() | |
| # Handle window close event to cleanup | |
| self.root.protocol("WM_DELETE_WINDOW", self.on_closing) | |
| # Bind window resize event for responsive layout | |
| self.root.bind("<Configure>", self.on_window_resize) | |
| # Ensure window can receive keyboard focus (important for Windows) | |
| # Use after() to set focus after window is fully created | |
| self.root.after(100, lambda: self.root.focus_force()) | |
| # Bind focus events to maintain keyboard focus on Windows | |
| self.root.bind("<FocusIn>", lambda e: self.root.focus_set()) | |
| # Load and display the first image | |
| self.load_image() | |
| def create_tooltip(self, widget, text): | |
| """Create a tooltip for a widget""" | |
| tooltip_window = [None] # Use list to maintain reference in closure | |
| tooltip_job = [None] # Track delayed tooltip creation | |
| def destroy_tooltip(): | |
| """Destroy tooltip and remove from active list""" | |
| if tooltip_window[0] is not None: | |
| try: | |
| if tooltip_window[0] in self.active_tooltips: | |
| self.active_tooltips.remove(tooltip_window[0]) | |
| tooltip_window[0].destroy() | |
| except: | |
| pass | |
| tooltip_window[0] = None | |
| def show_tooltip(event): | |
| """Show tooltip after a short delay""" | |
| # Cancel any pending tooltip creation | |
| if tooltip_job[0] is not None: | |
| self.root.after_cancel(tooltip_job[0]) | |
| tooltip_job[0] = None | |
| # Destroy any existing tooltip for this widget | |
| destroy_tooltip() | |
| # Destroy all other tooltips to prevent piling | |
| self._cleanup_all_tooltips() | |
| # Schedule tooltip creation after delay | |
| def create_tooltip(): | |
| try: | |
| tooltip = tk.Toplevel() | |
| tooltip.wm_overrideredirect(True) | |
| tooltip.wm_geometry(f"+{event.x_root+10}+{event.y_root+10}") | |
| label = tk.Label( | |
| tooltip, | |
| text=text, | |
| background="yellow", | |
| relief=tk.SOLID, | |
| borderwidth=1, | |
| font=("Arial", 11) | |
| ) | |
| label.pack() | |
| tooltip_window[0] = tooltip | |
| self.active_tooltips.append(tooltip) | |
| # Auto-destroy tooltip after 5 seconds as safety measure | |
| self.root.after(5000, destroy_tooltip) | |
| except: | |
| pass | |
| tooltip_job[0] = self.root.after(500, create_tooltip) # 500ms delay | |
| def hide_tooltip(event): | |
| """Hide tooltip when mouse leaves""" | |
| # Cancel pending tooltip creation | |
| if tooltip_job[0] is not None: | |
| self.root.after_cancel(tooltip_job[0]) | |
| tooltip_job[0] = None | |
| # Destroy tooltip | |
| destroy_tooltip() | |
| def on_motion(event): | |
| """Update tooltip position if mouse moves while tooltip is shown""" | |
| if tooltip_window[0] is not None: | |
| try: | |
| tooltip_window[0].wm_geometry(f"+{event.x_root+10}+{event.y_root+10}") | |
| except: | |
| pass | |
| widget.bind("<Enter>", show_tooltip) | |
| widget.bind("<Leave>", hide_tooltip) | |
| widget.bind("<Motion>", on_motion) | |
| def _cleanup_all_tooltips(self): | |
| """Destroy all active tooltips""" | |
| for tooltip in self.active_tooltips[:]: # Copy list to avoid modification during iteration | |
| try: | |
| tooltip.destroy() | |
| except: | |
| pass | |
| self.active_tooltips.clear() | |
| def parse_filename(self, filepath): | |
| """ | |
| Parse filename to extract version and base name. | |
| Pattern: {version}_image_{id}_{date}_{time}.npy | |
| Example: color_image_0001_20251124_085806.npy | |
| Returns: (version, base_name) or (None, filename) if pattern doesn't match | |
| """ | |
| filename = filepath.stem | |
| # Try to match the pattern: {version}_image_{rest} | |
| match = re.match(r'^(.+?)_image_(.+)$', filename) | |
| if match: | |
| version = match.group(1) | |
| base = f"image_{match.group(2)}" | |
| return (version, base) | |
| # If pattern doesn't match, return None as version | |
| return (None, filename) | |
| def build_version_groups(self): | |
| """Build logical images with multiple versions (supports .npy and .png files)""" | |
| # Group files by base name (without version) | |
| groups = defaultdict(list) | |
| files_without_version = [] | |
| # Process both .npy and .png files | |
| for file_path in self.all_files: | |
| version, base_name = self.parse_filename(file_path) | |
| if version: | |
| key = (file_path.parent, base_name) | |
| groups[key].append({'name': version, 'path': file_path}) | |
| else: | |
| # Files that don't match the pattern - treat as single-version images | |
| files_without_version.append(file_path) | |
| # Create logical images list | |
| self.logical_images = [] | |
| # Add grouped images (with versions) | |
| for key, versions in sorted(groups.items()): | |
| versions.sort(key=lambda x: x['name']) # Sort versions alphabetically | |
| self.logical_images.append({ | |
| 'base_name': key[1], | |
| 'parent': key[0], | |
| 'versions': versions, | |
| 'has_versions': True | |
| }) | |
| # Add ungrouped images (without versions) | |
| for npy_file in sorted(files_without_version): | |
| self.logical_images.append({ | |
| 'base_name': npy_file.stem, | |
| 'parent': npy_file.parent, | |
| 'versions': [{'name': None, 'path': npy_file}], | |
| 'has_versions': False | |
| }) | |
| # Sort logical images by parent and base name | |
| self.logical_images.sort(key=lambda x: (str(x['parent']), x['base_name'])) | |
| def get_current_versions(self): | |
| """Get all available versions for the current image""" | |
| if not self.logical_images or self.current_image_index >= len(self.logical_images): | |
| return [] | |
| current_image = self.logical_images[self.current_image_index] | |
| return [(v['name'], v['path']) for v in current_image['versions']] | |
| def switch_to_version(self, version_name): | |
| """Switch to a specific version of the current image""" | |
| if not self.logical_images or self.current_image_index >= len(self.logical_images): | |
| return | |
| current_image = self.logical_images[self.current_image_index] | |
| # Check if this version exists | |
| version_exists = any(v['name'] == version_name for v in current_image['versions']) | |
| if version_exists: | |
| self.current_version = version_name | |
| self.load_image() | |
| if version_name: | |
| self.show_status(f"✓ Switched to {version_name} version", 2000) | |
| else: | |
| self.show_status(f"⚠ Version {version_name} not available", 2000, "orange") | |
| def next_version(self): | |
| """Switch to next version of current image""" | |
| if not self.logical_images or self.current_image_index >= len(self.logical_images): | |
| return | |
| current_image = self.logical_images[self.current_image_index] | |
| versions = current_image['versions'] | |
| if len(versions) <= 1: | |
| return | |
| # Find current version index | |
| current_idx = next((i for i, v in enumerate(versions) if v['name'] == self.current_version), 0) | |
| # Move to next version | |
| next_idx = (current_idx + 1) % len(versions) | |
| next_version = versions[next_idx]['name'] | |
| self.switch_to_version(next_version) | |
| def previous_version(self): | |
| """Switch to previous version of current image""" | |
| if not self.logical_images or self.current_image_index >= len(self.logical_images): | |
| return | |
| current_image = self.logical_images[self.current_image_index] | |
| versions = current_image['versions'] | |
| if len(versions) <= 1: | |
| return | |
| # Find current version index | |
| current_idx = next((i for i, v in enumerate(versions) if v['name'] == self.current_version), 0) | |
| # Move to previous version | |
| prev_idx = (current_idx - 1) % len(versions) | |
| prev_version = versions[prev_idx]['name'] | |
| self.switch_to_version(prev_version) | |
| def update_version_ui(self): | |
| """Update the version switcher UI to show available versions""" | |
| # Clear existing version buttons | |
| for widget in self.version_buttons.values(): | |
| widget.destroy() | |
| self.version_buttons.clear() | |
| if not self.logical_images or self.current_image_index >= len(self.logical_images): | |
| self.version_label.config(text="") | |
| return | |
| current_image = self.logical_images[self.current_image_index] | |
| versions = current_image['versions'] | |
| if len(versions) <= 1: | |
| # No versions or only one version, hide the UI | |
| self.version_label.config(text="") | |
| return | |
| self.version_label.config(text="📸 Versions:") | |
| # Create button for each version | |
| for version_info in versions: | |
| version = version_info['name'] | |
| is_current = (version == self.current_version) | |
| btn = tk.Button( | |
| self.version_buttons_frame, | |
| text=version, | |
| font=("Arial", 10, "bold" if is_current else "normal"), | |
| bg="#4CAF50" if is_current else "#e0e0e0", | |
| fg="white" if is_current else "black", | |
| relief=tk.SUNKEN if is_current else tk.RAISED, | |
| padx=8, | |
| pady=4, | |
| command=lambda v=version: self.switch_to_version(v) | |
| ) | |
| btn.pack(side=tk.LEFT, padx=2) | |
| self.version_buttons[version] = btn | |
| # Add tooltip | |
| self.create_tooltip(btn, f"Switch to {version} version (or use Up/Down arrows)") | |
| def on_closing(self): | |
| """Cleanup when closing the window""" | |
| # Cleanup all tooltips | |
| self._cleanup_all_tooltips() | |
| self.root.destroy() | |
| def get_current_file_path(self): | |
| """Get the path of the currently displayed file""" | |
| if not self.logical_images or self.current_image_index >= len(self.logical_images): | |
| return None | |
| current_image = self.logical_images[self.current_image_index] | |
| # Find the file for the current version | |
| for version_info in current_image['versions']: | |
| if version_info['name'] == self.current_version: | |
| return version_info['path'] | |
| # If current version not found, return first version | |
| return current_image['versions'][0]['path'] | |
| def open_file_location(self, event=None): | |
| """Open file manager at the current file's location""" | |
| current_file = self.get_current_file_path() | |
| if not current_file: | |
| return | |
| folder = current_file.parent | |
| try: | |
| # Linux | |
| if sys.platform.startswith('linux'): | |
| subprocess.Popen(['xdg-open', str(folder)]) | |
| # macOS | |
| elif sys.platform == 'darwin': | |
| subprocess.Popen(['open', str(folder)]) | |
| # Windows | |
| elif sys.platform == 'win32': | |
| subprocess.Popen(['explorer', str(folder)]) | |
| self.show_status("Opened file location", 2000) | |
| except Exception as e: | |
| self.show_status(f"Error opening location: {e}", 3000, "red") | |
| def copy_path(self): | |
| """Copy the full path to clipboard""" | |
| current_file = self.get_current_file_path() | |
| if not current_file: | |
| return | |
| full_path = str(current_file.absolute()) | |
| self.root.clipboard_clear() | |
| self.root.clipboard_append(full_path) | |
| self.root.update() # Keep clipboard data after window closes | |
| self.show_status("✓ Path copied to clipboard!", 2000) | |
| def refresh_files(self): | |
| """Manually rescan folder for new .npy and .png files""" | |
| old_count = len(self.logical_images) | |
| old_base_name = None | |
| if self.logical_images and self.current_image_index < len(self.logical_images): | |
| old_base_name = self.logical_images[self.current_image_index]['base_name'] | |
| # Rescan for .npy and .png files | |
| self.npy_files = sorted(Path(self.folder_path).rglob("*.npy")) | |
| self.png_files = sorted(Path(self.folder_path).rglob("*.png")) | |
| self.all_files = sorted(list(self.npy_files) + list(self.png_files)) | |
| if len(self.npy_files) == 0: | |
| self.show_status("⚠ No .npy files found!", 3000, "red") | |
| return | |
| # Rebuild version groups | |
| self.build_version_groups() | |
| new_count = len(self.logical_images) | |
| # Try to maintain position on the same logical image | |
| if old_base_name: | |
| found = False | |
| for i, img in enumerate(self.logical_images): | |
| if img['base_name'] == old_base_name: | |
| self.current_image_index = i | |
| found = True | |
| break | |
| if not found: | |
| # Current image no longer exists, stay at same index or adjust | |
| self.current_image_index = min(self.current_image_index, new_count - 1) | |
| else: | |
| self.current_image_index = 0 | |
| # Ensure current version exists in new image | |
| if self.logical_images and self.current_image_index < len(self.logical_images): | |
| current_image = self.logical_images[self.current_image_index] | |
| version_exists = any(v['name'] == self.current_version for v in current_image['versions']) | |
| if not version_exists: | |
| self.current_version = current_image['versions'][0]['name'] | |
| # Show feedback | |
| if new_count > old_count: | |
| diff = new_count - old_count | |
| self.show_status(f"✓ Manual refresh: Found {diff} new image{'s' if diff > 1 else ''}!", 3000, "green") | |
| elif new_count < old_count: | |
| diff = old_count - new_count | |
| self.show_status(f"⚠ Manual refresh: {diff} image{'s' if diff > 1 else ''} removed", 3000, "orange") | |
| else: | |
| self.show_status("✓ Manual refresh: No changes", 2000) | |
| self.load_image() | |
| def change_folder(self): | |
| """Open folder selection dialog to change the working folder""" | |
| new_folder_path = filedialog.askdirectory( | |
| title="Select folder containing .npy files", | |
| initialdir=self.folder_path | |
| ) | |
| if not new_folder_path: | |
| # User cancelled the dialog | |
| return | |
| # Check if new folder has .npy or .png files | |
| new_npy_files = sorted(Path(new_folder_path).rglob("*.npy")) | |
| new_png_files = sorted(Path(new_folder_path).rglob("*.png")) | |
| new_all_files = sorted(list(new_npy_files) + list(new_png_files)) | |
| if not new_all_files: | |
| self.show_status("⚠ No .npy or .png files found in selected folder!", 3000, "red") | |
| return | |
| # Update folder path and file list | |
| self.folder_path = new_folder_path | |
| self.npy_files = new_npy_files | |
| self.png_files = new_png_files | |
| self.all_files = new_all_files | |
| # Rebuild version groups for new folder | |
| self.build_version_groups() | |
| # Reset to first image | |
| self.current_image_index = 0 | |
| if self.logical_images: | |
| self.current_version = self.logical_images[0]['versions'][0]['name'] | |
| # Update window title to show new folder | |
| folder_name = Path(new_folder_path).name or new_folder_path | |
| self.root.title(f"NPY Image Viewer - {folder_name}") | |
| self.show_status(f"✓ Changed to folder with {len(self.logical_images)} image{'s' if len(self.logical_images) != 1 else ''}", 3000, "green") | |
| self.load_image() | |
| def show_status(self, message, duration_ms, color="green"): | |
| """Show a temporary status message""" | |
| self.status_label.config(text=message, fg=color) | |
| self.root.after(duration_ms, lambda: self.status_label.config(text="")) | |
| def on_window_resize(self, event=None): | |
| """Handle window resize to make UI responsive""" | |
| if event and event.widget == self.root: | |
| window_width = self.root.winfo_width() | |
| window_height = self.root.winfo_height() | |
| if window_width < 100 or window_height < 100: | |
| return # Window not ready yet | |
| # Update filename label wraplength based on window width | |
| self.filename_label.config(wraplength=max(300, window_width - 100)) | |
| # Update info label wraplengths based on window width | |
| if hasattr(self, 'counter_label'): | |
| self.counter_label.config(wraplength=max(200, window_width // 3)) | |
| if hasattr(self, 'resolution_label'): | |
| self.resolution_label.config(wraplength=max(300, window_width // 2)) | |
| # Adjust font sizes based on window size | |
| self._adjust_font_sizes(window_width, window_height) | |
| # Debounce resize events - only resize image after user stops resizing | |
| if self.resize_job: | |
| self.root.after_cancel(self.resize_job) | |
| # Schedule resize after a short delay (150ms) | |
| self.resize_job = self.root.after(150, self._perform_resize) | |
| def _adjust_font_sizes(self, width, height): | |
| """Adjust font sizes based on window dimensions""" | |
| # Calculate scale factor (base on 900x700 window) | |
| width_scale = width / 900.0 | |
| height_scale = height / 700.0 | |
| scale = min(width_scale, height_scale, 1.2) # Cap at 1.2x | |
| # Adjust fonts proportionally | |
| self.filename_font_size = max(10, int(13 * scale)) | |
| self.button_font_size = max(9, int(11 * scale)) | |
| self.counter_font_size = max(10, int(12 * scale)) | |
| self.resolution_font_size = max(9, int(11 * scale)) | |
| self.version_font_size = max(9, int(11 * scale)) | |
| self.jump_font_size = max(9, int(11 * scale)) | |
| self.status_font_size = max(9, int(11 * scale)) | |
| # Update fonts | |
| self.filename_label.config(font=("Arial", self.filename_font_size, "bold")) | |
| self.counter_label.config(font=("Arial", self.counter_font_size, "bold")) | |
| self.resolution_label.config(font=("Arial", self.resolution_font_size)) | |
| self.version_label.config(font=("Arial", self.version_font_size, "bold")) | |
| self.status_label.config(font=("Arial", self.status_font_size)) | |
| self.jump_entry.config(font=("Arial", self.jump_font_size)) | |
| if hasattr(self, 'jump_label'): | |
| self.jump_label.config(font=("Arial", self.jump_font_size)) | |
| # Update button style | |
| style = ttk.Style() | |
| style.configure('Large.TButton', font=('Arial', self.button_font_size), padding=8) | |
| # Update version buttons | |
| for btn in self.version_buttons.values(): | |
| btn.config(font=("Arial", self.version_font_size)) | |
| def _perform_resize(self): | |
| """Actually perform the image resize after debounce delay""" | |
| if self.original_image is not None: | |
| self.resize_and_display_image() | |
| def load_image(self): | |
| if not self.logical_images: | |
| return | |
| if self.current_image_index >= len(self.logical_images): | |
| self.current_image_index = len(self.logical_images) - 1 | |
| current_image = self.logical_images[self.current_image_index] | |
| # Find the file for the current version | |
| current_file = None | |
| for version_info in current_image['versions']: | |
| if version_info['name'] == self.current_version: | |
| current_file = version_info['path'] | |
| break | |
| # If current version not found, use first available version | |
| if current_file is None: | |
| current_file = current_image['versions'][0]['path'] | |
| self.current_version = current_image['versions'][0]['name'] | |
| # Update filename label with just the filename (path shown in tooltip) | |
| filename_only = current_file.name | |
| parent_path = str(current_file.parent) | |
| display_text = f"{filename_only}\n📁 {parent_path}" | |
| self.filename_label.config(text=display_text) | |
| # Update counter - show logical image count | |
| self.counter_label.config( | |
| text=f"Image {self.current_image_index + 1} of {len(self.logical_images)}" | |
| ) | |
| try: | |
| # Load numpy array (from .npy or .png) | |
| if current_file.suffix.lower() == '.png': | |
| img_array = self.load_png_as_numpy(current_file) | |
| else: | |
| img_array = np.load(current_file) | |
| # Display resolution | |
| if len(img_array.shape) == 3: | |
| height, width, channels = img_array.shape | |
| self.resolution_label.config(text=f"📐 Resolution: {width}×{height}×{channels}") | |
| elif len(img_array.shape) == 2: | |
| height, width = img_array.shape | |
| self.resolution_label.config(text=f"📐 Resolution: {width}×{height}") | |
| else: | |
| self.resolution_label.config(text=f"📐 Shape: {img_array.shape}") | |
| # Normalize and convert to PIL Image (handles depth images properly) | |
| im = self.normalize_image_for_export(img_array, current_file.name) | |
| # Store original image for responsive resizing | |
| self.original_image = im.copy() | |
| # Resize image to fit window | |
| self.resize_and_display_image() | |
| except Exception as e: | |
| self.image_label.config( | |
| text=f"Error loading image: {e}", | |
| font=("Arial", 14), | |
| fg="red" | |
| ) | |
| self.resolution_label.config(text="") | |
| # Update button states | |
| self.prev_button.config(state=tk.NORMAL if self.current_image_index > 0 else tk.DISABLED) | |
| self.next_button.config(state=tk.NORMAL if self.current_image_index < len(self.logical_images) - 1 else tk.DISABLED) | |
| # Update version switcher UI | |
| self.update_version_ui() | |
| # Update mark button state | |
| self.update_mark_button_state() | |
| def resize_and_display_image(self): | |
| """Resize and display the stored image based on current window size""" | |
| if self.original_image is None: | |
| return | |
| try: | |
| # Get available space for image display | |
| window_width = self.root.winfo_width() | |
| window_height = self.root.winfo_height() | |
| # Account for padding and other UI elements (approximately) | |
| # Top frame: ~150px, buttons: ~100px, info: ~80px (2 rows), padding: ~60px | |
| available_width = max(400, window_width - 80) | |
| available_height = max(300, window_height - 350) # More space for info frame | |
| # Create a copy to resize | |
| im = self.original_image.copy() | |
| # Resize to fit available space while maintaining aspect ratio | |
| max_size = (available_width, available_height) | |
| im.thumbnail(max_size, Image.Resampling.LANCZOS) | |
| # Convert to PhotoImage for tkinter | |
| self.photo = ImageTk.PhotoImage(im) | |
| self.image_label.config(image=self.photo, text="") | |
| except Exception as e: | |
| # If resize fails, try to display original | |
| try: | |
| self.photo = ImageTk.PhotoImage(self.original_image) | |
| self.image_label.config(image=self.photo, text="") | |
| except: | |
| self.image_label.config( | |
| text=f"Error displaying image: {e}", | |
| font=("Arial", 12), | |
| fg="red" | |
| ) | |
| def show_previous(self): | |
| if self.current_image_index > 0: | |
| self.current_image_index -= 1 | |
| self.load_image() | |
| def show_next(self): | |
| if self.current_image_index < len(self.logical_images) - 1: | |
| self.current_image_index += 1 | |
| self.load_image() | |
| def jump_to_image(self): | |
| """Jump to a specific image by number""" | |
| try: | |
| jump_num = int(self.jump_entry.get()) | |
| if 1 <= jump_num <= len(self.logical_images): | |
| self.current_image_index = jump_num - 1 | |
| self.load_image() | |
| self.jump_entry.delete(0, tk.END) | |
| self.show_status(f"✓ Jumped to image {jump_num}", 2000) | |
| else: | |
| self.show_status(f"⚠ Please enter a number between 1 and {len(self.logical_images)}", 3000, "red") | |
| except ValueError: | |
| self.show_status("⚠ Please enter a valid number", 2000, "red") | |
| def load_png_as_numpy(self, png_path): | |
| """Load PNG file and convert to numpy array, preserving data types | |
| Restores original depth values from metadata if available (lossless restoration)""" | |
| try: | |
| img = Image.open(png_path) | |
| arr = np.array(img) | |
| # Preserve data types based on PNG bit depth | |
| if img.mode == 'I;16': # 16-bit grayscale | |
| arr = arr.astype(np.uint16) | |
| # Check if this is a scaled depth image with restoration metadata | |
| # Metadata can be in img.info (when loaded) or img.text (PNG text chunks) | |
| metadata_source = img.text if hasattr(img, 'text') and img.text else img.info | |
| if 'npy_scaled' in metadata_source and metadata_source.get('npy_scaled') == 'true': | |
| # Restore original depth range from metadata | |
| try: | |
| original_min = int(metadata_source.get('npy_original_min', 0)) | |
| original_max = int(metadata_source.get('npy_original_max', 65535)) | |
| if original_max > original_min: | |
| # Restore original values by reversing the scaling | |
| # Use INTEGER arithmetic only for perfect lossless conversion | |
| # scaled = ((original - min) * 65534 + range/2) // range + 1 | |
| # original = ((scaled - 1) * range + 32767) // 65534 + min | |
| restored = arr.astype(np.uint32).copy() # Use uint32 for intermediate calculations | |
| # Calculate range | |
| range_size = original_max - original_min | |
| # Restore non-zero values (scaled values are in range 1-65535) | |
| non_zero_mask = arr > 0 | |
| # Reverse the integer scaling with rounding | |
| restored[non_zero_mask] = (((arr[non_zero_mask].astype(np.uint32) - 1) * range_size + 32767) // | |
| 65534 + original_min).astype(np.uint16) | |
| # Preserve original zeros (where arr == 0) | |
| restored[arr == 0] = 0 | |
| restored = restored.astype(np.uint16) | |
| return restored | |
| except (ValueError, KeyError): | |
| # If metadata is corrupted, return as-is | |
| pass | |
| return arr | |
| elif img.mode == 'L': # 8-bit grayscale | |
| return arr.astype(np.uint8) | |
| elif img.mode == 'RGB': | |
| # Check bit depth - PIL loads as uint8 by default, but we preserve what we can | |
| # If it was originally 16-bit, it's already been converted to 8-bit by PIL | |
| return arr.astype(np.uint8) | |
| else: | |
| # Default to uint8 | |
| return arr.astype(np.uint8) | |
| except Exception as e: | |
| raise Exception(f"Error loading PNG: {e}") | |
| def toggle_mark_current_file(self): | |
| """Mark or unmark the current file for export""" | |
| current_file = self.get_current_file_path() | |
| if not current_file: | |
| return | |
| file_key = str(current_file.absolute()) | |
| if file_key in self.marked_files: | |
| self.marked_files.remove(file_key) | |
| self.show_status("✓ File unmarked", 1500) | |
| else: | |
| self.marked_files.add(file_key) | |
| self.show_status("✓ File marked", 1500) | |
| self.update_mark_button_state() | |
| self.update_marked_count() | |
| def update_mark_button_state(self): | |
| """Update the mark button appearance based on whether current file is marked""" | |
| current_file = self.get_current_file_path() | |
| if not current_file: | |
| self.mark_button.config(text="✓ Mark File", state=tk.DISABLED) | |
| return | |
| file_key = str(current_file.absolute()) | |
| is_marked = file_key in self.marked_files | |
| if is_marked: | |
| self.mark_button.config(text="✗ Unmark File", state=tk.NORMAL) | |
| else: | |
| self.mark_button.config(text="✓ Mark File", state=tk.NORMAL) | |
| def update_marked_count(self): | |
| """Update the marked files counter label""" | |
| count = len(self.marked_files) | |
| if count > 0: | |
| self.marked_count_label.config(text=f"📌 {count} file(s) marked", fg="blue") | |
| self.export_marked_button.config(state=tk.NORMAL) | |
| else: | |
| self.marked_count_label.config(text="", fg="blue") | |
| self.export_marked_button.config(state=tk.DISABLED) | |
| def export_marked_files(self): | |
| """Export all marked files as .npy files""" | |
| if not self.marked_files: | |
| self.show_status("⚠ No files marked!", 2000, "orange") | |
| return | |
| # Ask for output directory | |
| output_dir = filedialog.askdirectory( | |
| title="Select output directory for .npy exports", | |
| initialdir=self.folder_path | |
| ) | |
| if not output_dir: | |
| return | |
| # Create a progress window | |
| progress_window = tk.Toplevel(self.root) | |
| progress_window.title("Exporting Marked Files") | |
| progress_window.geometry("500x150") | |
| progress_window.transient(self.root) | |
| progress_window.grab_set() | |
| progress_label = tk.Label( | |
| progress_window, | |
| text="Starting export...", | |
| font=("Arial", 12), | |
| pady=20 | |
| ) | |
| progress_label.pack() | |
| progress_bar = ttk.Progressbar( | |
| progress_window, | |
| length=400, | |
| mode='determinate', | |
| maximum=len(self.marked_files) | |
| ) | |
| progress_bar.pack(pady=10) | |
| status_label = tk.Label( | |
| progress_window, | |
| text="", | |
| font=("Arial", 10), | |
| fg="gray" | |
| ) | |
| status_label.pack() | |
| progress_window.update() | |
| # Convert folder_path to Path for easier manipulation | |
| folder_path_obj = Path(self.folder_path) | |
| output_path = Path(output_dir) | |
| exported_count = 0 | |
| failed_files = [] | |
| for idx, file_key in enumerate(self.marked_files): | |
| try: | |
| source_file = Path(file_key) | |
| # Update progress | |
| progress_label.config(text=f"Exporting {idx + 1} of {len(self.marked_files)}...") | |
| status_label.config(text=f"File: {source_file.name}") | |
| progress_bar['value'] = idx + 1 | |
| progress_window.update() | |
| # Load the file (PNG or NPY) | |
| if source_file.suffix.lower() == '.png': | |
| img_array = self.load_png_as_numpy(source_file) | |
| else: | |
| img_array = np.load(source_file) | |
| # Get relative path from original folder to preserve structure | |
| try: | |
| relative_path = source_file.relative_to(folder_path_obj) | |
| except ValueError: | |
| # If file is not relative to folder_path, use just the filename | |
| relative_path = Path(source_file.name) | |
| # Create output path preserving folder structure, change extension to .npy | |
| output_filepath = output_path / relative_path.parent / (source_file.stem + ".npy") | |
| # Create parent directories if they don't exist | |
| output_filepath.parent.mkdir(parents=True, exist_ok=True) | |
| # Save as .npy | |
| np.save(output_filepath, img_array) | |
| exported_count += 1 | |
| except Exception as e: | |
| failed_files.append((Path(file_key).name, str(e))) | |
| # Close progress window | |
| progress_window.destroy() | |
| total_marked = len(self.marked_files) | |
| # Clear marked files after successful export | |
| if exported_count > 0: | |
| self.marked_files.clear() | |
| self.update_marked_count() | |
| self.update_mark_button_state() | |
| # Show summary | |
| if failed_files: | |
| summary_msg = f"✓ Exported {exported_count}/{total_marked} files\n⚠ {len(failed_files)} failed" | |
| self.show_status(summary_msg, 5000, "orange") | |
| print(f"\n{summary_msg}") | |
| for filename, error in failed_files: | |
| print(f" Failed: {filename} - {error}") | |
| else: | |
| summary_msg = f"✓ Successfully exported all {exported_count} marked file(s) as .npy!" | |
| self.show_status(summary_msg, 4000, "green") | |
| print(summary_msg) | |
| def is_png_valid(self, png_path): | |
| """Check if a PNG file is valid and not corrupted""" | |
| try: | |
| if not png_path.exists() or png_path.stat().st_size == 0: | |
| return False | |
| # Try to open and fully load the PNG to ensure it's not corrupted | |
| # verify() checks structure, but we also need to ensure data is readable | |
| with Image.open(png_path) as img: | |
| img.verify() # Verify file structure | |
| # Reopen to actually load the image data (verify() closes the file) | |
| with Image.open(png_path) as img: | |
| img.load() # Force loading of image data to catch corruption | |
| return True | |
| except Exception: | |
| return False | |
| def scan_existing_exports(self, output_path, folder_path_obj, progress_callback=None): | |
| """Scan existing exported PNG files and return a set of valid exported files | |
| Args: | |
| output_path: Path to the output directory | |
| folder_path_obj: Path to the source folder (for reference) | |
| progress_callback: Optional callback function(current, total, filename) for progress updates | |
| """ | |
| existing_exports = set() | |
| broken_files = [] | |
| if not output_path.exists(): | |
| return existing_exports, broken_files | |
| # First, collect all PNG files to know the total count | |
| all_png_files = list(output_path.rglob("*.png")) | |
| total_files = len(all_png_files) | |
| # Find all PNG files in the output directory | |
| for idx, png_file in enumerate(all_png_files): | |
| # Update progress if callback provided | |
| if progress_callback: | |
| progress_callback(idx + 1, total_files, png_file.name) | |
| # Check if PNG is valid | |
| if self.is_png_valid(png_file): | |
| # Store the relative path from output_path | |
| try: | |
| relative_png = png_file.relative_to(output_path) | |
| existing_exports.add(relative_png) | |
| except ValueError: | |
| pass | |
| else: | |
| # File exists but is broken | |
| broken_files.append(png_file) | |
| return existing_exports, broken_files | |
| def get_expected_output_path(self, npy_file, output_path, folder_path_obj): | |
| """Get the expected output PNG path for a given .npy file""" | |
| try: | |
| relative_path = npy_file.relative_to(folder_path_obj) | |
| except ValueError: | |
| relative_path = Path(npy_file.name) | |
| return output_path / relative_path.parent / (npy_file.stem + ".png") | |
| def normalize_image_for_export(self, img_array, filename): | |
| """Convert image array to PIL Image while preserving data quality (lossless) | |
| Returns PIL Image with original data type preserved where possible""" | |
| # Convert BGR to RGB if it's a 3-channel image | |
| if len(img_array.shape) == 3 and img_array.shape[2] == 3: | |
| img_array = img_array[:, :, ::-1] | |
| # Handle different data types - preserve original quality | |
| try: | |
| if len(img_array.shape) == 3: | |
| # Multi-channel image (RGB, etc.) | |
| if img_array.dtype == np.uint8: | |
| # 8-bit RGB - lossless | |
| return Image.fromarray(img_array, mode='RGB') | |
| elif img_array.dtype == np.uint16: | |
| # 16-bit RGB - Note: PIL's PNG plugin may convert to 8-bit | |
| # For true 16-bit RGB PNG, consider using imageio library | |
| # For now, preserving as uint16 (may be converted on save) | |
| return Image.fromarray(img_array, mode='RGB') | |
| elif img_array.dtype == np.uint32: | |
| # 32-bit -> convert to 16-bit to preserve more precision than 8-bit | |
| # Scale to 16-bit range (0-65535) | |
| max_val = np.iinfo(np.uint32).max | |
| img_array = (img_array / (max_val / 65535.0)).astype(np.uint16) | |
| return Image.fromarray(img_array, mode='RGB') | |
| elif img_array.dtype in [np.float32, np.float64]: | |
| # Float -> convert to 16-bit to preserve precision | |
| # Normalize to 0-1, then scale to 16-bit | |
| img_min = np.min(img_array) | |
| img_max = np.max(img_array) | |
| if img_max > img_min: | |
| normalized = (img_array - img_min) / (img_max - img_min) | |
| else: | |
| normalized = np.zeros_like(img_array) | |
| # Scale to 16-bit range | |
| img_array = (normalized * 65535.0).astype(np.uint16) | |
| return Image.fromarray(img_array, mode='RGB') | |
| else: | |
| # Unknown type - try direct conversion | |
| return Image.fromarray(img_array.astype(np.uint8), mode='RGB') | |
| else: | |
| # Single channel image (grayscale or depth) | |
| if img_array.dtype == np.uint8: | |
| # 8-bit grayscale - lossless | |
| return Image.fromarray(img_array, mode='L') | |
| elif img_array.dtype == np.uint16: | |
| # 16-bit grayscale - scale to full range for better display, but preserve original values | |
| # Check if this is a depth image by filename | |
| is_depth = 'depth' in filename.lower() | |
| if is_depth: | |
| # For depth images, scale to full 16-bit range for better visibility | |
| # Store original min/max in image info for lossless restoration | |
| valid_mask = (img_array > 0) & np.isfinite(img_array.astype(np.float32)) | |
| if np.any(valid_mask): | |
| original_min = int(np.min(img_array[valid_mask])) | |
| original_max = int(np.max(img_array[valid_mask])) | |
| if original_max > original_min: | |
| # Scale to full 16-bit range (1-65535) for better visibility | |
| # Use INTEGER arithmetic only for perfect lossless conversion | |
| # Create a copy to work with | |
| scaled = img_array.astype(np.uint32).copy() # Use uint32 for intermediate calculations | |
| # Calculate range | |
| range_size = original_max - original_min | |
| # Only scale valid (non-zero) values using integer arithmetic | |
| # Scale: ((value - min) * 65534 + range_size/2) // range_size + 1 | |
| # This uses integer division with rounding (adding half before dividing) | |
| # This ensures perfect reversibility | |
| scaled[valid_mask] = (((scaled[valid_mask].astype(np.uint32) - original_min) * 65534 + | |
| (range_size // 2)) // range_size + 1).astype(np.uint16) | |
| # Preserve original zeros (don't scale them) | |
| # Zeros stay as 0, valid values are now in range 1-65535 | |
| scaled[img_array == 0] = 0 | |
| scaled = scaled.astype(np.uint16) | |
| # Create image and store metadata for restoration | |
| img = Image.fromarray(scaled, mode='I;16') | |
| # Store original range in PNG text metadata for lossless restoration | |
| # Store in info dict - will be converted to PngInfo when saving | |
| img.info['npy_original_min'] = str(original_min) | |
| img.info['npy_original_max'] = str(original_max) | |
| img.info['npy_scaled'] = 'true' | |
| return img | |
| else: | |
| # All values are the same | |
| return Image.fromarray(img_array, mode='I;16') | |
| else: | |
| # No valid values | |
| return Image.fromarray(img_array, mode='I;16') | |
| else: | |
| # Regular 16-bit grayscale - lossless (no scaling) | |
| return Image.fromarray(img_array, mode='I;16') | |
| elif img_array.dtype == np.uint32: | |
| # 32-bit -> convert to 16-bit to preserve more precision than 8-bit | |
| max_val = np.iinfo(np.uint32).max | |
| img_array = (img_array / (max_val / 65535.0)).astype(np.uint16) | |
| return Image.fromarray(img_array, mode='I;16') | |
| elif img_array.dtype in [np.float32, np.float64]: | |
| # Float -> convert to 16-bit to preserve precision | |
| # Handle invalid values (NaN, Inf) | |
| valid_mask = np.isfinite(img_array) | |
| if np.any(valid_mask): | |
| img_min = np.min(img_array[valid_mask]) | |
| img_max = np.max(img_array[valid_mask]) | |
| if img_max > img_min: | |
| normalized = (img_array - img_min) / (img_max - img_min) | |
| normalized = np.clip(normalized, 0, 1) | |
| else: | |
| normalized = np.zeros_like(img_array) | |
| # Set invalid pixels to 0 | |
| normalized[~valid_mask] = 0 | |
| # Scale to 16-bit range | |
| img_array = (normalized * 65535.0).astype(np.uint16) | |
| else: | |
| # No valid values | |
| img_array = np.zeros_like(img_array, dtype=np.uint16) | |
| return Image.fromarray(img_array, mode='I;16') | |
| else: | |
| # Unknown type - try direct conversion to uint8 | |
| return Image.fromarray(img_array.astype(np.uint8), mode='L') | |
| except Exception as e: | |
| # Fallback: convert to uint8 if anything fails | |
| try: | |
| if len(img_array.shape) == 3: | |
| return Image.fromarray(img_array.astype(np.uint8), mode='RGB') | |
| else: | |
| return Image.fromarray(img_array.astype(np.uint8), mode='L') | |
| except: | |
| raise e | |
| def export_folder(self): | |
| """Export all .npy files in current folder as PNG images""" | |
| # Ask for output directory | |
| output_dir = filedialog.askdirectory( | |
| title="Select output directory for PNG exports", | |
| initialdir=self.folder_path | |
| ) | |
| if not output_dir: | |
| return | |
| # Ask for subfolder name | |
| subfolder_name = simpledialog.askstring( | |
| "Subfolder Name", | |
| "Enter name for the export subfolder:", | |
| initialvalue="png_exports" | |
| ) | |
| if not subfolder_name: | |
| return | |
| # Create the output subfolder | |
| output_path = Path(output_dir) / subfolder_name | |
| try: | |
| output_path.mkdir(parents=True, exist_ok=True) | |
| except Exception as e: | |
| self.show_status(f"⚠ Error creating folder: {e}", 3000, "red") | |
| return | |
| # Convert folder_path to Path for easier manipulation | |
| folder_path_obj = Path(self.folder_path) | |
| # Scan existing exports | |
| progress_window = tk.Toplevel(self.root) | |
| progress_window.title("Scanning Exports") | |
| progress_window.geometry("500x150") | |
| progress_window.transient(self.root) | |
| progress_window.grab_set() | |
| scan_label = tk.Label( | |
| progress_window, | |
| text="Scanning existing exported files...", | |
| font=("Arial", 12), | |
| pady=10 | |
| ) | |
| scan_label.pack() | |
| scan_progress_bar = ttk.Progressbar( | |
| progress_window, | |
| length=400, | |
| mode='determinate', | |
| maximum=100 # Will be updated when we know the total | |
| ) | |
| scan_progress_bar.pack(pady=10) | |
| scan_status_label = tk.Label( | |
| progress_window, | |
| text="", | |
| font=("Arial", 10), | |
| fg="gray" | |
| ) | |
| scan_status_label.pack() | |
| # Force window to appear | |
| progress_window.update() | |
| # Progress callback for scanning | |
| def update_scan_progress(current, total, filename): | |
| if total > 0: | |
| scan_progress_bar['maximum'] = total | |
| scan_progress_bar['value'] = current | |
| scan_status_label.config(text=f"Checking: {filename}") | |
| else: | |
| scan_status_label.config(text="No existing files found") | |
| progress_window.update() | |
| existing_exports, broken_files = self.scan_existing_exports( | |
| output_path, folder_path_obj, progress_callback=update_scan_progress | |
| ) | |
| # Delete broken files | |
| for broken_file in broken_files: | |
| try: | |
| broken_file.unlink() | |
| except Exception as e: | |
| print(f"Warning: Could not delete broken file {broken_file}: {e}") | |
| # Close scanning window | |
| progress_window.destroy() | |
| # Calculate which files need to be exported | |
| files_to_export = [] | |
| skipped_count = 0 | |
| for npy_file in self.npy_files: | |
| expected_output = self.get_expected_output_path(npy_file, output_path, folder_path_obj) | |
| expected_relative = expected_output.relative_to(output_path) | |
| if expected_relative in existing_exports: | |
| skipped_count += 1 | |
| else: | |
| files_to_export.append(npy_file) | |
| # Show info about what will be done | |
| if skipped_count > 0 or broken_files: | |
| info_msg = f"Found {skipped_count} already exported files" | |
| if broken_files: | |
| info_msg += f", removed {len(broken_files)} broken file(s)" | |
| info_msg += f". Will export {len(files_to_export)} remaining files." | |
| self.show_status(info_msg, 4000, "blue") | |
| print(info_msg) | |
| if not files_to_export: | |
| self.show_status(f"✓ All {len(self.npy_files)} files already exported!", 3000, "green") | |
| return | |
| # Create a progress window | |
| progress_window = tk.Toplevel(self.root) | |
| progress_window.title("Exporting Images") | |
| progress_window.geometry("500x150") | |
| progress_window.transient(self.root) | |
| progress_window.grab_set() | |
| progress_label = tk.Label( | |
| progress_window, | |
| text="Starting export...", | |
| font=("Arial", 12), | |
| pady=20 | |
| ) | |
| progress_label.pack() | |
| progress_bar = ttk.Progressbar( | |
| progress_window, | |
| length=400, | |
| mode='determinate', | |
| maximum=len(files_to_export) | |
| ) | |
| progress_bar.pack(pady=10) | |
| status_label = tk.Label( | |
| progress_window, | |
| text="", | |
| font=("Arial", 10), | |
| fg="gray" | |
| ) | |
| status_label.pack() | |
| # Force window to appear | |
| progress_window.update() | |
| # Export each file | |
| exported_count = skipped_count # Start with already exported count | |
| failed_files = [] | |
| for idx, npy_file in enumerate(files_to_export): | |
| try: | |
| # Update progress | |
| progress_label.config(text=f"Exporting {idx + 1} of {len(files_to_export)}...") | |
| status_label.config(text=f"File: {npy_file.name}") | |
| progress_bar['value'] = idx + 1 | |
| progress_window.update() | |
| # Load numpy array | |
| img_array = np.load(npy_file) | |
| # Normalize and convert to PIL Image (handles depth images properly) | |
| im = self.normalize_image_for_export(img_array, npy_file.name) | |
| # Get expected output path | |
| output_filepath = self.get_expected_output_path(npy_file, output_path, folder_path_obj) | |
| # Create parent directories if they don't exist | |
| output_filepath.parent.mkdir(parents=True, exist_ok=True) | |
| # Save as PNG with metadata if present | |
| pnginfo = None | |
| if hasattr(im, 'info') and any(key.startswith('npy_') for key in im.info.keys()): | |
| # Create PngInfo object to preserve metadata | |
| pnginfo = PngImagePlugin.PngInfo() | |
| for key, value in im.info.items(): | |
| if key.startswith('npy_'): | |
| pnginfo.add_text(key, str(value)) | |
| if pnginfo: | |
| im.save(output_filepath, "PNG", pnginfo=pnginfo) | |
| else: | |
| im.save(output_filepath, "PNG") | |
| exported_count += 1 | |
| except Exception as e: | |
| failed_files.append((npy_file.name, str(e))) | |
| # Close progress window | |
| progress_window.destroy() | |
| # Show summary | |
| if failed_files: | |
| summary_msg = f"✓ Exported {exported_count}/{len(self.npy_files)} images" | |
| if skipped_count > 0: | |
| summary_msg += f" ({skipped_count} skipped, {len(files_to_export) - len(failed_files)} new)" | |
| summary_msg += f"\n⚠ {len(failed_files)} failed" | |
| self.show_status(summary_msg, 5000, "orange") | |
| print(f"\n{summary_msg}") | |
| for filename, error in failed_files: | |
| print(f" Failed: {filename} - {error}") | |
| else: | |
| summary_msg = f"✓ Successfully exported {exported_count}/{len(self.npy_files)} images to PNG!" | |
| if skipped_count > 0: | |
| summary_msg += f" ({skipped_count} were already exported, {len(files_to_export)} new)" | |
| self.show_status(summary_msg, 4000, "green") | |
| print(summary_msg) | |
| if __name__ == "__main__": | |
| try: | |
| if len(sys.argv) > 2: | |
| cmd = sys.argv[0] if len(sys.argv) >= 1 else "npy_viewer.py" | |
| print(f"Usage: python3 {cmd} [folder_path]") | |
| sys.exit(1) | |
| # Get folder path from command line or None (will prompt dialog) | |
| folder_path = None | |
| if len(sys.argv) >= 2: | |
| folder_path = sys.argv[1] | |
| if not Path(folder_path).exists(): | |
| print(f"Error: Folder '{folder_path}' does not exist") | |
| sys.exit(2) | |
| root = tk.Tk() | |
| viewer = ImageViewer(root, folder_path) | |
| root.mainloop() | |
| except KeyboardInterrupt: | |
| print("Keyboard interrupt detected. Exiting...") | |
| sys.exit(0) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Script to combine PNG images from folders into MP4 videos. | |
| Creates separate videos for each image type (pose, color, depth) if mixed in folders. | |
| Organizes videos into type-specific folders. | |
| """ | |
| import os | |
| from pathlib import Path | |
| import cv2 | |
| import re | |
| from collections import defaultdict | |
| # ============================================================================ | |
| # CONFIGURATION - Easy to change framerate here | |
| # ============================================================================ | |
| FRAMERATE = 30 # Change this value to adjust video framerate (e.g., 24, 30, 60) | |
| # ============================================================================ | |
| # Paths | |
| # ============================================================================ | |
| SOURCE_FOLDER = r"C:\Users\kezzyhko\Downloads\B5G dataset images from humanoid robot\png_exports" | |
| OUTPUT_FOLDER = r"C:\Users\kezzyhko\Downloads\B5G dataset images from humanoid robot\videos" | |
| # Image type prefixes to detect and separate | |
| IMAGE_TYPES = ['pose', 'color', 'depth'] | |
| def sanitize_filename(path_str): | |
| """Convert a path to a valid filename by replacing backslashes with dashes and spaces with underscores.""" | |
| # Replace backslashes with dashes | |
| filename = path_str.replace('\\', '-') | |
| # Replace colons with dashes (Windows paths have C:) | |
| filename = filename.replace(':', '-') | |
| # Replace spaces with underscores | |
| filename = filename.replace(' ', '_') | |
| # Remove any double dashes | |
| filename = re.sub(r'-+', '-', filename) | |
| # Remove any double underscores | |
| filename = re.sub(r'_+', '_', filename) | |
| # Strip leading/trailing dashes and underscores | |
| filename = filename.strip('-_') | |
| return filename | |
| def get_image_type(filename): | |
| """Determine the image type based on filename prefix.""" | |
| filename_lower = filename.lower() | |
| for img_type in IMAGE_TYPES: | |
| if filename_lower.startswith(img_type + '_'): | |
| return img_type | |
| return None | |
| def group_images_by_type(folder_path): | |
| """Group image files by their type prefix (pose, color, depth).""" | |
| image_extensions = {'.png', '.jpg', '.jpeg', '.bmp', '.tiff', '.tif'} | |
| grouped = defaultdict(list) | |
| untyped = [] | |
| for file_path in Path(folder_path).iterdir(): | |
| if file_path.is_file() and file_path.suffix.lower() in image_extensions: | |
| img_type = get_image_type(file_path.name) | |
| if img_type: | |
| grouped[img_type].append(file_path) | |
| else: | |
| untyped.append(file_path) | |
| # Sort each group | |
| for img_type in grouped: | |
| grouped[img_type] = sorted(grouped[img_type]) | |
| untyped = sorted(untyped) | |
| return grouped, untyped | |
| def create_video_from_images(image_files, output_path, framerate=30): | |
| """Create a video from a list of image files.""" | |
| if not image_files: | |
| return False | |
| print(f" Processing {len(image_files)} images...") | |
| # Read first image to get dimensions | |
| first_image = cv2.imread(str(image_files[0])) | |
| if first_image is None: | |
| print(f" Error: Could not read first image {image_files[0]}") | |
| return False | |
| height, width, channels = first_image.shape | |
| # Define codec and create VideoWriter | |
| fourcc = cv2.VideoWriter_fourcc(*'mp4v') | |
| video_writer = cv2.VideoWriter(str(output_path), fourcc, framerate, (width, height)) | |
| if not video_writer.isOpened(): | |
| print(f" Error: Could not open video writer for {output_path}") | |
| return False | |
| # Write all images to video | |
| for i, image_path in enumerate(image_files): | |
| img = cv2.imread(str(image_path)) | |
| if img is None: | |
| print(f" Warning: Could not read image {image_path}, skipping...") | |
| continue | |
| # Resize image if dimensions don't match (shouldn't happen, but just in case) | |
| if img.shape[:2] != (height, width): | |
| img = cv2.resize(img, (width, height)) | |
| video_writer.write(img) | |
| if (i + 1) % 100 == 0: | |
| print(f" Processed {i + 1}/{len(image_files)} images...") | |
| video_writer.release() | |
| print(f" ✓ Created video: {output_path}") | |
| return True | |
| def main(): | |
| """Main function to process all folders and create videos.""" | |
| source_path = Path(SOURCE_FOLDER) | |
| output_path = Path(OUTPUT_FOLDER) | |
| # Create output folder if it doesn't exist | |
| output_path.mkdir(parents=True, exist_ok=True) | |
| if not source_path.exists(): | |
| print(f"Error: Source folder does not exist: {SOURCE_FOLDER}") | |
| return | |
| print(f"Source folder: {SOURCE_FOLDER}") | |
| print(f"Output folder: {OUTPUT_FOLDER}") | |
| print(f"Framerate: {FRAMERATE} fps") | |
| print("=" * 80) | |
| # Find all folders containing images | |
| folders_to_process = [] | |
| # Walk through the source folder | |
| for root, dirs, files in os.walk(source_path): | |
| # Check if this folder has any image files | |
| image_files = [f for f in files if Path(f).suffix.lower() in {'.png', '.jpg', '.jpeg', '.bmp', '.tiff', '.tif'}] | |
| if image_files: | |
| folders_to_process.append(Path(root)) | |
| if not folders_to_process: | |
| print("No folders with images found!") | |
| return | |
| print(f"Found {len(folders_to_process)} folders with images to process\n") | |
| # Process each folder | |
| successful = 0 | |
| failed = 0 | |
| for folder_path in folders_to_process: | |
| # Get relative path from source folder | |
| try: | |
| relative_path = folder_path.relative_to(source_path) | |
| except ValueError: | |
| # If folder_path is not relative to source_path, use the full path | |
| relative_path = folder_path | |
| # Group images by type | |
| grouped_images, untyped_images = group_images_by_type(folder_path) | |
| # Create base filename (without type prefix) | |
| path_str = str(relative_path) | |
| base_filename = sanitize_filename(path_str) | |
| print(f"\nProcessing: {relative_path}") | |
| # If we have typed images, create separate videos for each type | |
| # Only create videos for types that actually have images in this folder | |
| if grouped_images: | |
| for img_type, image_files in sorted(grouped_images.items()): | |
| # Only process if this type has images | |
| if not image_files: | |
| continue | |
| # Create output filename with type prefix: type-base_filename.mp4 | |
| output_file = output_path / f"{img_type}-{base_filename}.mp4" | |
| # Skip if video already exists | |
| if output_file.exists(): | |
| print(f" Skipping {img_type} video (already exists): {output_file.name}") | |
| successful += 1 | |
| continue | |
| print(f" Creating {img_type} video: {output_file.name}") | |
| if create_video_from_images(image_files, output_file, FRAMERATE): | |
| successful += 1 | |
| else: | |
| failed += 1 | |
| # If we have untyped images, create a video in the root output folder | |
| if untyped_images: | |
| output_file = output_path / f"{base_filename}.mp4" | |
| # Skip if video already exists | |
| if output_file.exists(): | |
| print(f" Skipping untyped video (already exists): {output_file.name}") | |
| successful += 1 | |
| else: | |
| print(f" Creating video for untyped images: {output_file.name}") | |
| if create_video_from_images(untyped_images, output_file, FRAMERATE): | |
| successful += 1 | |
| else: | |
| failed += 1 | |
| # If no images were found (shouldn't happen, but just in case) | |
| if not grouped_images and not untyped_images: | |
| print(f" No images found in {folder_path}") | |
| failed += 1 | |
| print("\n" + "=" * 80) | |
| print(f"Processing complete!") | |
| print(f" Successful: {successful}") | |
| print(f" Failed: {failed}") | |
| print(f" Total folders processed: {len(folders_to_process)}") | |
| if __name__ == "__main__": | |
| main() | |
Author
kezzyhko
commented
Dec 3, 2025
Author
Fully vibe-coded for one specific purpose. Might not work great in other cases
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment