feat: create intitial repository
This commit is contained in:
+120
@@ -0,0 +1,120 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
|
||||
# IDE specific files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS specific files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Pixstri ComfyUI Comics
|
||||
|
||||
Pixstri is a custom plugin for [ComfyUI](https://github.com/comfyanonymous/ComfyUI) designed to generate comic pages. It provides a hierarchical node system that allows you to create comic layouts with rows and frames, making it easy to design and preview comic pages within your ComfyUI workflows.
|
||||
|
||||
## Features
|
||||
|
||||
- **Hierarchical Comic Layout System**:
|
||||
- **Page Node**: The top-level container for your comic page
|
||||
- Accepts multiple Row nodes as input
|
||||
- Provides image preview of the entire page layout
|
||||
- **Row Node**: Organizes frames horizontally in a row
|
||||
- Accepts multiple Frame nodes as input
|
||||
- Provides image preview of the row layout
|
||||
- **Frame Node**: Individual comic panels
|
||||
- Accepts images as input
|
||||
- Provides image preview of the frame
|
||||
|
||||
## Installation
|
||||
|
||||
### Option 1: Install via ComfyUI Manager (Recommended)
|
||||
|
||||
If you have [ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager) installed:
|
||||
|
||||
1. Open ComfyUI
|
||||
2. Go to the "Manager" tab
|
||||
3. Search for "Pixstri"
|
||||
4. Click "Install"
|
||||
|
||||
### Option 2: Manual Installation
|
||||
|
||||
1. Clone this repository into your ComfyUI custom_nodes directory:
|
||||
```bash
|
||||
cd /path/to/ComfyUI/custom_nodes
|
||||
git clone https://github.com/yourusername/pixstri.git
|
||||
```
|
||||
|
||||
2. Restart ComfyUI
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating a Comic Page
|
||||
|
||||
1. **Start with Frame Nodes**:
|
||||
- Add Frame nodes to your workflow
|
||||
- Connect images to each Frame node
|
||||
- Adjust frame properties as needed
|
||||
|
||||
2. **Create Rows with Row Nodes**:
|
||||
- Add Row nodes to your workflow
|
||||
- Connect Frame nodes to each Row node
|
||||
- Arrange frames horizontally within each row
|
||||
|
||||
3. **Assemble the Page**:
|
||||
- Add a Page node to your workflow
|
||||
- Connect Row nodes to the Page node
|
||||
- The complete comic page will be output
|
||||
|
||||
Each node provides an image preview of its current state, allowing you to see how individual frames, rows, and the final page will look.
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Comic Page Layout
|
||||
|
||||
)
|
||||
|
||||
## Requirements
|
||||
|
||||
- ComfyUI
|
||||
- PyTorch
|
||||
- NumPy
|
||||
- Pillow (PIL)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
import io
|
||||
|
||||
class FrameNode:
|
||||
"""
|
||||
FrameNode: The basic building block for a comic panel
|
||||
|
||||
This node processes an individual comic frame and handles
|
||||
its border, padding, and other style settings.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""
|
||||
Define the input types for the frame node.
|
||||
"""
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"border_width": ("INT", {
|
||||
"default": 2,
|
||||
"min": 0,
|
||||
"max": 20,
|
||||
"step": 1,
|
||||
"display": "slider"
|
||||
}),
|
||||
"padding": ("INT", {
|
||||
"default": 5,
|
||||
"min": 0,
|
||||
"max": 50,
|
||||
"step": 1,
|
||||
"display": "slider"
|
||||
}),
|
||||
"border_color": (["black", "white"],),
|
||||
},
|
||||
"optional": {
|
||||
"caption": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": ""
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "FRAME_DATA")
|
||||
RETURN_NAMES = ("preview", "frame_data")
|
||||
FUNCTION = "process_frame"
|
||||
CATEGORY = "Pixstri/Comics"
|
||||
|
||||
def process_frame(self, image, border_width, padding, border_color, caption=None):
|
||||
"""
|
||||
Process a comic frame with borders and padding.
|
||||
|
||||
Parameters:
|
||||
-----------
|
||||
image : torch.Tensor
|
||||
The input image tensor
|
||||
border_width : int
|
||||
Width of the frame border in pixels
|
||||
padding : int
|
||||
Internal padding between the border and the image
|
||||
border_color : str
|
||||
Color of the frame border ("black" or "white")
|
||||
caption : str, optional
|
||||
Text caption for the frame
|
||||
|
||||
Returns:
|
||||
--------
|
||||
tuple(torch.Tensor, dict)
|
||||
The processed image with frame and the frame data
|
||||
"""
|
||||
# Convert tensor to PIL image for processing
|
||||
# Take the first batch item if there are multiple
|
||||
if len(image.shape) == 4:
|
||||
pil_image = self._tensor_to_pil(image[0])
|
||||
else:
|
||||
pil_image = self._tensor_to_pil(image)
|
||||
|
||||
# Get the dimensions
|
||||
width, height = pil_image.size
|
||||
|
||||
# Create a new image with border and padding
|
||||
total_border = border_width + padding
|
||||
new_width = width + (total_border * 2)
|
||||
new_height = height + (total_border * 2)
|
||||
|
||||
# Create a new image with the border color
|
||||
if border_color == "black":
|
||||
frame_image = Image.new("RGB", (new_width, new_height), (0, 0, 0))
|
||||
else:
|
||||
frame_image = Image.new("RGB", (new_width, new_height), (255, 255, 255))
|
||||
|
||||
# If padding > 0, create an inner padding area
|
||||
if padding > 0:
|
||||
inner_width = width + (padding * 2)
|
||||
inner_height = height + (padding * 2)
|
||||
inner_x = border_width
|
||||
inner_y = border_width
|
||||
|
||||
# Create inner padding area (white or light gray)
|
||||
if border_color == "black":
|
||||
inner_color = (240, 240, 240) # Light gray for black borders
|
||||
else:
|
||||
inner_color = (240, 240, 240) # Light gray for white borders too
|
||||
|
||||
# Draw the inner padding area
|
||||
draw = ImageDraw.Draw(frame_image)
|
||||
draw.rectangle(
|
||||
[(inner_x, inner_y),
|
||||
(inner_x + inner_width - 1, inner_y + inner_height - 1)],
|
||||
fill=inner_color
|
||||
)
|
||||
|
||||
# Paste the original image onto the frame
|
||||
frame_image.paste(pil_image, (total_border, total_border))
|
||||
|
||||
# Add caption if provided
|
||||
if caption and caption.strip():
|
||||
draw = ImageDraw.Draw(frame_image)
|
||||
# Simple caption at the bottom
|
||||
# In a real implementation, you'd want more sophisticated text rendering
|
||||
caption_y = new_height - border_width - 15 # Position above the border
|
||||
draw.text((total_border, caption_y), caption.strip(), fill=(0, 0, 0))
|
||||
|
||||
# Convert back to tensor
|
||||
result_tensor = self._pil_to_tensor(frame_image)
|
||||
|
||||
# Create frame data dictionary
|
||||
frame_data = {
|
||||
"width": new_width,
|
||||
"height": new_height,
|
||||
"image": result_tensor,
|
||||
"border_width": border_width,
|
||||
"padding": padding,
|
||||
"caption": caption if caption else ""
|
||||
}
|
||||
|
||||
return (result_tensor, frame_data)
|
||||
|
||||
def _tensor_to_pil(self, tensor):
|
||||
"""Convert a PyTorch tensor to a PIL Image."""
|
||||
# Handle different tensor formats
|
||||
if not torch.is_tensor(tensor):
|
||||
return tensor # Already a PIL image or something else
|
||||
|
||||
# Clone tensor to avoid modifying the original
|
||||
tensor = tensor.clone()
|
||||
|
||||
# Standardize tensor format - handle different shapes
|
||||
if len(tensor.shape) == 4: # BCHW or BHWC or B111C
|
||||
if tensor.shape[1] == 3: # BCHW format
|
||||
tensor = tensor[0].permute(1, 2, 0) # Convert to HWC
|
||||
elif tensor.shape[3] == 3: # BHWC format
|
||||
tensor = tensor[0] # Just remove batch dim
|
||||
elif tensor.shape[1] == 1 and tensor.shape[2] == 1: # B111C format?
|
||||
tensor = tensor[0, 0] # Extract the image
|
||||
elif len(tensor.shape) == 3: # CHW or HWC
|
||||
if tensor.shape[0] == 3: # CHW format
|
||||
tensor = tensor.permute(1, 2, 0) # Convert to HWC
|
||||
|
||||
# Convert to numpy with proper scaling
|
||||
if tensor.dtype == torch.uint8:
|
||||
img_np = tensor.cpu().numpy()
|
||||
else:
|
||||
img_np = (tensor.cpu().numpy() * 255).astype(np.uint8)
|
||||
|
||||
# Create PIL image - handle grayscale vs RGB
|
||||
if img_np.shape[-1] == 1:
|
||||
img_pil = Image.fromarray(img_np[:, :, 0], 'L')
|
||||
else:
|
||||
img_pil = Image.fromarray(img_np)
|
||||
|
||||
return img_pil
|
||||
|
||||
def _pil_to_tensor(self, pil_image):
|
||||
"""Convert a PIL Image to a PyTorch tensor in BCHW format"""
|
||||
# Convert PIL to numpy array
|
||||
img_np = np.array(pil_image)
|
||||
|
||||
# Keep as uint8 for better compatibility
|
||||
if img_np.dtype != np.uint8:
|
||||
img_np = (img_np * 255).astype(np.uint8)
|
||||
|
||||
# Convert to tensor and keep as uint8
|
||||
img_tensor = torch.from_numpy(img_np)
|
||||
|
||||
# Return in BCHW format
|
||||
if len(img_tensor.shape) == 2: # Grayscale
|
||||
return img_tensor.unsqueeze(0).unsqueeze(0)
|
||||
else: # RGB
|
||||
# Move channels to correct position and add batch dimension
|
||||
return img_tensor.permute(2, 0, 1).unsqueeze(0)
|
||||
|
||||
|
||||
class RowNode:
|
||||
"""
|
||||
RowNode: Combines multiple frames horizontally into a row
|
||||
|
||||
This node organizes multiple comic frames into a horizontal row,
|
||||
with configurable spacing and alignment.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""
|
||||
Define the input types for the row node.
|
||||
"""
|
||||
return {
|
||||
"required": {
|
||||
"spacing": ("INT", {
|
||||
"default": 10,
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"step": 1,
|
||||
"display": "slider"
|
||||
}),
|
||||
"alignment": (["top", "center", "bottom"],),
|
||||
},
|
||||
"optional": {
|
||||
"frame1": ("FRAME_DATA",),
|
||||
"frame2": ("FRAME_DATA",),
|
||||
"frame3": ("FRAME_DATA",),
|
||||
"frame4": ("FRAME_DATA",),
|
||||
"frame5": ("FRAME_DATA",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "ROW_DATA")
|
||||
RETURN_NAMES = ("preview", "row_data")
|
||||
FUNCTION = "combine_frames"
|
||||
CATEGORY = "Pixstri/Comics"
|
||||
|
||||
def combine_frames(self, spacing, alignment, frame1=None, frame2=None, frame3=None, frame4=None, frame5=None):
|
||||
"""
|
||||
Combine multiple frames into a horizontal row.
|
||||
|
||||
Parameters:
|
||||
-----------
|
||||
spacing : int
|
||||
Spacing between frames in pixels
|
||||
alignment : str
|
||||
Vertical alignment of frames ("top", "center", "bottom")
|
||||
frame1-frame5 : dict, optional
|
||||
Frame data dictionaries from FrameNode
|
||||
|
||||
Returns:
|
||||
--------
|
||||
tuple(torch.Tensor, dict)
|
||||
The combined row image and the row data
|
||||
"""
|
||||
# Collect all provided frames (filter out None values)
|
||||
frames = [f for f in [frame1, frame2, frame3, frame4, frame5] if f is not None]
|
||||
|
||||
if not frames:
|
||||
# Return empty image and data if no frames
|
||||
empty_img = Image.new("RGB", (400, 200), (240, 240, 240))
|
||||
draw = ImageDraw.Draw(empty_img)
|
||||
draw.text((150, 90), "No frames provided", fill=(0, 0, 0))
|
||||
empty_tensor = self._pil_to_tensor(empty_img)
|
||||
return (empty_tensor, {"width": 400, "height": 200, "frames": []})
|
||||
|
||||
# Calculate the total width and maximum height
|
||||
total_width = sum(frame["width"] for frame in frames) + spacing * (len(frames) - 1)
|
||||
max_height = max(frame["height"] for frame in frames)
|
||||
|
||||
# Create a new image for the row
|
||||
row_image = Image.new("RGB", (total_width, max_height), (255, 255, 255))
|
||||
|
||||
# Position to place the next frame
|
||||
x_offset = 0
|
||||
|
||||
# Track frame positions for row data
|
||||
frame_positions = []
|
||||
|
||||
# Place each frame on the row
|
||||
for frame in frames:
|
||||
frame_tensor = frame["image"]
|
||||
frame_pil = self._tensor_to_pil(frame_tensor)
|
||||
frame_width = frame["width"]
|
||||
frame_height = frame["height"]
|
||||
|
||||
# Calculate Y position based on alignment
|
||||
if alignment == "top":
|
||||
y_offset = 0
|
||||
elif alignment == "bottom":
|
||||
y_offset = max_height - frame_height
|
||||
else: # center
|
||||
y_offset = (max_height - frame_height) // 2
|
||||
|
||||
# Paste the frame onto the row
|
||||
row_image.paste(frame_pil, (x_offset, y_offset))
|
||||
|
||||
# Record the frame position
|
||||
frame_positions.append({
|
||||
"x": x_offset,
|
||||
"y": y_offset,
|
||||
"width": frame_width,
|
||||
"height": frame_height
|
||||
})
|
||||
|
||||
# Update the x offset
|
||||
x_offset += frame_width + spacing
|
||||
|
||||
# Convert to tensor for preview
|
||||
result_tensor = self._pil_to_tensor(row_image)
|
||||
|
||||
# Create row data dictionary
|
||||
row_data = {
|
||||
"width": total_width,
|
||||
"height": max_height,
|
||||
"image": result_tensor,
|
||||
"frames": frame_positions,
|
||||
"spacing": spacing,
|
||||
"alignment": alignment
|
||||
}
|
||||
|
||||
return (result_tensor, row_data)
|
||||
|
||||
def _tensor_to_pil(self, tensor):
|
||||
"""Convert a PyTorch tensor to a PIL Image"""
|
||||
if not torch.is_tensor(tensor):
|
||||
return tensor
|
||||
|
||||
# Clone tensor to CPU
|
||||
tensor = tensor.cpu().clone()
|
||||
|
||||
# Handle BCHW format (which is what FrameNode outputs)
|
||||
if len(tensor.shape) == 4: # BCHW format
|
||||
tensor = tensor[0] # Remove batch dimension
|
||||
if tensor.shape[0] == 3 or tensor.shape[0] == 1: # CHW format
|
||||
tensor = tensor.permute(1, 2, 0) # CHW to HWC
|
||||
|
||||
# Convert to numpy array
|
||||
if tensor.dtype == torch.uint8:
|
||||
img_np = tensor.numpy()
|
||||
else:
|
||||
img_np = (tensor.numpy() * 255).astype(np.uint8)
|
||||
|
||||
# Create PIL image
|
||||
if len(img_np.shape) == 2:
|
||||
return Image.fromarray(img_np, 'L')
|
||||
elif img_np.shape[-1] == 1:
|
||||
return Image.fromarray(img_np[:, :, 0], 'L')
|
||||
else:
|
||||
return Image.fromarray(img_np)
|
||||
|
||||
def _pil_to_tensor(self, pil_image):
|
||||
"""Convert a PIL Image to a PyTorch tensor in BCHW format for ComfyUI compatibility."""
|
||||
# Convert PIL to numpy
|
||||
img_np = np.array(pil_image).astype(np.float32) / 255.0
|
||||
|
||||
# Ensure proper dimensions for RGB images
|
||||
if len(img_np.shape) == 2: # Grayscale
|
||||
img_np = np.expand_dims(img_np, axis=2) # Add channel dimension
|
||||
|
||||
# Convert HWC to BCHW format (batch, channels, height, width)
|
||||
img_np = img_np.transpose(2, 0, 1) # HWC to CHW
|
||||
img_tensor = torch.from_numpy(img_np).unsqueeze(0) # Add batch dimension
|
||||
|
||||
return img_tensor
|
||||
|
||||
|
||||
class PageNode:
|
||||
"""
|
||||
PageNode: Combines multiple rows vertically into a complete comic page
|
||||
|
||||
This node stacks multiple rows to create a full comic page layout,
|
||||
with configurable spacing and alignment.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""
|
||||
Define the input types for the page node.
|
||||
"""
|
||||
return {
|
||||
"required": {
|
||||
"spacing": ("INT", {
|
||||
"default": 15,
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"step": 1,
|
||||
"display": "slider"
|
||||
}),
|
||||
"alignment": (["left", "center", "right"],),
|
||||
"page_color": (["white", "off-white", "light-gray"],),
|
||||
"page_width": ("INT", {
|
||||
"default": 1000,
|
||||
"min": 500,
|
||||
"max": 3000,
|
||||
"step": 10,
|
||||
"display": "slider"
|
||||
}),
|
||||
"page_margin": ("INT", {
|
||||
"default": 30,
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"step": 5,
|
||||
"display": "slider"
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"row1": ("ROW_DATA",),
|
||||
"row2": ("ROW_DATA",),
|
||||
"row3": ("ROW_DATA",),
|
||||
"row4": ("ROW_DATA",),
|
||||
"row5": ("ROW_DATA",),
|
||||
"title": ("STRING", {
|
||||
"multiline": False,
|
||||
"default": ""
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("page_image",)
|
||||
FUNCTION = "create_page"
|
||||
CATEGORY = "Pixstri/Comics"
|
||||
|
||||
def create_page(self, spacing, alignment, page_color, page_width, page_margin,
|
||||
row1=None, row2=None, row3=None, row4=None, row5=None, title=None):
|
||||
"""
|
||||
Create a comic page by combining multiple rows.
|
||||
|
||||
Parameters:
|
||||
-----------
|
||||
spacing : int
|
||||
Vertical spacing between rows in pixels
|
||||
alignment : str
|
||||
Horizontal alignment of rows ("left", "center", "right")
|
||||
page_color : str
|
||||
Background color of the page
|
||||
page_width : int
|
||||
Width of the page in pixels
|
||||
page_margin : int
|
||||
Margin around the page content in pixels
|
||||
row1-row5 : dict, optional
|
||||
Row data dictionaries from RowNode
|
||||
title : str, optional
|
||||
Title for the comic page
|
||||
|
||||
Returns:
|
||||
--------
|
||||
torch.Tensor
|
||||
The complete comic page image
|
||||
"""
|
||||
# Collect all provided rows (filter out None values)
|
||||
rows = [r for r in [row1, row2, row3, row4, row5] if r is not None]
|
||||
|
||||
if not rows:
|
||||
# Return empty page if no rows
|
||||
empty_img = Image.new("RGB", (page_width, page_width * 1.4), (240, 240, 240))
|
||||
draw = ImageDraw.Draw(empty_img)
|
||||
draw.text((page_width//2 - 80, page_width//2), "Empty Comic Page", fill=(0, 0, 0))
|
||||
empty_tensor = self._pil_to_tensor(empty_img)
|
||||
return (empty_tensor,)
|
||||
|
||||
# Set page background color
|
||||
if page_color == "white":
|
||||
bg_color = (255, 255, 255)
|
||||
elif page_color == "off-white":
|
||||
bg_color = (252, 252, 245)
|
||||
else: # light-gray
|
||||
bg_color = (240, 240, 240)
|
||||
|
||||
# Calculate heights and determine max width
|
||||
total_height = sum(row["height"] for row in rows) + spacing * (len(rows) - 1) + (page_margin * 2)
|
||||
max_row_width = max(row["width"] for row in rows)
|
||||
|
||||
# Determine page width (use specified width or fit content)
|
||||
content_width = max_row_width + (page_margin * 2)
|
||||
if content_width > page_width:
|
||||
page_width = content_width
|
||||
|
||||
# Add extra space for title if provided
|
||||
title_height = 0
|
||||
if title and title.strip():
|
||||
title_height = 50 # Space for title
|
||||
total_height += title_height
|
||||
|
||||
# Create the page canvas
|
||||
page_image = Image.new("RGB", (page_width, total_height), bg_color)
|
||||
draw = ImageDraw.Draw(page_image)
|
||||
|
||||
# Add title if provided
|
||||
y_offset = page_margin
|
||||
if title and title.strip():
|
||||
# Draw title text
|
||||
draw.text((page_width//2 - len(title)*4, y_offset), title.strip(), fill=(0, 0, 0))
|
||||
y_offset += title_height
|
||||
|
||||
# Place each row on the page
|
||||
for row in rows:
|
||||
row_tensor = row["image"]
|
||||
row_pil = self._tensor_to_pil(row_tensor)
|
||||
row_width = row["width"]
|
||||
row_height = row["height"]
|
||||
|
||||
# Calculate X position based on alignment
|
||||
if alignment == "left":
|
||||
x_offset = page_margin
|
||||
elif alignment == "right":
|
||||
x_offset = page_width - row_width - page_margin
|
||||
else: # center
|
||||
x_offset = (page_width - row_width) // 2
|
||||
|
||||
# Paste the row onto the page
|
||||
page_image.paste(row_pil, (x_offset, y_offset))
|
||||
|
||||
# Update the y offset
|
||||
y_offset += row_height + spacing
|
||||
|
||||
# Convert to tensor
|
||||
result_tensor = self._pil_to_tensor(page_image)
|
||||
|
||||
return (result_tensor,)
|
||||
|
||||
def _tensor_to_pil(self, tensor):
|
||||
"""Convert a PyTorch tensor to a PIL Image."""
|
||||
if not torch.is_tensor(tensor):
|
||||
return tensor # Already a PIL image or something else
|
||||
|
||||
# Clone and move to CPU
|
||||
tensor = tensor.cpu().clone()
|
||||
|
||||
# Handle uint8 tensors by converting to float
|
||||
if tensor.dtype == torch.uint8:
|
||||
tensor = tensor.float() / 255.0
|
||||
|
||||
# Handle B111C format specifically - this is the problematic format
|
||||
if len(tensor.shape) == 4:
|
||||
if tensor.shape[1] == 1 and tensor.shape[2] == 1: # B111C format
|
||||
tensor = tensor.squeeze(1).squeeze(1) # Remove singleton dimensions
|
||||
elif tensor.shape[3] == 3: # BHWC format
|
||||
tensor = tensor.permute(0, 3, 1, 2) # BHWC to BCHW
|
||||
|
||||
# Ensure we're in BCHW format and remove batch dimension
|
||||
if len(tensor.shape) == 4:
|
||||
tensor = tensor[0] # Remove batch dimension to get CHW
|
||||
|
||||
# Convert CHW to HWC for PIL
|
||||
if len(tensor.shape) == 3 and (tensor.shape[0] == 3 or tensor.shape[0] == 1):
|
||||
tensor = tensor.permute(1, 2, 0) # CHW to HWC
|
||||
|
||||
# Convert to numpy with proper scaling
|
||||
img_np = (tensor.numpy() * 255).astype(np.uint8)
|
||||
|
||||
# Create PIL image - handle grayscale vs RGB
|
||||
if len(img_np.shape) == 2:
|
||||
img_pil = Image.fromarray(img_np, 'L')
|
||||
elif img_np.shape[-1] == 1:
|
||||
img_pil = Image.fromarray(img_np[:, :, 0], 'L')
|
||||
else:
|
||||
img_pil = Image.fromarray(img_np)
|
||||
|
||||
return img_pil
|
||||
|
||||
def _pil_to_tensor(self, pil_image):
|
||||
"""Convert a PIL Image to a PyTorch tensor in BCHW format"""
|
||||
# Convert PIL to numpy array
|
||||
img_np = np.array(pil_image)
|
||||
|
||||
# Keep as uint8 for better compatibility
|
||||
if img_np.dtype != np.uint8:
|
||||
img_np = (img_np * 255).astype(np.uint8)
|
||||
|
||||
# Convert to tensor and keep as uint8
|
||||
img_tensor = torch.from_numpy(img_np)
|
||||
|
||||
# Return in BCHW format
|
||||
if len(img_tensor.shape) == 2: # Grayscale
|
||||
return img_tensor.unsqueeze(0).unsqueeze(0)
|
||||
else: # RGB
|
||||
# Move channels to correct position and add batch dimension
|
||||
return img_tensor.permute(2, 0, 1).unsqueeze(0)
|
||||
|
||||
|
||||
# A dictionary that contains all nodes you want to export with their names
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"FrameNode": FrameNode,
|
||||
"RowNode": RowNode,
|
||||
"PageNode": PageNode
|
||||
}
|
||||
|
||||
# A dictionary that contains the friendly/humanly readable titles for the nodes
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"FrameNode": "Comic Frame",
|
||||
"RowNode": "Comic Row",
|
||||
"PageNode": "Comic Page"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
torch>=1.7.0
|
||||
numpy>=1.19.0
|
||||
|
||||
Reference in New Issue
Block a user