#!/usr/bin/env python3 # -*- coding: utf-8 -*- # File: scripts/python/tile_panels.py # ---------------------------------------- from __future__ import annotations import os __FILE__ = "./scripts/python/tile_panels.py" __DIR__ = os.path.dirname(__FILE__) # ---------------------------------------- """ Functionalities: - Auto-detects panel images using naming convention - Creates tiled figures with automatic layout - Adds panel labels (A, B, C, D) - Integrates with SciTeX figure processing Dependencies: - packages: - PIL (Pillow) - numpy IO: - input-files: - .XX_name_A.jpg, .XX_name_B.jpg, etc. - output-files: - .XX_name.jpg (tiled composite) """ import argparse import math import sys from pathlib import Path from PIL import Image, ImageDraw, ImageFont def detect_panels(figure_base, search_dir): """Detect panel files following naming convention.""" panels = {} search_path = Path(search_dir) # Extract figure ID from base (e.g., "01" from "01_demographic_data") figure_id = figure_base.split("_")[0] # XX_name -> XX # Look for panel files: 01a_name.jpg, 01b_name.jpg, etc. (new naming convention) for panel_file in search_path.glob(f"{figure_id}[a-zA-Z]_*.jpg"): # Extract panel letter from filename (e.g., "a" from "01a_name.jpg") filename = panel_file.stem # Find the position where the figure ID ends and panel letter begins id_part = f"{figure_id}" panel_letter = filename[ len(id_part) ].upper() # Get the letter and convert to uppercase panels[panel_letter] = str(panel_file) return dict(sorted(panels.items())) # Sort by panel letter def calculate_layout(num_panels): """Calculate optimal grid layout for given number of panels.""" if num_panels == 1: return (1, 1) elif num_panels == 2: return (1, 2) # 1 row, 2 cols elif num_panels == 3: return (1, 3) # 1 row, 3 cols elif num_panels == 4: return (2, 2) # 2 rows, 2 cols elif num_panels <= 6: return (2, 3) # 2 rows, 3 cols elif num_panels <= 9: return (3, 3) # 3 rows, 3 cols else: # For larger numbers, try to make it roughly square cols = math.ceil(math.sqrt(num_panels)) rows = math.ceil(num_panels / cols) return (rows, cols) def add_panel_label(image, label, position="top-left", font_size=72): """Add prominent panel label for presentations.""" draw = ImageDraw.Draw(image) # Try to get a larger font, fall back to default font = None try: # Try system fonts in order of preference font_paths = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", "/System/Library/Fonts/Arial.ttf", "/Windows/Fonts/arial.ttf", ] for font_path in font_paths: try: font = ImageFont.truetype(font_path, font_size) break except: continue except: pass # If no TTF font found, use default if font is None: font = ImageFont.load_default() # Make labels VERY prominent - large white box with thick border margin = 30 x, y = margin, margin # Very large white box with black text - unmissable for presentations padding = 20 box_size = 120 # Create elegant transparent label - no background box needed # Just use text with strong outline for visibility # Center the text in the box with large font text_x = x + (box_size - padding) // 3 text_y = y + (box_size - padding) // 3 # Create elegant text with strong white outline for visibility on any background outline_width = 4 # Draw white outline for offset_x in range(-outline_width, outline_width + 1): for offset_y in range(-outline_width, outline_width + 1): if offset_x != 0 or offset_y != 0: # Don't draw on center draw.text( (text_x + offset_x, text_y + offset_y), label, fill="white", font=font, ) # Draw black text on top for maximum contrast draw.text((text_x, text_y), label, fill="black", font=font) # print(f"Added BOLD label '{label}' at position ({text_x}, {text_y}) with box size {box_size}") return image def add_panel_label_to_composite( composite_image, label, panel_x, panel_y, font_size=200 ): """Add panel label directly to the composite tiled image.""" draw = ImageDraw.Draw(composite_image) # Use a proper TrueType font - prefer serif for scientific papers font = None font_paths = [ "/usr/share/fonts/liberation-serif/LiberationSerif-Bold.ttf", # Serif for papers "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf", # Fallback sans "/usr/share/fonts/liberation-sans/LiberationSans-Bold.ttf", ] for font_path in font_paths: try: font = ImageFont.truetype(font_path, font_size) # print(f"Using font: {font_path}") break except: continue if font is None: # print("Warning: No TrueType font found, using default") font = ImageFont.load_default() # Position label in top-left corner of this panel margin = 80 text_x = panel_x + margin text_y = panel_y + margin # Draw the label using the proper font # First draw a white outline for contrast outline_width = 5 for offset_x in range(-outline_width, outline_width + 1): for offset_y in range(-outline_width, outline_width + 1): if offset_x != 0 or offset_y != 0: draw.text( (text_x + offset_x, text_y + offset_y), label, fill="white", font=font, ) # Then draw black text on top draw.text((text_x, text_y), label, fill="black", font=font) # print(f"Added label '{label}' to composite at ({text_x}, {text_y}) with font size {font_size}") def tile_images(panels, output_path, spacing=20, dpi=300): """Create tiled image from panel dictionary.""" if not panels: # print("No panels found for tiling") return False # Load all panel images (without labels yet) images = {} for label, path in panels.items(): try: img = Image.open(path) images[label] = img # print(f"Loaded panel {label}: {img.size}") except Exception: # print(f"Error loading panel {label} from {path}: {e}") return False if not images: # print("No valid images loaded") return False # Calculate layout num_panels = len(images) rows, cols = calculate_layout(num_panels) # print(f"Using {rows}x{cols} layout for {num_panels} panels") # Force all panels to exactly the same dimensions for perfect consistency # Use dimensions from panels A, B, C (which are the same) as the standard standard_panels = [img for label, img in images.items() if label in ["A", "B", "C"]] if standard_panels: target_width = standard_panels[0].width # Use A, B, C dimensions target_height = standard_panels[0].height else: # Fallback to reasonable dimensions target_width = 1889 target_height = 1200 # print(f"Forcing all panels to exact same size: {target_width}×{target_height}") # Resize ALL panels (including A, B, C) to ensure perfect consistency for label in images: images[label].size images[label] = images[label].resize( (target_width, target_height), Image.Resampling.LANCZOS ) # print(f"Panel {label}: {original_size} → {target_width}×{target_height}") # Calculate final image dimensions total_width = cols * target_width + (cols - 1) * spacing total_height = rows * target_height + (rows - 1) * spacing # Create composite image composite = Image.new("RGB", (total_width, total_height), "white") # Place images in grid and add labels AFTER tiling panel_labels = sorted(images.keys()) for i, label in enumerate(panel_labels): if i >= rows * cols: break # Skip extra panels if any row = i // cols col = i % cols x = col * (target_width + spacing) y = row * (target_height + spacing) composite.paste(images[label], (x, y)) # print(f"Placed panel {label} at position ({row}, {col})") # Now add the label to the composite image at the correct position add_panel_label_to_composite(composite, label, x, y) # print(f"Added label {label} to tiled image at position ({x}, {y})") # Save with high quality composite.save(output_path, "JPEG", quality=95, dpi=(dpi, dpi)) # print(f"Tiled figure saved: {output_path}") # print(f"Final dimensions: {composite.size}") return True def main(): parser = argparse.ArgumentParser( description="Tile figure panels with automatic layout" ) parser.add_argument( "--figure-base", required=True, help="Base figure name (e.g., .01_workflow)", ) parser.add_argument( "--search-dir", required=True, help="Directory to search for panel files", ) parser.add_argument("--output", required=True, help="Output tiled image path") parser.add_argument( "--spacing", type=int, default=20, help="Spacing between panels in pixels", ) parser.add_argument("--dpi", type=int, default=300, help="Output DPI") args = parser.parse_args() # Detect panels panels = detect_panels(args.figure_base, args.search_dir) if not panels: # print(f"No panels found for {args.figure_base} in {args.search_dir}") # print("Looking for files like: {}_A.jpg, {}_B.jpg, etc.".format(args.figure_base, args.figure_base)) return 1 # print(f"Found {len(panels)} panels: {list(panels.keys())}") # Create tiled image if tile_images(panels, args.output, args.spacing, args.dpi): return 0 else: return 1 if __name__ == "__main__": sys.exit(main()) # EOF