Spaces:
Paused
Paused
File size: 10,032 Bytes
017676c 5a1b419 017676c 5a1b419 017676c 5a1b419 017676c 5a1b419 017676c 5a1b419 017676c 5a1b419 017676c 5a1b419 017676c 5a1b419 017676c 5a1b419 017676c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 | """Gradio/ZeroGPU entry point for the LayerD image decomposition pipeline."""
from __future__ import annotations
import logging
import math
import os
import shutil
import tempfile
import time
import uuid
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from typing import NoReturn
import gradio as gr
import spaces
import torch
from PIL import Image, ImageOps, UnidentifiedImageError
logging.basicConfig(
level=os.getenv("LAYERD_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s - %(message)s",
)
LOGGER = logging.getLogger("layerd_api")
def _package_version(package_name: str) -> str:
"""Return a package version for diagnostics without masking the real error."""
try:
return version(package_name)
except PackageNotFoundError:
return "unknown"
def _env_int(name: str, default: int, *, minimum: int, maximum: int) -> int:
"""Read a bounded integer setting without making a bad env var fatal."""
raw_value = os.getenv(name)
if raw_value is None:
return default
try:
value = int(raw_value)
except ValueError:
LOGGER.warning("Ignoring invalid %s=%r; using %d", name, raw_value, default)
return default
bounded = max(minimum, min(value, maximum))
if bounded != value:
LOGGER.warning("Clamped %s=%d to the supported range [%d, %d]", name, value, minimum, maximum)
return bounded
def _env_float(name: str, default: float, *, minimum: float, maximum: float) -> float:
"""Read a bounded floating-point setting without making startup fragile."""
raw_value = os.getenv(name)
if raw_value is None:
return default
try:
value = float(raw_value)
except ValueError:
LOGGER.warning("Ignoring invalid %s=%r; using %s", name, raw_value, default)
return default
bounded = max(minimum, min(value, maximum))
if bounded != value:
LOGGER.warning("Clamped %s=%s to the supported range [%s, %s]", name, value, minimum, maximum)
return bounded
def _resolve_device() -> str:
"""Select CUDA for Spaces and retain a CPU escape hatch for local runs.
ZeroGPU exposes a CUDA emulation layer while the module is importing, so
loading the model on ``cuda`` here is intentional and required for efficient
placement when a request receives a real GPU.
"""
requested = os.getenv("LAYERD_DEVICE", "").strip().lower()
if requested:
if requested not in {"cpu", "cuda"}:
raise ValueError("LAYERD_DEVICE must be either 'cpu' or 'cuda'")
return requested
# SPACE_ID is set by Hugging Face Spaces. The configured Space uses
# ZeroGPU, where torch.cuda.is_available() is not a reliable startup test.
if os.getenv("SPACE_ID"):
return "cuda"
return "cuda" if torch.cuda.is_available() else "cpu"
def _assert_transformers_compatibility() -> None:
"""Fail early with an actionable message if dependency resolution drifts."""
installed = _package_version("transformers")
if installed == "unknown":
return
try:
major_version = int(installed.split(".", 1)[0])
except (TypeError, ValueError):
LOGGER.warning("Could not parse Transformers version %r", installed)
return
if major_version >= 5:
raise RuntimeError(
"LayerD's BiRefNet remote model requires transformers<5.0.0; "
f"found transformers=={installed}. Pin transformers==4.57.5 and rebuild the Space."
)
_assert_transformers_compatibility()
from layerd import LayerDPipeline # noqa: E402 (check dependency before importing LayerD)
MODEL_CARD = os.getenv("LAYERD_MATTING_MODEL", "cyberagent/layerd-birefnet")
DEVICE = _resolve_device()
MATTING_SIZE = _env_int("LAYERD_MATTING_SIZE", 1024, minimum=256, maximum=2048)
MAX_ITERATIONS = _env_int("LAYERD_MAX_ITERATIONS", 3, minimum=1, maximum=5)
MAX_SIDE = _env_int("LAYERD_MAX_SIDE", 2048, minimum=256, maximum=4096)
MAX_PIXELS = _env_int("LAYERD_MAX_PIXELS", 4_194_304, minimum=65_536, maximum=16_777_216)
OUTPUT_TTL_SECONDS = _env_float("LAYERD_OUTPUT_TTL_SECONDS", 7_200.0, minimum=300.0, maximum=86_400.0)
OUTPUT_ROOT = Path(os.getenv("LAYERD_OUTPUT_DIR", tempfile.gettempdir())) / "layerd-api"
def _build_pipeline() -> LayerDPipeline:
LOGGER.info(
"Loading LayerD model card=%s device=%s matting_size=%sx%s max_iterations=%s",
MODEL_CARD,
DEVICE,
MATTING_SIZE,
MATTING_SIZE,
MAX_ITERATIONS,
)
pipeline = LayerDPipeline(
matting_hf_card=MODEL_CARD,
matting_process_size=(MATTING_SIZE, MATTING_SIZE),
device=DEVICE,
)
LOGGER.info("LayerD model is ready (transformers=%s)", _package_version("transformers"))
return pipeline
# Load at module level so ZeroGPU can place weights efficiently during startup.
pipeline = _build_pipeline()
def _cleanup_expired_outputs(now: float | None = None) -> None:
"""Remove old request artifacts while preserving files still offered to users."""
if not OUTPUT_ROOT.exists():
return
current_time = time.time() if now is None else now
cutoff = current_time - OUTPUT_TTL_SECONDS
for child in OUTPUT_ROOT.iterdir():
try:
if child.is_dir() and child.stat().st_mtime < cutoff:
shutil.rmtree(child)
except FileNotFoundError:
# Another queued request may have cleaned the same directory.
continue
except OSError:
LOGGER.warning("Could not clean old output directory %s", child, exc_info=True)
def _prepare_image(input_image: Image.Image) -> Image.Image:
"""Normalize orientation/color and bound work for the shared GPU."""
if not isinstance(input_image, Image.Image):
raise ValueError("请上传一张有效的 PNG、JPG 或 WEBP 图片。")
try:
image = ImageOps.exif_transpose(input_image)
except (AttributeError, ValueError, UnidentifiedImageError) as exc:
raise ValueError("无法读取图片元数据,请重新上传图片。") from exc
width, height = image.size
if width < 16 or height < 16:
raise ValueError("图片尺寸至少需要 16 x 16 像素。")
# LayerD ultimately works on RGB. Converting once here also avoids carrying
# an unexpected palette/alpha mode through the matting and inpainting code.
image = image.convert("RGB")
scale = min(
1.0,
MAX_SIDE / max(width, height),
math.sqrt(MAX_PIXELS / (width * height)),
)
if scale < 1.0:
# Floor rather than round so the configured pixel/side limits remain
# hard upper bounds even for dimensions that land on a fractional pixel.
resized = (max(16, math.floor(width * scale)), max(16, math.floor(height * scale)))
LOGGER.info("Resizing input from %sx%s to %sx%s", width, height, resized[0], resized[1])
image = image.resize(resized, Image.Resampling.LANCZOS)
return image
def _new_output_dir(request_id: str) -> Path:
"""Create an isolated output directory for one request."""
OUTPUT_ROOT.mkdir(parents=True, exist_ok=True)
output_dir = OUTPUT_ROOT / request_id
output_dir.mkdir()
return output_dir
def _raise_gradio_error(message: str) -> NoReturn:
"""Raise a user-visible Gradio error across supported Gradio versions."""
error_type = getattr(gr, "Error", RuntimeError)
raise error_type(message)
@spaces.GPU(duration=120)
def process_image(input_image: Image.Image | None) -> tuple[str | None, str | None]:
"""Decompose one image and return downloadable PSD and self-contained SVG files."""
request_id = uuid.uuid4().hex[:12]
if input_image is None:
_raise_gradio_error("请先上传一张图片。")
output_dir: Path | None = None
started_at = time.perf_counter()
try:
_cleanup_expired_outputs()
image = _prepare_image(input_image)
output_dir = _new_output_dir(request_id)
# LayerD and LaMa already use inference mode internally; the outer guard
# also covers classification and any future model components.
with torch.inference_mode():
result = pipeline(image, max_iterations=MAX_ITERATIONS)
psd_path = output_dir / "layerd-output.psd"
svg_path = output_dir / "layerd-output.svg"
result.save(str(psd_path), format="psd")
result.save(str(svg_path), format="svg")
elapsed = time.perf_counter() - started_at
LOGGER.info(
"request=%s complete input=%sx%s elements=%s psd=%s svg=%s elapsed=%.2fs",
request_id,
image.width,
image.height,
len(result.elements),
psd_path.stat().st_size,
svg_path.stat().st_size,
elapsed,
)
return str(psd_path), str(svg_path)
except (ValueError, UnidentifiedImageError) as exc:
LOGGER.warning("request=%s rejected: %s", request_id, exc)
if output_dir is not None:
shutil.rmtree(output_dir, ignore_errors=True)
_raise_gradio_error(str(exc))
except Exception:
LOGGER.exception("request=%s failed", request_id)
if output_dir is not None:
shutil.rmtree(output_dir, ignore_errors=True)
_raise_gradio_error(f"处理失败(请求 ID: {request_id}),请稍后重试。")
demo = gr.Interface(
fn=process_image,
inputs=gr.Image(type="pil", label="上传海报/设计图"),
outputs=[
gr.File(label="下载 PSD 分层文件"),
gr.File(label="下载 SVG 矢量文件"),
],
title="LayerD GPU 分层服务",
description="上传一张海报或设计图,生成可编辑的 PSD 分层文件和自包含 SVG 文件。",
api_name="process",
)
# Serialize GPU jobs to avoid concurrent LaMa allocations on the shared device.
demo.queue(max_size=8, default_concurrency_limit=1)
if __name__ == "__main__":
demo.launch()
|