All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 11s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / VM Test - security (push) Successful in 8s
CI/CD Pipeline / Backend Linting (push) Successful in 3s
CI/CD Pipeline / Frontend Linting (push) Successful in 18s
CI/CD Pipeline / Nix Flake Check (push) Successful in 43s
CI/CD Pipeline / CI Summary (push) Successful in 0s
229 lines
7.3 KiB
Python
229 lines
7.3 KiB
Python
"""Composite image generation for board export."""
|
|
|
|
import io
|
|
|
|
from fastapi import HTTPException, status
|
|
from fastapi.responses import StreamingResponse
|
|
from PIL import Image as PILImage
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.storage import storage_client
|
|
from app.database.models.board import Board
|
|
from app.database.models.board_image import BoardImage
|
|
from app.database.models.image import Image
|
|
|
|
|
|
def create_composite_export(board_id: str, db: Session, scale: float = 1.0, format: str = "PNG") -> StreamingResponse:
|
|
"""
|
|
Create a composite image showing the entire board layout.
|
|
|
|
Args:
|
|
board_id: Board UUID
|
|
db: Database session
|
|
scale: Resolution multiplier (1x, 2x, 4x)
|
|
format: Output format (PNG or JPEG)
|
|
|
|
Returns:
|
|
StreamingResponse with composite image
|
|
|
|
Raises:
|
|
HTTPException: If export fails
|
|
"""
|
|
try:
|
|
# Get board
|
|
board = db.query(Board).filter(Board.id == board_id).first()
|
|
if not board:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Board not found",
|
|
)
|
|
|
|
# Get all images for the board with positions
|
|
board_images = (
|
|
db.query(BoardImage, Image)
|
|
.join(Image, BoardImage.image_id == Image.id)
|
|
.filter(BoardImage.board_id == board_id)
|
|
.order_by(BoardImage.z_order)
|
|
.all()
|
|
)
|
|
|
|
if not board_images:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="No images found for this board",
|
|
)
|
|
|
|
# Calculate canvas bounds
|
|
bounds = _calculate_canvas_bounds(board_images)
|
|
if not bounds:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Unable to calculate canvas bounds",
|
|
)
|
|
|
|
min_x, min_y, max_x, max_y = bounds
|
|
|
|
# Calculate canvas size with padding
|
|
padding = 50
|
|
canvas_width = int((max_x - min_x + 2 * padding) * scale)
|
|
canvas_height = int((max_y - min_y + 2 * padding) * scale)
|
|
|
|
# Limit canvas size to prevent memory issues
|
|
max_dimension = 8192 # 8K resolution limit
|
|
if canvas_width > max_dimension or canvas_height > max_dimension:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Composite image too large (max {max_dimension}x{max_dimension})",
|
|
)
|
|
|
|
# Create blank canvas
|
|
if format.upper() == "JPEG":
|
|
canvas = PILImage.new("RGB", (canvas_width, canvas_height), color=(255, 255, 255))
|
|
else:
|
|
canvas = PILImage.new("RGBA", (canvas_width, canvas_height), color=(255, 255, 255, 255))
|
|
|
|
# Composite each image onto canvas
|
|
for board_image, image in board_images:
|
|
try:
|
|
# Get image from storage
|
|
image_data = storage_client.get_object(image.storage_path)
|
|
if not image_data:
|
|
continue
|
|
|
|
# Open image
|
|
pil_image = PILImage.open(io.BytesIO(image_data))
|
|
|
|
# Apply transformations
|
|
transformed_image = _apply_transformations(pil_image, board_image.transformations, scale)
|
|
|
|
# Calculate position on canvas
|
|
pos = board_image.position
|
|
x = int((pos["x"] - min_x + padding) * scale)
|
|
y = int((pos["y"] - min_y + padding) * scale)
|
|
|
|
# Paste onto canvas
|
|
if transformed_image.mode == "RGBA":
|
|
canvas.paste(transformed_image, (x, y), transformed_image)
|
|
else:
|
|
canvas.paste(transformed_image, (x, y))
|
|
|
|
except Exception as e:
|
|
# Log error but continue with other images
|
|
print(f"Warning: Failed to composite {image.filename}: {str(e)}")
|
|
continue
|
|
|
|
# Save to buffer
|
|
output = io.BytesIO()
|
|
if format.upper() == "JPEG":
|
|
canvas = canvas.convert("RGB")
|
|
canvas.save(output, format="JPEG", quality=95)
|
|
media_type = "image/jpeg"
|
|
extension = "jpg"
|
|
else:
|
|
canvas.save(output, format="PNG", optimize=True)
|
|
media_type = "image/png"
|
|
extension = "png"
|
|
|
|
output.seek(0)
|
|
|
|
# Return composite image
|
|
return StreamingResponse(
|
|
output,
|
|
media_type=media_type,
|
|
headers={
|
|
"Content-Disposition": f'attachment; filename="board_composite.{extension}"',
|
|
"Cache-Control": "no-cache",
|
|
},
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to create composite export: {str(e)}",
|
|
) from e
|
|
|
|
|
|
def _calculate_canvas_bounds(board_images) -> tuple[float, float, float, float] | None:
|
|
"""
|
|
Calculate the bounding box for all images.
|
|
|
|
Args:
|
|
board_images: List of (BoardImage, Image) tuples
|
|
|
|
Returns:
|
|
Tuple of (min_x, min_y, max_x, max_y) or None
|
|
"""
|
|
if not board_images:
|
|
return None
|
|
|
|
min_x = min_y = float("inf")
|
|
max_x = max_y = float("-inf")
|
|
|
|
for board_image, image in board_images:
|
|
pos = board_image.position
|
|
transforms = board_image.transformations
|
|
|
|
x = pos["x"]
|
|
y = pos["y"]
|
|
width = image.width * transforms.get("scale", 1.0)
|
|
height = image.height * transforms.get("scale", 1.0)
|
|
|
|
min_x = min(min_x, x)
|
|
min_y = min(min_y, y)
|
|
max_x = max(max_x, x + width)
|
|
max_y = max(max_y, y + height)
|
|
|
|
return (min_x, min_y, max_x, max_y)
|
|
|
|
|
|
def _apply_transformations(image: PILImage.Image, transformations: dict, scale: float) -> PILImage.Image:
|
|
"""
|
|
Apply transformations to an image.
|
|
|
|
Args:
|
|
image: PIL Image
|
|
transformations: Transformation dict
|
|
scale: Resolution multiplier
|
|
|
|
Returns:
|
|
Transformed PIL Image
|
|
"""
|
|
# Apply scale
|
|
img_scale = transformations.get("scale", 1.0) * scale
|
|
if img_scale != 1.0:
|
|
new_width = int(image.width * img_scale)
|
|
new_height = int(image.height * img_scale)
|
|
image = image.resize((new_width, new_height), PILImage.Resampling.LANCZOS)
|
|
|
|
# Apply rotation
|
|
rotation = transformations.get("rotation", 0)
|
|
if rotation != 0:
|
|
image = image.rotate(-rotation, expand=True, resample=PILImage.Resampling.BICUBIC)
|
|
|
|
# Apply flips
|
|
if transformations.get("flipped_h", False):
|
|
image = image.transpose(PILImage.Transpose.FLIP_LEFT_RIGHT)
|
|
if transformations.get("flipped_v", False):
|
|
image = image.transpose(PILImage.Transpose.FLIP_TOP_BOTTOM)
|
|
|
|
# Apply greyscale
|
|
if transformations.get("greyscale", False):
|
|
if image.mode == "RGBA":
|
|
# Preserve alpha channel
|
|
alpha = image.split()[-1]
|
|
image = image.convert("L").convert("RGBA")
|
|
image.putalpha(alpha)
|
|
else:
|
|
image = image.convert("L")
|
|
|
|
# Apply opacity
|
|
opacity = transformations.get("opacity", 1.0)
|
|
if opacity < 1.0 and image.mode in ("RGBA", "LA"):
|
|
alpha = image.split()[-1]
|
|
alpha = alpha.point(lambda p: int(p * opacity))
|
|
image.putalpha(alpha)
|
|
|
|
return image
|