Scientific Figure Assembly (R-based)
Create publication-ready multi-panel figures using R packages (patchwork, cowplot) with professional panel labels (A, B, C, D) at 300 DPI resolution.
⚠️ IMPORTANT: This workflow uses R for figure assembly. For meta-analysis projects, all figures should be generated and assembled in R.
When to Use
-
Combining multiple plots into a single multi-panel figure for publication
-
Adding panel labels (A, B, C) to existing figures
-
Ensuring figures meet journal requirements (300 DPI minimum)
-
Creating consistent figure layouts for manuscripts
-
Preparing figures for Nature, Science, Cell, JAMA, Lancet submissions
Quick Start
Tell me:
-
Plot objects: R plot objects (ggplot, forest plots, etc.) OR paths to PNG/JPG files
-
Layout: Vertical (stacked), horizontal (side-by-side), or grid (2x2, 2x3, etc.)
-
Output name: What to call the final figure
-
Labels: Which panel labels to use (default: A, B, C, D...)
I'll create an R script using patchwork or cowplot to assemble the figure with proper spacing and labels.
R Package Approach (Recommended)
Method 1: patchwork (For ggplot2 objects)
The simplest and most powerful method for combining ggplot2 objects:
library(ggplot2) library(patchwork)
Create or load individual plots
p1 <- ggplot(data1, aes(x, y)) + geom_point() + ggtitle("A. First Panel") p2 <- ggplot(data2, aes(x, y)) + geom_line() + ggtitle("B. Second Panel") p3 <- ggplot(data3, aes(x, y)) + geom_bar(stat="identity") + ggtitle("C. Third Panel")
Combine vertically
combined <- p1 / p2 / p3
Or combine horizontally
combined <- p1 | p2 | p3
Or grid layout (2 columns)
combined <- (p1 | p2) / p3
Export at 300 DPI
ggsave("figures/figure1_combined.png", plot = combined, width = 10, height = 12, dpi = 300)
Method 2: cowplot (For any R plots)
More flexible, works with base R plots and ggplot2:
library(ggplot2) library(cowplot)
Create individual plots
p1 <- ggplot(data1, aes(x, y)) + geom_point() p2 <- ggplot(data2, aes(x, y)) + geom_line() p3 <- ggplot(data3, aes(x, y)) + geom_bar(stat="identity")
Combine with automatic panel labels
combined <- plot_grid( p1, p2, p3, labels = c("A", "B", "C"), label_size = 18, ncol = 1, # Vertical stack rel_heights = c(1, 1, 1) # Equal heights )
Export
ggsave("figures/figure1_combined.png", plot = combined, width = 10, height = 12, dpi = 300)
Legacy Python Script Template (Not Recommended)
⚠️ For meta-analysis projects, use R methods above instead.
If you absolutely need Python for existing PNG files:
#!/usr/bin/env python3 """Legacy: Assemble multi-panel scientific figure from PNG files."""
from PIL import Image, ImageDraw, ImageFont from pathlib import Path
def add_panel_label(img, label, position='top-left', font_size=80, offset=(40, 40), bg_color='white', text_color='black', border=True): """ Add panel label (A, B, C) to image.
Args:
img: PIL Image object
label: Label text (e.g., 'A', 'B', 'C')
position: 'top-left', 'top-right', 'bottom-left', 'bottom-right'
font_size: Font size in pixels (80 works well for 3000px wide images)
offset: (x, y) offset from corner in pixels
bg_color: Background color for label box
text_color: Label text color
border: Whether to draw border around label box
"""
draw = ImageDraw.Draw(img)
# Try system fonts (macOS, then Linux)
try:
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size)
except:
try:
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size
)
except:
font = ImageFont.load_default()
print(f"Warning: Using default font for label {label}")
# Calculate label position
x, y = offset
if 'right' in position:
bbox = draw.textbbox((0, 0), label, font=font)
text_width = bbox[2] - bbox[0]
x = img.width - text_width - offset[0]
if 'bottom' in position:
bbox = draw.textbbox((0, 0), label, font=font)
text_height = bbox[3] - bbox[1]
y = img.height - text_height - offset[1]
# Draw background box
bbox = draw.textbbox((x, y), label, font=font)
padding = 10
draw.rectangle(
[bbox[0] - padding, bbox[1] - padding,
bbox[2] + padding, bbox[3] + padding],
fill=bg_color,
outline='black' if border else None,
width=2 if border else 0
)
# Draw text
draw.text((x, y), label, fill=text_color, font=font)
return img
def assemble_vertical(input_files, output_file, labels=None, spacing=40, dpi=300): """ Stack images vertically with panel labels.
Args:
input_files: List of paths to input images
output_file: Path for output image
labels: List of labels (default: A, B, C, ...)
spacing: Vertical spacing between panels in pixels
dpi: Output resolution
"""
if labels is None:
labels = [chr(65 + i) for i in range(len(input_files))] # A, B, C, ...
# Load all images
images = [Image.open(f) for f in input_files]
# Add labels
labeled = [add_panel_label(img, label)
for img, label in zip(images, labels)]
# Calculate dimensions
max_width = max(img.width for img in labeled)
total_height = sum(img.height for img in labeled) + spacing * (len(labeled) - 1)
# Create combined image
combined = Image.new('RGB', (max_width, total_height), 'white')
# Paste images
y_offset = 0
for img in labeled:
combined.paste(img, (0, y_offset))
y_offset += img.height + spacing
# Save with specified DPI
combined.save(output_file, dpi=(dpi, dpi))
print(f"✅ Created {output_file}")
print(f" Dimensions: {combined.width}×{combined.height} px at {dpi} DPI")
return output_file
def assemble_horizontal(input_files, output_file, labels=None, spacing=40, dpi=300): """Stack images horizontally with panel labels.""" if labels is None: labels = [chr(65 + i) for i in range(len(input_files))]
images = [Image.open(f) for f in input_files]
labeled = [add_panel_label(img, label)
for img, label in zip(images, labels)]
max_height = max(img.height for img in labeled)
total_width = sum(img.width for img in labeled) + spacing * (len(labeled) - 1)
combined = Image.new('RGB', (total_width, max_height), 'white')
x_offset = 0
for img in labeled:
combined.paste(img, (x_offset, 0))
x_offset += img.width + spacing
combined.save(output_file, dpi=(dpi, dpi))
print(f"✅ Created {output_file}")
print(f" Dimensions: {combined.width}×{combined.height} px at {dpi} DPI")
return output_file
def assemble_grid(input_files, output_file, rows, cols, labels=None, spacing=40, dpi=300): """ Arrange images in a grid with panel labels.
Args:
rows: Number of rows
cols: Number of columns
Other args same as assemble_vertical
"""
if labels is None:
labels = [chr(65 + i) for i in range(len(input_files))]
images = [Image.open(f) for f in input_files]
labeled = [add_panel_label(img, label)
for img, label in zip(images, labels)]
# Calculate cell dimensions (use max from each row/col)
cell_width = max(img.width for img in labeled)
cell_height = max(img.height for img in labeled)
# Total dimensions
total_width = cell_width * cols + spacing * (cols - 1)
total_height = cell_height * rows + spacing * (rows - 1)
combined = Image.new('RGB', (total_width, total_height), 'white')
# Place images
for idx, img in enumerate(labeled):
if idx >= rows * cols:
break
row = idx // cols
col = idx % cols
x = col * (cell_width + spacing)
y = row * (cell_height + spacing)
combined.paste(img, (x, y))
combined.save(output_file, dpi=(dpi, dpi))
print(f"✅ Created {output_file}")
print(f" Dimensions: {combined.width}×{combined.height} px at {dpi} DPI")
return output_file
if name == 'main': import sys
# Example usage
if len(sys.argv) < 3:
print("Usage: python assemble_figures.py <output> <layout> <input1> <input2> ...")
print(" layout: vertical, horizontal, or grid:RxC (e.g., grid:2x2)")
sys.exit(1)
output = sys.argv[1]
layout = sys.argv[2]
inputs = sys.argv[3:]
if layout == 'vertical':
assemble_vertical(inputs, output)
elif layout == 'horizontal':
assemble_horizontal(inputs, output)
elif layout.startswith('grid:'):
rows, cols = map(int, layout.split(':')[1].split('x'))
assemble_grid(inputs, output, rows, cols)
else:
print(f"Unknown layout: {layout}")
sys.exit(1)
Common Layouts
Vertical (Most Common)
Stack plots on top of each other - good for showing progression or related outcomes.
Example: Three forest plots (pCR, EFS, OS) stacked vertically
-
Panel A: pCR forest plot
-
Panel B: EFS forest plot
-
Panel C: OS forest plot
Horizontal
Place plots side-by-side - good for comparisons.
Example: Two funnel plots showing publication bias
-
Panel A: pCR funnel plot
-
Panel B: EFS funnel plot
Grid (2x2, 2x3, etc.)
Arrange in rows and columns - good for systematic comparisons.
Example: 2x2 grid of subgroup analyses
-
Panel A: Age subgroup
-
Panel B: Sex subgroup
-
Panel C: Stage subgroup
-
Panel D: Histology subgroup
R Workflow (Recommended)
Complete Example: Meta-Analysis Forest Plots
#!/usr/bin/env Rscript
assemble_forest_plots.R
Combine multiple forest plots into a single figure
library(meta) library(metafor) library(patchwork)
Set working directory
setwd("/Users/htlin/meta-pipe/06_analysis")
Load extraction data
data <- read.csv("../05_extraction/extraction.csv")
--- Create individual forest plots ---
Plot 1: Pathologic complete response
res_pcr <- metabin( event.e = events_pcr_ici, n.e = total_ici, event.c = events_pcr_control, n.c = total_control, data = data, studlab = study_id, sm = "RR", method = "MH" )
Save as ggplot-compatible object
p1 <- forest(res_pcr, layout = "RevMan5") + ggtitle("A. Pathologic Complete Response")
Plot 2: Event-free survival
res_efs <- metagen( TE = log_hr_efs, seTE = se_log_hr_efs, data = data, studlab = study_id, sm = "HR" )
p2 <- forest(res_efs) + ggtitle("B. Event-Free Survival")
Plot 3: Overall survival
res_os <- metagen( TE = log_hr_os, seTE = se_log_hr_os, data = data, studlab = study_id, sm = "HR" )
p3 <- forest(res_os) + ggtitle("C. Overall Survival")
--- Combine with patchwork ---
combined <- p1 / p2 / p3 + plot_annotation( title = "Figure 1. Efficacy Outcomes with ICI vs Control", theme = theme(plot.title = element_text(size = 16, face = "bold")) )
Export at 300 DPI
ggsave("../07_manuscript/figures/figure1_efficacy.png", plot = combined, width = 10, height = 14, dpi = 300, bg = "white")
cat("✅ Created figure1_efficacy.png\n") cat(" Dimensions: 3000×4200 px at 300 DPI\n")
Using cowplot for More Control
library(cowplot)
Combine with explicit panel labels and alignment
combined <- plot_grid( p1, p2, p3, labels = c("A", "B", "C"), label_size = 18, label_fontface = "bold", ncol = 1, align = "v", # Vertical alignment axis = "l", # Align left axis rel_heights = c(1, 1, 1) )
Add overall title
title <- ggdraw() + draw_label( "Figure 1. Efficacy Outcomes with ICI vs Control", fontface = "bold", size = 16, x = 0.5, hjust = 0.5 )
Combine title and plots
final <- plot_grid( title, combined, ncol = 1, rel_heights = c(0.1, 1) )
Export
ggsave("../07_manuscript/figures/figure1_efficacy.png", plot = final, width = 10, height = 14, dpi = 300, bg = "white")
Grid Layout (2x2 or 2x3)
library(patchwork)
2x2 grid
combined <- (p1 | p2) / (p3 | p4) + plot_annotation(tag_levels = "A")
2x3 grid
combined <- (p1 | p2 | p3) / (p4 | p5 | p6) + plot_annotation(tag_levels = "A")
ggsave("figure_grid.png", width = 14, height = 10, dpi = 300)
Python Workflow (Legacy - For PNG Files Only)
⚠️ Only use if you have existing PNG files and cannot regenerate in R.
Step 1: Verify Input Files
Check that all files exist and are PNG/JPG
ls -lh path/to/plots/*.png
Step 2: Create Assembly Script
Use the Python template provided in this skill.
Step 3: Run Assembly
Using uv (recommended for dependency management)
uv run python assemble_figures.py Figure1_Efficacy.png vertical
forest_plot_pCR.png
forest_plot_EFS.png
forest_plot_OS.png
Or with system Python (requires PIL/Pillow)
python assemble_figures.py Figure1.png grid:2x2
plot1.png plot2.png plot3.png plot4.png
Step 4: Verify Output
Check dimensions and file size
ls -lh Figure1_Efficacy.png
Verify DPI (should show 300x300)
file Figure1_Efficacy.png
Customization Options
Font Size Adjustment
For different image sizes:
-
3000px wide images: font_size=80 (default)
-
1500px wide images: font_size=40
-
6000px wide images: font_size=160
Label Position
-
position='top-left' (default)
-
position='top-right'
-
position='bottom-left'
-
position='bottom-right'
Spacing Between Panels
-
Default: spacing=40 pixels
-
Tight spacing: spacing=20
-
Loose spacing: spacing=80
Label Style
-
White background with black border (default, best visibility)
-
Transparent background: bg_color=None, border=False
-
Custom colors: bg_color='#f0f0f0', text_color='#333333'
Journal Requirements
Nature, Science, Cell
-
Resolution: 300-600 DPI
-
Format: TIFF or high-quality PDF preferred, PNG acceptable
-
Width: 89mm (single column) or 183mm (double column) at final size
-
Font: Arial, Helvetica, or similar sans-serif
-
Labels: Bold, 8-10pt at final size
Lancet, JAMA, NEJM
-
Resolution: 300 DPI minimum
-
Format: TIFF, EPS, or PNG
-
Width: Fit within column width (typically 3-4 inches)
-
Labels: Clear, high contrast
-
Grayscale: Must be readable in B&W
Quality Checklist
Before submitting:
-
All figures at 300 DPI minimum
-
Panel labels (A, B, C) visible and correctly ordered
-
Labels don't obscure important data
-
All panels aligned properly
-
Spacing consistent between panels
-
File size reasonable (<10 MB for PNG)
-
Figures readable when printed at final journal size
-
Color schemes work in grayscale (if required)
Common Issues & Solutions
Problem: Labels too small Solution: Increase font_size parameter (try doubling it)
Problem: Labels obscure data Solution: Change position to different corner or adjust offset
Problem: DPI too low Solution: Regenerate input plots at higher resolution first, then reassemble
Problem: Uneven spacing Solution: Crop input images to remove excess white space before assembly
Problem: File too large Solution: Use PNG compression or convert to JPEG (may lose quality)
Example Use Cases
Meta-Analysis Figures (R)
Figure 1: Efficacy outcomes (3 vertical panels)
library(patchwork)
combined <- p_pcr / p_efs / p_os + plot_annotation( title = "Figure 1. Efficacy Outcomes", tag_levels = "A" )
ggsave("07_manuscript/figures/figure1_efficacy.png", width = 10, height = 14, dpi = 300)
Figure 2: Safety + Bias (2 vertical panels)
combined <- p_safety / p_funnel + plot_annotation(tag_levels = "A")
ggsave("07_manuscript/figures/figure2_safety.png", width = 10, height = 10, dpi = 300)
Figure 3: Subgroup analysis (2x2 grid)
combined <- (p_age | p_sex) / (p_stage | p_histology) + plot_annotation( title = "Figure 3. Subgroup Analyses", tag_levels = "A" )
ggsave("07_manuscript/figures/figure3_subgroups.png", width = 14, height = 12, dpi = 300)
Legacy Python Examples (Not Recommended)
Figure 1: Efficacy outcomes (3 vertical panels)
uv run python assemble.py Figure1_Efficacy.png vertical
forest_plot_pCR.png
forest_plot_EFS.png
forest_plot_OS.png
Figure 2: Safety + Bias (2 vertical panels)
uv run python assemble.py Figure2_Safety.png vertical
forest_plot_SAE.png
funnel_plot_pCR.png
Dependencies
R Packages (Recommended)
Install from CRAN
install.packages(c("patchwork", "cowplot", "ggplot2"))
For meta-analysis plots
install.packages(c("meta", "metafor"))
Python (Legacy - Only for PNG Assembly)
Install using uv (if needed for legacy workflows)
uv add Pillow
Or using pip
pip install Pillow
Output Example
R Output
✅ Created figure1_efficacy.png Dimensions: 3000×4200 px at 300 DPI Size: 2.3 MB
The output file will have:
-
Professional panel labels (A, B, C) automatically added by patchwork/cowplot
-
Consistent spacing between panels
-
300 DPI resolution suitable for publication
-
Aligned axes for easy comparison
-
Publication-ready theme
Python Output (Legacy)
✅ Created Figure1_Efficacy.png Dimensions: 3000×6080 px at 300 DPI
The output file will have:
-
Professional panel labels (A, B, C) in top-left corners
-
Consistent spacing between panels
-
300 DPI resolution suitable for publication
-
White background with black border around labels for maximum visibility
Related Skills
-
/meta-manuscript-assembly
-
Complete meta-analysis manuscript preparation
-
/plot-publication
-
Create individual publication-ready plots
-
/figure-legends
-
Generate comprehensive figure legends
Pro Tips
R Workflow Tips
-
Work in R throughout: Generate plots AND assemble in R for best results
-
Use patchwork for ggplot2: Simplest syntax (p1 / p2 for vertical)
-
Use cowplot for mixed plots: Works with base R and ggplot2
-
Set theme globally: Use theme_set(theme_minimal()) for consistency
-
Export once at end: Create all plots, combine, then export (faster)
-
Check alignment: Use align = "v" and axis = "l" in cowplot
-
Consistent sizes: Set base_size in theme for readable text
General Tips
-
Generate high-quality inputs first: Assembly won't improve low-quality source plots
-
Label systematically: A-B-C top-to-bottom or left-to-right
-
Check in print preview: Ensure labels readable at final print size
-
Keep R scripts: Save R code for reproducibility
-
Version control figures: Commit both R scripts and final PNG files
-
Test on different screens: Check readability on laptop and printed page
R Package Resources
When you need help with R packages:
-
CRAN: https://cran.r-project.org/ (patchwork, cowplot documentation)
-
Tidyverse: https://www.tidyverse.org/ (ggplot2 reference)
-
R-universe: https://r-universe.dev/search/ (search all R packages)
-
patchwork guide: https://patchwork.data-imaginist.com/
-
cowplot guide: https://wilkelab.org/cowplot/