feat: add dithering API with 12 algorithms and screen-print color separations
Adds on-demand dithering endpoint (Floyd-Steinberg, Atkinson, Bayer, etc.) and screen-print color separation workflow with per-channel PNG exports. Uses hitherdither library with in-memory LRU cache (max 200 entries). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
19c00b5ed9
commit
94fd3ad4d3
|
|
@ -7,6 +7,7 @@ WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
gcc \
|
gcc \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
|
git \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Python dependencies
|
# Install Python dependencies
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
"""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}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -6,7 +6,7 @@ from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.api import designs, products, cart, checkout, orders, webhooks, health, design_generator, upload, spaces
|
from app.api import designs, products, cart, checkout, orders, webhooks, health, design_generator, upload, spaces, dither
|
||||||
from app.api.admin import router as admin_router
|
from app.api.admin import router as admin_router
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
@ -52,6 +52,7 @@ app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"])
|
||||||
app.include_router(design_generator.router, prefix="/api/design", tags=["design-generator"])
|
app.include_router(design_generator.router, prefix="/api/design", tags=["design-generator"])
|
||||||
app.include_router(upload.router, prefix="/api/design", tags=["upload"])
|
app.include_router(upload.router, prefix="/api/design", tags=["upload"])
|
||||||
app.include_router(spaces.router, prefix="/api/spaces", tags=["spaces"])
|
app.include_router(spaces.router, prefix="/api/spaces", tags=["spaces"])
|
||||||
|
app.include_router(dither.router, prefix="/api/designs", tags=["dithering"])
|
||||||
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
|
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from app.schemas.cart import (
|
||||||
CartItemResponse,
|
CartItemResponse,
|
||||||
)
|
)
|
||||||
from app.schemas.order import OrderResponse, OrderItemResponse, OrderStatus
|
from app.schemas.order import OrderResponse, OrderItemResponse, OrderStatus
|
||||||
|
from app.schemas.dither import DitherAlgorithm, DitherResponse, PaletteMode, ScreenPrintExport
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Design",
|
"Design",
|
||||||
|
|
@ -25,4 +26,8 @@ __all__ = [
|
||||||
"OrderResponse",
|
"OrderResponse",
|
||||||
"OrderItemResponse",
|
"OrderItemResponse",
|
||||||
"OrderStatus",
|
"OrderStatus",
|
||||||
|
"DitherAlgorithm",
|
||||||
|
"DitherResponse",
|
||||||
|
"PaletteMode",
|
||||||
|
"ScreenPrintExport",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Pydantic schemas for dithering API."""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class DitherAlgorithm(str, Enum):
|
||||||
|
"""Supported dithering algorithms."""
|
||||||
|
|
||||||
|
FLOYD_STEINBERG = "floyd-steinberg"
|
||||||
|
ATKINSON = "atkinson"
|
||||||
|
STUCKI = "stucki"
|
||||||
|
BURKES = "burkes"
|
||||||
|
SIERRA = "sierra"
|
||||||
|
SIERRA_TWO_ROW = "sierra-two-row"
|
||||||
|
SIERRA_LITE = "sierra-lite"
|
||||||
|
JARVIS_JUDICE_NINKE = "jarvis-judice-ninke"
|
||||||
|
BAYER = "bayer"
|
||||||
|
YLILUOMA = "yliluoma"
|
||||||
|
ORDERED = "ordered"
|
||||||
|
CLUSTER_DOT = "cluster-dot"
|
||||||
|
|
||||||
|
|
||||||
|
class PaletteMode(str, Enum):
|
||||||
|
"""Palette generation modes."""
|
||||||
|
|
||||||
|
AUTO = "auto"
|
||||||
|
GRAYSCALE = "grayscale"
|
||||||
|
SPOT = "spot"
|
||||||
|
CUSTOM = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
class DitherResponse(BaseModel):
|
||||||
|
"""Metadata response for dithered image."""
|
||||||
|
|
||||||
|
slug: str
|
||||||
|
algorithm: DitherAlgorithm
|
||||||
|
palette_mode: PaletteMode
|
||||||
|
num_colors: int
|
||||||
|
colors_used: list[str] = Field(description="Hex color codes used in the palette")
|
||||||
|
cached: bool
|
||||||
|
image_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenPrintRequest(BaseModel):
|
||||||
|
"""Request body for screen print color separation."""
|
||||||
|
|
||||||
|
num_colors: int = Field(default=4, ge=2, le=12)
|
||||||
|
algorithm: DitherAlgorithm = DitherAlgorithm.FLOYD_STEINBERG
|
||||||
|
spot_colors: list[str] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional list of hex colors to use as spot colors (e.g. ['FF0000', '00FF00'])",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenPrintExport(BaseModel):
|
||||||
|
"""Response for screen print color separation."""
|
||||||
|
|
||||||
|
slug: str
|
||||||
|
num_colors: int
|
||||||
|
algorithm: DitherAlgorithm
|
||||||
|
colors: list[str] = Field(description="Hex color codes for each separation channel")
|
||||||
|
composite_url: str
|
||||||
|
separation_urls: dict[str, str] = Field(
|
||||||
|
description="Map of hex color to separation image URL",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,247 @@
|
||||||
|
"""Dithering service — palette building, dithering, and color separations."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
import hitherdither
|
||||||
|
|
||||||
|
from app.schemas.dither import DitherAlgorithm, PaletteMode
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# In-memory cache: sha256(slug+params) → (png_bytes, metadata)
|
||||||
|
_MAX_CACHE = 200
|
||||||
|
_dither_cache: OrderedDict[str, tuple[bytes, dict]] = OrderedDict()
|
||||||
|
|
||||||
|
# Screen-print separation cache: sha256(slug+params) → {color_hex: png_bytes, "composite": png_bytes}
|
||||||
|
_separation_cache: OrderedDict[str, tuple[bytes, dict[str, bytes], list[str]]] = OrderedDict()
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key(*parts) -> str:
|
||||||
|
raw = "|".join(str(p) for p in parts)
|
||||||
|
return hashlib.sha256(raw.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _evict(cache: OrderedDict, max_size: int = _MAX_CACHE):
|
||||||
|
while len(cache) > max_size:
|
||||||
|
cache.popitem(last=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_to_rgb(hex_str: str) -> tuple[int, int, int]:
|
||||||
|
h = hex_str.lstrip("#")
|
||||||
|
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
||||||
|
|
||||||
|
|
||||||
|
def _rgb_to_hex(r: int, g: int, b: int) -> str:
|
||||||
|
return f"{r:02X}{g:02X}{b:02X}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_palette(
|
||||||
|
image: Image.Image,
|
||||||
|
mode: PaletteMode,
|
||||||
|
num_colors: int = 8,
|
||||||
|
custom_colors: list[str] | None = None,
|
||||||
|
) -> hitherdither.palette.Palette:
|
||||||
|
"""Build a hitherdither Palette from an image and mode."""
|
||||||
|
if mode == PaletteMode.CUSTOM and custom_colors:
|
||||||
|
rgb_colors = [_hex_to_rgb(c) for c in custom_colors]
|
||||||
|
return hitherdither.palette.Palette(rgb_colors)
|
||||||
|
|
||||||
|
if mode == PaletteMode.GRAYSCALE:
|
||||||
|
step = 255 // max(num_colors - 1, 1)
|
||||||
|
grays = [(i * step, i * step, i * step) for i in range(num_colors)]
|
||||||
|
return hitherdither.palette.Palette(grays)
|
||||||
|
|
||||||
|
if mode == PaletteMode.SPOT and custom_colors:
|
||||||
|
rgb_colors = [_hex_to_rgb(c) for c in custom_colors[:num_colors]]
|
||||||
|
return hitherdither.palette.Palette(rgb_colors)
|
||||||
|
|
||||||
|
# Auto mode: median-cut quantization
|
||||||
|
quantized = image.convert("RGB").quantize(colors=num_colors, method=Image.Quantize.MEDIANCUT)
|
||||||
|
palette_data = quantized.getpalette()
|
||||||
|
colors = []
|
||||||
|
for i in range(num_colors):
|
||||||
|
idx = i * 3
|
||||||
|
if idx + 2 < len(palette_data):
|
||||||
|
colors.append((palette_data[idx], palette_data[idx + 1], palette_data[idx + 2]))
|
||||||
|
if not colors:
|
||||||
|
colors = [(0, 0, 0), (255, 255, 255)]
|
||||||
|
return hitherdither.palette.Palette(colors)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_palette_hex(palette: hitherdither.palette.Palette) -> list[str]:
|
||||||
|
"""Extract hex color strings from a palette."""
|
||||||
|
colors = []
|
||||||
|
for color in palette.colours:
|
||||||
|
r, g, b = int(color[0]), int(color[1]), int(color[2])
|
||||||
|
colors.append(_rgb_to_hex(r, g, b))
|
||||||
|
return colors
|
||||||
|
|
||||||
|
|
||||||
|
# Map algorithm enum to hitherdither function calls
|
||||||
|
_ERROR_DIFFUSION_ALGOS = {
|
||||||
|
DitherAlgorithm.FLOYD_STEINBERG: "floyd-steinberg",
|
||||||
|
DitherAlgorithm.ATKINSON: "atkinson",
|
||||||
|
DitherAlgorithm.STUCKI: "stucki",
|
||||||
|
DitherAlgorithm.BURKES: "burkes",
|
||||||
|
DitherAlgorithm.SIERRA: "sierra3",
|
||||||
|
DitherAlgorithm.SIERRA_TWO_ROW: "sierra2",
|
||||||
|
DitherAlgorithm.SIERRA_LITE: "sierra-2-4a",
|
||||||
|
DitherAlgorithm.JARVIS_JUDICE_NINKE: "jarvis-judice-ninke",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_dither(
|
||||||
|
image: Image.Image,
|
||||||
|
palette: hitherdither.palette.Palette,
|
||||||
|
algorithm: DitherAlgorithm,
|
||||||
|
threshold: int = 64,
|
||||||
|
order: int = 8,
|
||||||
|
) -> Image.Image:
|
||||||
|
"""Apply a dithering algorithm to an image with the given palette."""
|
||||||
|
img_rgb = image.convert("RGB")
|
||||||
|
|
||||||
|
if algorithm in _ERROR_DIFFUSION_ALGOS:
|
||||||
|
method = _ERROR_DIFFUSION_ALGOS[algorithm]
|
||||||
|
result = hitherdither.diffusion.error_diffusion_dithering(
|
||||||
|
img_rgb, palette, method=method, order=2,
|
||||||
|
)
|
||||||
|
elif algorithm == DitherAlgorithm.ORDERED:
|
||||||
|
result = hitherdither.ordered.bayer.bayer_dithering(
|
||||||
|
img_rgb, palette, [threshold, threshold, threshold], order=order,
|
||||||
|
)
|
||||||
|
elif algorithm == DitherAlgorithm.BAYER:
|
||||||
|
result = hitherdither.ordered.bayer.bayer_dithering(
|
||||||
|
img_rgb, palette, [threshold, threshold, threshold], order=order,
|
||||||
|
)
|
||||||
|
elif algorithm == DitherAlgorithm.YLILUOMA:
|
||||||
|
result = hitherdither.ordered.yliluoma.yliluomas_1_ordered_dithering(
|
||||||
|
img_rgb, palette, order=order,
|
||||||
|
)
|
||||||
|
elif algorithm == DitherAlgorithm.CLUSTER_DOT:
|
||||||
|
result = hitherdither.ordered.cluster.cluster_dot_dithering(
|
||||||
|
img_rgb, palette, [threshold, threshold, threshold], order=order,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback to Floyd-Steinberg
|
||||||
|
result = hitherdither.diffusion.error_diffusion_dithering(
|
||||||
|
img_rgb, palette, method="floyd-steinberg", order=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# hitherdither returns a PIL Image (indexed); convert to RGB
|
||||||
|
if hasattr(result, "convert"):
|
||||||
|
return result.convert("RGB")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def dither_design(
|
||||||
|
image_path: str,
|
||||||
|
slug: str,
|
||||||
|
algorithm: DitherAlgorithm = DitherAlgorithm.FLOYD_STEINBERG,
|
||||||
|
palette_mode: PaletteMode = PaletteMode.AUTO,
|
||||||
|
num_colors: int = 8,
|
||||||
|
custom_colors: list[str] | None = None,
|
||||||
|
threshold: int = 64,
|
||||||
|
order: int = 8,
|
||||||
|
fresh: bool = False,
|
||||||
|
) -> tuple[bytes, dict]:
|
||||||
|
"""Dither a design image and return (png_bytes, metadata).
|
||||||
|
|
||||||
|
Results are cached by slug+params combination.
|
||||||
|
"""
|
||||||
|
key = _cache_key(slug, algorithm.value, palette_mode.value, num_colors,
|
||||||
|
",".join(custom_colors or []), threshold, order)
|
||||||
|
|
||||||
|
if not fresh and key in _dither_cache:
|
||||||
|
_dither_cache.move_to_end(key)
|
||||||
|
png_bytes, meta = _dither_cache[key]
|
||||||
|
meta["cached"] = True
|
||||||
|
return png_bytes, meta
|
||||||
|
|
||||||
|
image = Image.open(image_path)
|
||||||
|
palette = build_palette(image, palette_mode, num_colors, custom_colors)
|
||||||
|
dithered = apply_dither(image, palette, algorithm, threshold, order)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
dithered.save(buf, format="PNG", optimize=True)
|
||||||
|
png_bytes = buf.getvalue()
|
||||||
|
|
||||||
|
colors_used = _get_palette_hex(palette)
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"slug": slug,
|
||||||
|
"algorithm": algorithm.value,
|
||||||
|
"palette_mode": palette_mode.value,
|
||||||
|
"num_colors": len(colors_used),
|
||||||
|
"colors_used": colors_used,
|
||||||
|
"cached": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
_dither_cache[key] = (png_bytes, meta)
|
||||||
|
_evict(_dither_cache)
|
||||||
|
|
||||||
|
return png_bytes, meta
|
||||||
|
|
||||||
|
|
||||||
|
def generate_color_separations(
|
||||||
|
image_path: str,
|
||||||
|
slug: str,
|
||||||
|
num_colors: int = 4,
|
||||||
|
algorithm: DitherAlgorithm = DitherAlgorithm.FLOYD_STEINBERG,
|
||||||
|
spot_colors: list[str] | None = None,
|
||||||
|
) -> tuple[bytes, dict[str, bytes], list[str]]:
|
||||||
|
"""Generate color separations for screen printing.
|
||||||
|
|
||||||
|
Returns (composite_png_bytes, {hex_color: separation_png_bytes}, colors_list).
|
||||||
|
"""
|
||||||
|
palette_mode = PaletteMode.SPOT if spot_colors else PaletteMode.AUTO
|
||||||
|
key = _cache_key("sep", slug, num_colors, algorithm.value,
|
||||||
|
",".join(spot_colors or []))
|
||||||
|
|
||||||
|
if key in _separation_cache:
|
||||||
|
_separation_cache.move_to_end(key)
|
||||||
|
return _separation_cache[key]
|
||||||
|
|
||||||
|
image = Image.open(image_path)
|
||||||
|
palette = build_palette(image, palette_mode, num_colors, spot_colors)
|
||||||
|
dithered = apply_dither(image, palette, algorithm)
|
||||||
|
|
||||||
|
# Save composite
|
||||||
|
buf = io.BytesIO()
|
||||||
|
dithered.save(buf, format="PNG", optimize=True)
|
||||||
|
composite_bytes = buf.getvalue()
|
||||||
|
|
||||||
|
# Generate per-color separations
|
||||||
|
dithered_rgb = dithered.convert("RGB")
|
||||||
|
arr = np.array(dithered_rgb)
|
||||||
|
colors = _get_palette_hex(palette)
|
||||||
|
separations: dict[str, bytes] = {}
|
||||||
|
|
||||||
|
for hex_color in colors:
|
||||||
|
r, g, b = _hex_to_rgb(hex_color)
|
||||||
|
# Create mask where pixels match this color (with small tolerance)
|
||||||
|
mask = (
|
||||||
|
(np.abs(arr[:, :, 0].astype(int) - r) < 16) &
|
||||||
|
(np.abs(arr[:, :, 1].astype(int) - g) < 16) &
|
||||||
|
(np.abs(arr[:, :, 2].astype(int) - b) < 16)
|
||||||
|
)
|
||||||
|
|
||||||
|
# White background, color pixels where mask is true
|
||||||
|
sep_img = np.full_like(arr, 255)
|
||||||
|
sep_img[mask] = [r, g, b]
|
||||||
|
|
||||||
|
sep_pil = Image.fromarray(sep_img.astype(np.uint8))
|
||||||
|
buf = io.BytesIO()
|
||||||
|
sep_pil.save(buf, format="PNG", optimize=True)
|
||||||
|
separations[hex_color] = buf.getvalue()
|
||||||
|
|
||||||
|
result = (composite_bytes, separations, colors)
|
||||||
|
_separation_cache[key] = result
|
||||||
|
_evict(_separation_cache)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
@ -27,6 +27,8 @@ pyyaml>=6.0.0
|
||||||
pillow>=10.0.0
|
pillow>=10.0.0
|
||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
aiofiles>=23.0.0
|
aiofiles>=23.0.0
|
||||||
|
hitherdither @ git+https://github.com/hbldh/hitherdither@master
|
||||||
|
numpy>=1.24.0
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
aiosmtplib>=3.0.0
|
aiosmtplib>=3.0.0
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue