rswag-online/backend/app/api/dither.py

154 lines
5.3 KiB
Python

"""Dithering API endpoints for on-demand dithering and screen-print separations."""
import io
import logging
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from app.schemas.dither import (
DitherAlgorithm,
DitherResponse,
PaletteMode,
ScreenPrintExport,
ScreenPrintRequest,
)
from app.services.design_service import DesignService
from app.services.dither_service import dither_design, generate_color_separations
logger = logging.getLogger(__name__)
router = APIRouter()
design_service = DesignService()
@router.get("/{slug}/dither")
async def get_dithered_design(
slug: str,
algorithm: DitherAlgorithm = DitherAlgorithm.FLOYD_STEINBERG,
palette: PaletteMode = PaletteMode.AUTO,
num_colors: int = Query(default=8, ge=2, le=32),
colors: str | None = Query(default=None, description="Comma-separated hex colors for custom palette"),
threshold: int = Query(default=64, ge=1, le=256),
order: int = Query(default=8, ge=2, le=16),
fresh: bool = False,
format: str = Query(default="image", description="'image' for PNG, 'json' for metadata"),
):
"""Apply dithering to a design image.
Returns a PNG image (default) or JSON metadata with image URL.
"""
image_path = await design_service.get_design_image_path(slug)
if not image_path or not Path(image_path).exists():
raise HTTPException(status_code=404, detail="Design not found")
custom_colors = [c.strip() for c in colors.split(",")] if colors else None
try:
png_bytes, meta = dither_design(
image_path=image_path,
slug=slug,
algorithm=algorithm,
palette_mode=palette,
num_colors=num_colors,
custom_colors=custom_colors,
threshold=threshold,
order=order,
fresh=fresh,
)
except Exception as e:
logger.error(f"Dithering failed for {slug}: {e}")
raise HTTPException(status_code=500, detail=f"Dithering failed: {e}")
if format == "json":
return DitherResponse(
slug=slug,
algorithm=algorithm,
palette_mode=palette,
num_colors=meta["num_colors"],
colors_used=meta["colors_used"],
cached=meta["cached"],
image_url=f"/api/designs/{slug}/dither?algorithm={algorithm.value}&palette={palette.value}&num_colors={num_colors}",
)
return StreamingResponse(
io.BytesIO(png_bytes),
media_type="image/png",
headers={"Cache-Control": "public, max-age=86400"},
)
@router.post("/{slug}/screen-print", response_model=ScreenPrintExport)
async def create_screen_print(slug: str, body: ScreenPrintRequest):
"""Generate color separations for screen printing."""
image_path = await design_service.get_design_image_path(slug)
if not image_path or not Path(image_path).exists():
raise HTTPException(status_code=404, detail="Design not found")
try:
composite_bytes, separations, colors = generate_color_separations(
image_path=image_path,
slug=slug,
num_colors=body.num_colors,
algorithm=body.algorithm,
spot_colors=body.spot_colors,
)
except Exception as e:
logger.error(f"Screen print generation failed for {slug}: {e}")
raise HTTPException(status_code=500, detail=f"Screen print generation failed: {e}")
base_url = f"/api/designs/{slug}/screen-print"
separation_urls = {color: f"{base_url}/{color}" for color in colors}
return ScreenPrintExport(
slug=slug,
num_colors=len(colors),
algorithm=body.algorithm,
colors=colors,
composite_url=f"{base_url}/composite",
separation_urls=separation_urls,
)
@router.get("/{slug}/screen-print/{channel}")
async def get_screen_print_channel(slug: str, channel: str):
"""Serve a screen-print separation channel image.
channel: "composite" or a hex color code (e.g. "FF0000")
"""
image_path = await design_service.get_design_image_path(slug)
if not image_path or not Path(image_path).exists():
raise HTTPException(status_code=404, detail="Design not found")
# Try to find cached separations — generate with defaults if not cached
try:
composite_bytes, separations, colors = generate_color_separations(
image_path=image_path,
slug=slug,
)
except Exception as e:
logger.error(f"Screen print channel failed for {slug}/{channel}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to generate separation: {e}")
if channel == "composite":
png_bytes = composite_bytes
filename = f"{slug}-composite.png"
else:
hex_upper = channel.upper().lstrip("#")
if hex_upper not in separations:
raise HTTPException(
status_code=404,
detail=f"Color channel '{channel}' not found. Available: {', '.join(separations.keys())}",
)
png_bytes = separations[hex_upper]
filename = f"{slug}-{hex_upper}.png"
return StreamingResponse(
io.BytesIO(png_bytes),
media_type="image/png",
headers={
"Cache-Control": "public, max-age=86400",
"Content-Disposition": f"attachment; filename={filename}",
},
)