- 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
- :
- (
- "Usage: python assemble_figures.py
- )
- (
- " 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
- :
- (
- 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