#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Timestamp: "2025-09-28 17:52:54 (ywatanabe)"
# File: /ssh:sp:/home/ywatanabe/proj/neurovista/paper/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