feat: port img2img watercolour develop to ZeroGPU (staged LoRA)

#1
Files changed (3) hide show
  1. app.py +240 -0
  2. packages.txt +1 -0
  3. requirements.txt +4 -1
app.py CHANGED
@@ -1274,6 +1274,12 @@ def client_config():
1274
  "asrProfiles": list(ASR_CHUNK_PROFILES.keys()),
1275
  "asrDefaultProfile": ASR_DEFAULT_PROFILE,
1276
  "captionModel": AUTOCAPTION_MODEL_ID,
 
 
 
 
 
 
1277
  "phoenix": _get_tracer() is not None,
1278
  }
1279
 
@@ -1411,6 +1417,240 @@ async def nsfw_rest(request: Request):
1411
  return {"score": 0.0, "blocked": False, "threshold": NSFW_THRESHOLD, "available": available, "error": str(exc)}
1412
 
1413
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1414
  # --------------------------------------------------------------------------- #
1415
  # Voice → text via NVIDIA NeMo ASR (item 5). Streaming Nemotron model; needs a
1416
  # GPU to be practical. Loaded lazily; if NeMo/torch/GPU are missing the endpoint
 
1274
  "asrProfiles": list(ASR_CHUNK_PROFILES.keys()),
1275
  "asrDefaultProfile": ASR_DEFAULT_PROFILE,
1276
  "captionModel": AUTOCAPTION_MODEL_ID,
1277
+ # "Proceed to Development" watercolour: the endpoint always exists; this
1278
+ # only reports whether torch/diffusers are present (a CUDA-free check —
1279
+ # the LoRA is optional, stage 1 runs base img2img). The ~14 GB pipeline
1280
+ # still builds lazily on first use inside the GPU worker.
1281
+ "img2imgEnabled": _img2img_prep(),
1282
+ "img2imgModel": IMG2IMG_MODEL_ID,
1283
  "phoenix": _get_tracer() is not None,
1284
  }
1285
 
 
1417
  return {"score": 0.0, "blocked": False, "threshold": NSFW_THRESHOLD, "available": available, "error": str(exc)}
1418
 
1419
 
1420
+ # --------------------------------------------------------------------------- #
1421
+ # Develop → watercolour via Flux.2-klein + a scene LoRA (in-process img2img).
1422
+ # This is what "Proceed to Development" triggers: each photo is sent here and
1423
+ # painted into a watercolour by Flux2KleinPipeline (the source is VAE-encoded
1424
+ # and used as reference conditioning while the LoRA steers the watercolour
1425
+ # style).
1426
+ #
1427
+ # ZeroGPU port notes (mirrors the caption/NSFW/ASR helpers in this file):
1428
+ # * The pipeline is ~14 GB and lives ONLY on the GPU, so it must be built
1429
+ # INSIDE a @spaces.GPU context — never in the main process, where CUDA must
1430
+ # stay un-initialized. `_img2img_prep()` does the CUDA-free part (import +
1431
+ # LoRA file check) so /config and the endpoint can report availability
1432
+ # cheaply; `_ensure_img2img_pipe()` does the actual GPU build, lazily, the
1433
+ # first time `_img2img_infer` runs in a worker.
1434
+ # * @spaces.GPU pickles arguments to/from the worker. The Flux pipe is NOT
1435
+ # picklable, so it is fetched from module state inside the worker and never
1436
+ # crosses the boundary. Only the PIL image in and the PIL image out cross
1437
+ # it (both picklable), exactly like `_nsfw_infer`.
1438
+ # * This file is self-contained on the Space (no backend/ package), so the
1439
+ # squaring + load + stylize logic from backend/hf/3_infer_img2img.py is
1440
+ # inlined here rather than imported.
1441
+ #
1442
+ # STAGED ROLLOUT: the scene LoRA is OPTIONAL. Stage 1 (now) is "pure img2img" —
1443
+ # the base Flux2Klein model develops the photo with a generic watercolour prompt,
1444
+ # no .safetensors required. Stage 2 is dropping the LoRA file in (or setting
1445
+ # SOZAI_IMG2IMG_LORA_REPO): it's detected automatically and the trained
1446
+ # watercolour style + trigger prompt switch on. So a missing LoRA degrades to
1447
+ # base img2img rather than disabling the feature.
1448
+ #
1449
+ # Fails CLOSED (available=false) only when torch/diffusers are absent, so a CPU
1450
+ # Space or local Mac keeps the original photo instead of erroring/OOMing.
1451
+ # --------------------------------------------------------------------------- #
1452
+ IMG2IMG_MODEL_ID = os.environ.get("SOZAI_IMG2IMG_MODEL", "black-forest-labs/FLUX.2-klein-4B")
1453
+ # Path to the watercolour scene LoRA .safetensors. OPTIONAL: ship the file in the
1454
+ # repo at this path, or point SOZAI_IMG2IMG_LORA_REPO/_FILE at a Hub repo to have
1455
+ # it downloaded (CPU/network only) at prep time. Absent = base img2img (stage 1).
1456
+ IMG2IMG_LORA_PATH = os.environ.get(
1457
+ "SOZAI_IMG2IMG_LORA",
1458
+ os.path.join(BASE, "data/places/checkpoints/hf-spaces/scene_lora.safetensors"),
1459
+ )
1460
+ IMG2IMG_LORA_REPO = os.environ.get("SOZAI_IMG2IMG_LORA_REPO", "")
1461
+ IMG2IMG_LORA_FILE = os.environ.get("SOZAI_IMG2IMG_LORA_FILE", "scene_lora.safetensors")
1462
+ IMG2IMG_LORA_SCALE = float(os.environ.get("SOZAI_IMG2IMG_LORA_SCALE", "1.0"))
1463
+ # Generation defaults (from backend/hf/3_infer_img2img.py).
1464
+ IMG2IMG_TRIGGER = "wtrclr8"
1465
+ # Prompt used WITH the LoRA (its trigger token), vs. the base-model fallback used
1466
+ # in stage 1 when no LoRA is loaded — the trigger token means nothing without it.
1467
+ IMG2IMG_PROMPT = os.environ.get("SOZAI_IMG2IMG_PROMPT", "WTRCLR8 watercolour painting")
1468
+ IMG2IMG_BASE_PROMPT = os.environ.get(
1469
+ "SOZAI_IMG2IMG_BASE_PROMPT",
1470
+ "a soft watercolour painting of this scene, painterly, textured paper, gentle washes",
1471
+ )
1472
+ IMG2IMG_SIZE = int(os.environ.get("SOZAI_IMG2IMG_SIZE", "1024"))
1473
+ IMG2IMG_STEPS = int(os.environ.get("SOZAI_IMG2IMG_STEPS", "8"))
1474
+ IMG2IMG_GUIDANCE = float(os.environ.get("SOZAI_IMG2IMG_GUIDANCE", "3.5"))
1475
+ IMG2IMG_SEED = int(os.environ.get("SOZAI_IMG2IMG_SEED", "42"))
1476
+ _img2img_lock = threading.Lock()
1477
+ _img2img_state = {"loaded": False, "available": False, "pipe": None,
1478
+ "lora_path": None, "lora_loaded": False}
1479
+
1480
+
1481
+ def _img2img_prep() -> bool:
1482
+ """CUDA-free availability check, run in the main process. Verifies diffusers
1483
+ (with Flux2KleinPipeline) imports; the LoRA is OPTIONAL — if a file is present
1484
+ (or downloadable via SOZAI_IMG2IMG_LORA_REPO) its path is cached for stage 2,
1485
+ otherwise we fall back to base img2img. Does NOT touch CUDA or build the
1486
+ pipeline. Returns True whenever the base feature can run in a GPU worker."""
1487
+ if _img2img_state["loaded"]:
1488
+ return _img2img_state["available"]
1489
+ with _img2img_lock:
1490
+ if _img2img_state["loaded"]:
1491
+ return _img2img_state["available"]
1492
+ try:
1493
+ import torch # noqa: F401 (import check only — no CUDA here)
1494
+ from diffusers import Flux2KleinPipeline # noqa: F401
1495
+ # Resolve the LoRA if we can, but its absence is NOT fatal.
1496
+ lora_path = IMG2IMG_LORA_PATH if (IMG2IMG_LORA_PATH and
1497
+ os.path.isfile(IMG2IMG_LORA_PATH)) else None
1498
+ if lora_path is None and IMG2IMG_LORA_REPO:
1499
+ try:
1500
+ from huggingface_hub import hf_hub_download
1501
+ lora_path = hf_hub_download(repo_id=IMG2IMG_LORA_REPO,
1502
+ filename=IMG2IMG_LORA_FILE)
1503
+ except Exception as lexc: # noqa: BLE001
1504
+ print(f"[img2img] LoRA download skipped ({lexc}); using base model")
1505
+ lora_path = None
1506
+ _img2img_state["lora_path"] = lora_path
1507
+ _img2img_state["available"] = True
1508
+ print(f"[img2img] available ({IMG2IMG_MODEL_ID}; "
1509
+ f"LoRA={lora_path or 'none — base img2img (stage 1)'}); "
1510
+ f"pipeline will build on first use inside the GPU worker")
1511
+ except Exception as exc: # noqa: BLE001
1512
+ print(f"[img2img] unavailable, endpoint will report disabled: {exc}")
1513
+ _img2img_state["available"] = False
1514
+ finally:
1515
+ _img2img_state["loaded"] = True
1516
+ return _img2img_state["available"]
1517
+
1518
+
1519
+ _img2img_build_lock = threading.Lock()
1520
+
1521
+
1522
+ def _ensure_img2img_pipe():
1523
+ """Build (once) and return the Flux2KleinPipeline on the GPU, applying the
1524
+ watercolour LoRA only if one was resolved (stage 2); otherwise it's plain
1525
+ base img2img (stage 1). MUST be called from inside a @spaces.GPU context on
1526
+ ZeroGPU so the CUDA context is created with a GPU attached. Idempotent +
1527
+ thread-safe; caches into _img2img_state. Returns the pipe, or None on failure
1528
+ (caller falls back)."""
1529
+ pipe = _img2img_state.get("pipe")
1530
+ if pipe is not None:
1531
+ return pipe
1532
+ with _img2img_build_lock:
1533
+ pipe = _img2img_state.get("pipe")
1534
+ if pipe is not None:
1535
+ return pipe
1536
+ try:
1537
+ import torch
1538
+ from diffusers import Flux2KleinPipeline
1539
+ try:
1540
+ from pillow_heif import register_heif_opener
1541
+ register_heif_opener() # so HEIC/HEIF phone photos open
1542
+ except Exception: # noqa: BLE001
1543
+ pass
1544
+ lora_path = _img2img_state.get("lora_path")
1545
+ device = "cuda" if torch.cuda.is_available() else "cpu"
1546
+ print(f"[img2img] building {IMG2IMG_MODEL_ID} on {device} "
1547
+ f"({'with LoRA' if lora_path else 'base, no LoRA'}) …")
1548
+ pipe = Flux2KleinPipeline.from_pretrained(
1549
+ IMG2IMG_MODEL_ID, torch_dtype=torch.bfloat16
1550
+ ).to(device)
1551
+ if lora_path and os.path.isfile(lora_path):
1552
+ from pathlib import Path as _Path
1553
+ lf = _Path(lora_path)
1554
+ pipe.load_lora_weights(str(lf.parent), weight_name=lf.name,
1555
+ adapter_name=IMG2IMG_TRIGGER)
1556
+ pipe.set_adapters([IMG2IMG_TRIGGER], adapter_weights=[IMG2IMG_LORA_SCALE])
1557
+ _img2img_state["lora_loaded"] = True
1558
+ print("[img2img] pipeline ready (watercolour LoRA applied)")
1559
+ else:
1560
+ _img2img_state["lora_loaded"] = False
1561
+ print("[img2img] pipeline ready (base model — stage 1, no LoRA)")
1562
+ _img2img_state["pipe"] = pipe
1563
+ return pipe
1564
+ except Exception as exc: # noqa: BLE001
1565
+ print(f"[img2img] in-worker build failed: {exc}")
1566
+ return None
1567
+
1568
+
1569
+ def _img2img_square(img, size: int = IMG2IMG_SIZE):
1570
+ """EXIF-normalise, flatten to RGB, and centre-crop a PIL image to size×size —
1571
+ the shape Klein expects (inlined from backend/hf/3_infer_img2img.py)."""
1572
+ from PIL import Image as PILImage, ImageOps
1573
+ src = ImageOps.exif_transpose(img).convert("RGB")
1574
+ w, h = src.size
1575
+ s = min(w, h)
1576
+ src = src.crop(((w - s) // 2, (h - s) // 2, (w + s) // 2, (h + s) // 2))
1577
+ return src.resize((size, size), PILImage.LANCZOS)
1578
+
1579
+
1580
+ @spaces.GPU(duration=120)
1581
+ def _img2img_infer(src):
1582
+ """Develop one PIL photo into a watercolour PIL image, inside a @spaces.GPU
1583
+ context. Builds/reuses the pipeline here (never in the main process, never
1584
+ pickled across the boundary). `src` and the returned image are PIL (both
1585
+ picklable), the only things that cross the GPU boundary. Generous duration
1586
+ because the FIRST call also pays the ~14 GB cold load; later calls reuse the
1587
+ cached pipe and are fast."""
1588
+ import torch
1589
+ pipe = _ensure_img2img_pipe()
1590
+ if pipe is None:
1591
+ raise RuntimeError("img2img pipeline could not be initialized")
1592
+ sq = _img2img_square(src, size=IMG2IMG_SIZE)
1593
+ # Trigger-token prompt only makes sense once the LoRA is loaded; otherwise
1594
+ # use the generic base-model watercolour prompt (stage 1).
1595
+ prompt = IMG2IMG_PROMPT if _img2img_state.get("lora_loaded") else IMG2IMG_BASE_PROMPT
1596
+ device = getattr(pipe, "device", None)
1597
+ generator = torch.Generator(
1598
+ str(device) if device is not None else "cuda"
1599
+ ).manual_seed(IMG2IMG_SEED)
1600
+ result = pipe(
1601
+ prompt=prompt,
1602
+ image=sq,
1603
+ height=IMG2IMG_SIZE,
1604
+ width=IMG2IMG_SIZE,
1605
+ num_inference_steps=IMG2IMG_STEPS,
1606
+ guidance_scale=IMG2IMG_GUIDANCE,
1607
+ generator=generator,
1608
+ )
1609
+ return result.images[0]
1610
+
1611
+
1612
+ def _pil_to_data_url(img, fmt: str = "PNG") -> str:
1613
+ import base64
1614
+ buf = io.BytesIO()
1615
+ img.save(buf, format=fmt)
1616
+ b64 = base64.b64encode(buf.getvalue()).decode("ascii")
1617
+ return f"data:image/{fmt.lower()};base64,{b64}"
1618
+
1619
+
1620
+ @app.post("/api/img2img")
1621
+ async def img2img_rest(request: Request):
1622
+ """Develop one uploaded photo into a watercolour painting.
1623
+ Body: {data_url: "data:image/...;base64,...", name?: str}.
1624
+ Returns {available, image: <png data url>, model}. When the pipeline can't
1625
+ run on this host it returns {available: false, image: null} so the caller can
1626
+ keep the original photo rather than treating it as an error."""
1627
+ import base64
1628
+ if not _img2img_prep():
1629
+ return {"available": False, "image": None, "model": IMG2IMG_MODEL_ID}
1630
+ try:
1631
+ body = await request.json()
1632
+ except Exception: # noqa: BLE001
1633
+ raise HTTPException(status_code=400, detail="invalid JSON body")
1634
+ data_url = (body or {}).get("data_url", "")
1635
+ name = str((body or {}).get("name", "upload"))[:60]
1636
+ if not data_url:
1637
+ raise HTTPException(status_code=400, detail="missing data_url")
1638
+ try:
1639
+ b64 = data_url.split(",", 1)[1] if "," in data_url else data_url
1640
+ raw = base64.b64decode(b64)
1641
+ from PIL import Image
1642
+ src = Image.open(io.BytesIO(raw))
1643
+ with traced("img2img", model=IMG2IMG_MODEL_ID, file=name) as t:
1644
+ out = _img2img_infer(src)
1645
+ t.set_output({"size": list(out.size)})
1646
+ return {"available": True, "image": _pil_to_data_url(out), "model": IMG2IMG_MODEL_ID}
1647
+ except HTTPException:
1648
+ raise
1649
+ except Exception as exc: # noqa: BLE001
1650
+ print(f"[img2img] generation failed: {exc}")
1651
+ raise HTTPException(status_code=500, detail=str(exc))
1652
+
1653
+
1654
  # --------------------------------------------------------------------------- #
1655
  # Voice → text via NVIDIA NeMo ASR (item 5). Streaming Nemotron model; needs a
1656
  # GPU to be practical. Loaded lazily; if NeMo/torch/GPU are missing the endpoint
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ libheif-dev
requirements.txt CHANGED
@@ -1,8 +1,11 @@
1
  gradio
2
  uvicorn[standard]
3
  transformers
4
- diffusers
 
 
5
  accelerate
 
6
  hf_xet
7
  huggingface_hub
8
  soundfile>=0.12
 
1
  gradio
2
  uvicorn[standard]
3
  transformers
4
+ # Flux2KleinPipeline (img2img watercolour develop) is not in a released diffusers
5
+ # yet — it must come from git main. See backend/hf/3_infer_img2img.py.
6
+ diffusers @ git+https://github.com/huggingface/diffusers.git
7
  accelerate
8
+ pillow-heif # HEIC/HEIF source photos for the img2img develop step
9
  hf_xet
10
  huggingface_hub
11
  soundfile>=0.12