Spaces:
Running on Zero
Running on Zero
| """ | |
| Soul Threading HF Space v12 Primeo-Fused | |
| Single-platform Hugging Face / ZeroGPU-ready Gradio app. | |
| Fuses: | |
| - v11 white-only real-template scanner | |
| - v11 no-hero-spam role-safe composition | |
| - v11 pocket/flap/overlay underlay continuity | |
| - Primeo-style pipeline objects, warnings, quality score, productionReady flag | |
| - Divergent layout brancher + Quality Tribunal | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import math | |
| import tempfile | |
| import zipfile | |
| from dataclasses import asdict, dataclass, field | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Optional, Tuple | |
| import cv2 | |
| import gradio as gr | |
| import numpy as np | |
| from PIL import Image, ImageDraw | |
| APP_VERSION = "soul-threading-hf-space-v12-primeo-fused" | |
| DEFAULT_BRANCH_COUNT = 4 | |
| try: | |
| import spaces | |
| except Exception: | |
| class spaces: # type: ignore | |
| def GPU(*args, **kwargs): | |
| def deco(fn): | |
| return fn | |
| return deco | |
| # ============================================================================= | |
| # Primeo-inspired pipeline data shapes | |
| # ============================================================================= | |
| class BoundingBox: | |
| x1: float | |
| y1: float | |
| x2: float | |
| y2: float | |
| def width(self) -> float: | |
| return self.x2 - self.x1 | |
| def height(self) -> float: | |
| return self.y2 - self.y1 | |
| def to_xyxy(self) -> List[float]: | |
| return [self.x1, self.y1, self.x2, self.y2] | |
| class PiecePlan: | |
| piece_id: str | |
| role: str | |
| crop_center: List[float] | |
| zoom: float | |
| protected_regions: List[str] = field(default_factory=list) | |
| avoid_regions: List[str] = field(default_factory=list) | |
| reason: str = "" | |
| continuity_parent: Optional[str] = None | |
| composition_mode: str = "role_safe_crop" | |
| class LayoutCandidate: | |
| candidate_id: str | |
| description: str | |
| piece_plans: List[PiecePlan] | |
| score: float = 0.0 | |
| warnings: List[str] = field(default_factory=list) | |
| production_ready: bool = False | |
| class PipelineOutput: | |
| version: str | |
| selected_candidate_id: str | |
| production_ready: bool | |
| quality_score: float | |
| status_message: str | |
| warnings: List[str] | |
| files: Dict[str, Any] | |
| analysis: Dict[str, Any] | |
| selected_plan: Dict[str, Any] | |
| all_candidates: List[Dict[str, Any]] | |
| scan_reports: List[Dict[str, Any]] | |
| piece_reports: List[Dict[str, Any]] | |
| # ============================================================================= | |
| # Utility | |
| # ============================================================================= | |
| def safe_name(name: str) -> str: | |
| return "".join(c if c.isalnum() or c in "._-" else "_" for c in name).strip("_") or "piece" | |
| def odd(n: int) -> int: | |
| return n if n % 2 else n + 1 | |
| def close_kernel(width: int, height: int) -> int: | |
| longest = max(width, height) | |
| if longest >= 7000: | |
| return 41 | |
| if longest >= 5000: | |
| return 35 | |
| if longest >= 3000: | |
| return 25 | |
| if longest >= 1500: | |
| return 15 | |
| return 7 | |
| def image_to_rgb_white_bg(path_or_img: Any) -> Image.Image: | |
| img = Image.open(path_or_img).convert("RGBA") if not isinstance(path_or_img, Image.Image) else path_or_img.convert("RGBA") | |
| bg = Image.new("RGBA", img.size, (255, 255, 255, 255)) | |
| bg.alpha_composite(img) | |
| return bg.convert("RGB") | |
| # ============================================================================= | |
| # White-only template scanner | |
| # ============================================================================= | |
| def get_white_only_mask(template_path: Path) -> Tuple[np.ndarray, Dict[str, Any]]: | |
| """ | |
| The user rule: | |
| only the white printable area marked off by colored seam/bleed/blur/safe lines is usable. | |
| Colored lines are restrictions, never pieces. | |
| """ | |
| img = Image.open(template_path).convert("RGBA") | |
| arr = np.array(img) | |
| rgb = arr[:, :, :3] | |
| h, w = rgb.shape[:2] | |
| r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2] | |
| hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV) | |
| sat = hsv[:, :, 1] | |
| val = hsv[:, :, 2] | |
| white = (r >= 238) & (g >= 238) & (b >= 238) & (sat <= 35) & (val >= 238) | |
| mask = white.astype(np.uint8) * 255 | |
| raw_ratio = float(white.mean()) | |
| # If full background is white, drop edge-connected background. | |
| if raw_ratio > 0.70: | |
| flood = mask.copy() | |
| flood_mask = np.zeros((h + 2, w + 2), np.uint8) | |
| cv2.floodFill(flood, flood_mask, (0, 0), 0) | |
| mask = flood | |
| # Close tiny guide/text holes inside the white printable region. | |
| # This does NOT admit colored guide lines because the seed mask is white-only. | |
| k = odd(close_kernel(w, h)) | |
| mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((k, k), np.uint8), iterations=1) | |
| mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8), iterations=1) | |
| return mask, { | |
| "template": template_path.name, | |
| "canvas_size": [int(w), int(h)], | |
| "white_ratio_before_repair": round(raw_ratio, 6), | |
| "white_ratio_after_repair": round(float((mask > 0).mean()), 6), | |
| "scanner_rule": "white_printable_area_only", | |
| "colored_lines_are_pieces": False, | |
| "guide_lines_exported": False, | |
| "close_kernel_px": int(k), | |
| } | |
| def extract_components(template_path: Path) -> Tuple[int, int, List[Dict[str, Any]], Dict[str, Any]]: | |
| mask, report = get_white_only_mask(template_path) | |
| h, w = mask.shape | |
| n, labels, stats, _ = cv2.connectedComponentsWithStats(mask, 8) | |
| comps: List[Dict[str, Any]] = [] | |
| rejected: List[Dict[str, Any]] = [] | |
| for i in range(1, n): | |
| x, y, cw, ch, area = [int(v) for v in stats[i]] | |
| area_ratio = area / max(w * h, 1) | |
| density = area / max(cw * ch, 1) | |
| reason: Optional[str] = None | |
| if area_ratio < 0.0015: | |
| reason = "too_small" | |
| elif cw < 80 or ch < 80: | |
| reason = "too_thin" | |
| elif density < 0.18: | |
| reason = "too_sparse" | |
| elif area_ratio > 0.92: | |
| reason = "likely_background" | |
| if reason: | |
| rejected.append({ | |
| "bbox": [x, y, cw, ch], | |
| "area_ratio": round(area_ratio, 6), | |
| "density": round(density, 4), | |
| "reason": reason, | |
| }) | |
| continue | |
| comps.append({ | |
| "component_id": f"shape_{i:02d}", | |
| "bbox": [x, y, cw, ch], | |
| "mask": (labels == i).astype(np.uint8) * 255, | |
| "area": int(area), | |
| "area_ratio": round(area_ratio, 6), | |
| "density": round(density, 4), | |
| }) | |
| comps.sort(key=lambda c: (c["bbox"][1], c["bbox"][0])) | |
| report["component_count"] = len(comps) | |
| report["rejected_count"] = len(rejected) | |
| report["rejected_examples"] = rejected[:20] | |
| return w, h, comps, report | |
| # ============================================================================= | |
| # Artwork analysis | |
| # ============================================================================= | |
| def cv_subject_box(img: Image.Image) -> Dict[str, Any]: | |
| arr = np.array(img.convert("RGB")) | |
| h, w = arr.shape[:2] | |
| gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY) | |
| edges = cv2.Canny(gray, 70, 170) | |
| hsv = cv2.cvtColor(arr, cv2.COLOR_RGB2HSV) | |
| sat = hsv[:, :, 1] | |
| val = hsv[:, :, 2] | |
| heat = (edges.astype(np.float32) / 255.0) * 0.60 | |
| heat += (sat.astype(np.float32) / 255.0) * 0.30 | |
| heat += ((255 - val).astype(np.float32) / 255.0) * 0.10 | |
| heat = cv2.GaussianBlur(heat, (31, 31), 0) | |
| ys, xs = np.where(heat > np.percentile(heat, 86)) | |
| if len(xs) < 30: | |
| box = [int(w * 0.25), int(h * 0.15), int(w * 0.75), int(h * 0.85)] | |
| else: | |
| box = [ | |
| int(np.percentile(xs, 4)), | |
| int(np.percentile(ys, 4)), | |
| int(np.percentile(xs, 96)), | |
| int(np.percentile(ys, 96)), | |
| ] | |
| x1, y1, x2, y2 = box | |
| cx = ((x1 + x2) / 2) / max(w, 1) | |
| cy = ((y1 + y2) / 2) / max(h, 1) | |
| # Rough text map from long horizontal high-contrast components. | |
| text_risk = detect_text_like_regions(arr) | |
| return { | |
| "bbox_xyxy": box, | |
| "center_xy_norm": [round(cx, 4), round(cy, 4)], | |
| "method": "opencv_saliency", | |
| "text_like_regions": text_risk, | |
| } | |
| def detect_text_like_regions(rgb: np.ndarray) -> List[Dict[str, Any]]: | |
| gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) | |
| edges = cv2.Canny(gray, 80, 180) | |
| kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (25, 5)) | |
| joined = cv2.dilate(edges, kernel, iterations=1) | |
| n, labels, stats, _ = cv2.connectedComponentsWithStats(joined, 8) | |
| h, w = gray.shape[:2] | |
| regs = [] | |
| for i in range(1, n): | |
| x, y, cw, ch, area = [int(v) for v in stats[i]] | |
| if area < 300: | |
| continue | |
| aspect = cw / max(ch, 1) | |
| if aspect >= 3.0 and cw > w * 0.08 and ch < h * 0.18: | |
| regs.append({ | |
| "bbox_xyxy_norm": [round(x/w, 4), round(y/h, 4), round((x+cw)/w, 4), round((y+ch)/h, 4)], | |
| "aspect": round(aspect, 2), | |
| "reason": "text_like_horizontal_component", | |
| }) | |
| return regs[:12] | |
| def analyze_artwork(img: Image.Image, focal_hint: str = "") -> Dict[str, Any]: | |
| subject = cv_subject_box(img) | |
| cx, cy = subject["center_xy_norm"] | |
| front_center = [float(np.clip(cx, 0.40, 0.56)), float(np.clip(cy, 0.34, 0.58))] | |
| return { | |
| "version": APP_VERSION, | |
| "source_size": list(img.size), | |
| "focal_hint": focal_hint, | |
| "primary_subject": subject, | |
| "composition_law": "hero_front_only; abstract_support_elsewhere; pocket_inherits_front_underlay", | |
| "zones": { | |
| "front_hero": {"center": front_center, "zoom": 1.05}, | |
| "front_hero_tight": {"center": front_center, "zoom": 1.22}, | |
| "back_atmosphere": {"center": [0.72, 0.48], "zoom": 1.28}, | |
| "back_wide": {"center": [0.52, 0.50], "zoom": 1.02}, | |
| "hood_atmosphere_left": {"center": [0.25, 0.20], "zoom": 1.38}, | |
| "hood_atmosphere_right": {"center": [0.78, 0.22], "zoom": 1.38}, | |
| "sleeve_left_motion": {"center": [0.22, 0.55], "zoom": 1.40}, | |
| "sleeve_right_motion": {"center": [0.82, 0.56], "zoom": 1.40}, | |
| "trim_texture": {"center": [0.50, 0.88], "zoom": 1.75}, | |
| "label_texture": {"center": [0.50, 0.18], "zoom": 2.20}, | |
| }, | |
| } | |
| # ============================================================================= | |
| # Template and role handling | |
| # ============================================================================= | |
| def classify_piece(template_name: str, component_index: int, bbox: List[int], canvas_size: Tuple[int, int]) -> str: | |
| n = template_name.lower() | |
| if "front" in n: | |
| return "front_body" if component_index == 0 else "trim" | |
| if "back" in n: | |
| return "back_body" if component_index == 0 else "trim" | |
| if "hood" in n: | |
| return "hood" | |
| if "sleeve" in n: | |
| return "sleeve" if component_index < 2 else "trim" | |
| if "pocket" in n or "kangaroo" in n: | |
| return "overlay_pocket" | |
| if any(word in n for word in ["flap", "placket", "overlay", "guard", "welt"]): | |
| return "overlay" | |
| if "label" in n: | |
| return "inside_label" | |
| # Fallback geometry hints. | |
| _, _, pw, ph = bbox | |
| ratio = pw / max(ph, 1) | |
| if ratio > 1.5 and ph < canvas_size[1] * 0.25: | |
| return "trim" | |
| return "garment_piece" | |
| def template_files_from_zip(template_zip: str, work: Path) -> List[Path]: | |
| tdir = work / "template" | |
| tdir.mkdir(parents=True, exist_ok=True) | |
| with zipfile.ZipFile(template_zip, "r") as z: | |
| z.extractall(tdir) | |
| files = [ | |
| p for p in tdir.rglob("*") | |
| if p.suffix.lower() in [".png", ".jpg", ".jpeg", ".webp"] | |
| and "__macosx" not in str(p).lower() | |
| ] | |
| def sort_key(p: Path): | |
| n = p.name.lower() | |
| if "front" in n: return (0, n) | |
| if "back" in n: return (1, n) | |
| if "hood" in n: return (2, n) | |
| if "sleeve" in n: return (3, n) | |
| if "pocket" in n or "kangaroo" in n: return (4, n) | |
| if "label" in n: return (5, n) | |
| return (9, n) | |
| return sorted(files, key=sort_key) | |
| # ============================================================================= | |
| # Divergent brancher and quality tribunal | |
| # ============================================================================= | |
| def build_candidate(candidate_id: str, description: str, roles: List[Tuple[str, str]], analysis: Dict[str, Any]) -> LayoutCandidate: | |
| z = analysis["zones"] | |
| piece_plans: List[PiecePlan] = [] | |
| for piece_key, role in roles: | |
| if role == "front_body": | |
| zone = "front_hero_tight" if candidate_id == "hero_tight" else "front_hero" | |
| plan = PiecePlan(piece_key, role, z[zone]["center"], z[zone]["zoom"], ["face", "main_subject"], ["seams"], "hero anchor on front only", composition_mode=zone) | |
| elif role == "back_body": | |
| zone = "back_wide" if candidate_id == "gallery_wrap" else "back_atmosphere" | |
| plan = PiecePlan(piece_key, role, z[zone]["center"], z[zone]["zoom"], [], ["face", "primary_text"], "supporting back crop avoids front hero repeat", composition_mode=zone) | |
| elif role == "hood": | |
| zone = "hood_atmosphere_left" if len(piece_plans) % 2 == 0 else "hood_atmosphere_right" | |
| plan = PiecePlan(piece_key, role, z[zone]["center"], z[zone]["zoom"], [], ["face", "eyes", "text"], "hood receives abstract atmosphere only", composition_mode=zone) | |
| elif role == "sleeve": | |
| zone = "sleeve_left_motion" if len(piece_plans) % 2 == 0 else "sleeve_right_motion" | |
| plan = PiecePlan(piece_key, role, z[zone]["center"], z[zone]["zoom"], [], ["face", "text"], "sleeve receives motion/texture flow", composition_mode=zone) | |
| elif role in ["overlay_pocket", "overlay"]: | |
| plan = PiecePlan(piece_key, role, [0.5, 0.7], 1.0, ["parent_underlay"], ["fresh_crop"], "overlay inherits rendered parent underlay", continuity_parent="front_body", composition_mode="exact_parent_underlay") | |
| elif role == "inside_label": | |
| zone = "label_texture" | |
| plan = PiecePlan(piece_key, role, z[zone]["center"], z[zone]["zoom"], [], ["face", "text"], "label receives simple hidden detail", composition_mode=zone) | |
| else: | |
| zone = "trim_texture" | |
| plan = PiecePlan(piece_key, role, z[zone]["center"], z[zone]["zoom"], [], ["face", "text"], "trim receives low-detail texture", composition_mode=zone) | |
| piece_plans.append(plan) | |
| return LayoutCandidate(candidate_id=candidate_id, description=description, piece_plans=piece_plans) | |
| def branch_layouts(piece_roles: List[Tuple[str, str]], analysis: Dict[str, Any], branch_count: int = DEFAULT_BRANCH_COUNT) -> List[LayoutCandidate]: | |
| candidates = [ | |
| build_candidate("balanced_safe", "Default safe AOP: hero front, support crops everywhere else.", piece_roles, analysis), | |
| build_candidate("hero_tight", "Tighter front hero with stricter support crops.", piece_roles, analysis), | |
| build_candidate("gallery_wrap", "Wider atmospheric back crop while preserving no-hero-spam.", piece_roles, analysis), | |
| build_candidate("abstract_safe", "Maximum seam safety: abstract/motion crops dominate all non-front pieces.", piece_roles, analysis), | |
| ] | |
| return candidates[:max(1, min(branch_count, len(candidates)))] | |
| def quality_tribunal(candidate: LayoutCandidate, piece_roles: List[Tuple[str, str]], analysis: Dict[str, Any], require_pocket_continuity: bool = True) -> LayoutCandidate: | |
| score = 100.0 | |
| warnings: List[str] = [] | |
| front_hero_count = 0 | |
| nonfront_hero_count = 0 | |
| overlay_count = 0 | |
| for plan in candidate.piece_plans: | |
| if plan.role == "front_body" and "hero" in plan.composition_mode: | |
| front_hero_count += 1 | |
| if plan.role != "front_body" and "hero" in plan.composition_mode: | |
| nonfront_hero_count += 1 | |
| if plan.role in ["overlay_pocket", "overlay"]: | |
| overlay_count += 1 | |
| if require_pocket_continuity and plan.composition_mode != "exact_parent_underlay": | |
| score -= 30 | |
| warnings.append(f"{plan.piece_id}: overlay did not inherit front underlay.") | |
| if plan.role in ["hood", "sleeve", "trim"] and ("face" not in plan.avoid_regions and "eyes" not in plan.avoid_regions): | |
| score -= 10 | |
| warnings.append(f"{plan.piece_id}: risky non-front piece did not avoid face/eyes.") | |
| if plan.zoom < 0.85: | |
| score -= 5 | |
| warnings.append(f"{plan.piece_id}: low zoom could expose weak crop edges.") | |
| if front_hero_count == 0: | |
| score -= 20 | |
| warnings.append("No clear front hero assignment.") | |
| if nonfront_hero_count > 0: | |
| score -= 35 * nonfront_hero_count | |
| warnings.append("Hero spam detected on non-front piece.") | |
| text_regions = analysis.get("primary_subject", {}).get("text_like_regions", []) | |
| if text_regions: | |
| score -= min(8, len(text_regions) * 2) | |
| warnings.append(f"Text-like regions detected: {len(text_regions)}. Avoid seam-heavy pieces.") | |
| score = max(0.0, min(100.0, score)) | |
| candidate.score = round(score, 2) | |
| candidate.warnings = warnings | |
| candidate.production_ready = score >= 75 and nonfront_hero_count == 0 | |
| return candidate | |
| def choose_best_candidate(candidates: List[LayoutCandidate]) -> LayoutCandidate: | |
| return sorted(candidates, key=lambda c: (c.production_ready, c.score), reverse=True)[0] | |
| # ============================================================================= | |
| # Rendering | |
| # ============================================================================= | |
| def crop_cover(img: Image.Image, target_w: int, target_h: int, center: List[float], zoom: float) -> Tuple[Image.Image, Dict[str, Any]]: | |
| sw, sh = img.size | |
| tr = target_w / max(target_h, 1) | |
| sr = sw / max(sh, 1) | |
| if sr > tr: | |
| crop_h = sh / zoom | |
| crop_w = crop_h * tr | |
| else: | |
| crop_w = sw / zoom | |
| crop_h = crop_w / max(tr, 0.0001) | |
| cw, ch = max(2, int(round(crop_w))), max(2, int(round(crop_h))) | |
| cx, cy = center[0] * sw, center[1] * sh | |
| x1 = max(0, min(int(round(cx - cw / 2)), sw - cw)) | |
| y1 = max(0, min(int(round(cy - ch / 2)), sh - ch)) | |
| rect = [x1, y1, x1 + cw, y1 + ch] | |
| crop = img.crop(tuple(rect)).resize((target_w, target_h), Image.Resampling.LANCZOS) | |
| return crop, {"source_crop_xyxy": rect, "center": center, "zoom": zoom} | |
| def apply_mask(img: Image.Image, mask: Image.Image) -> Image.Image: | |
| out = img.convert("RGBA") | |
| out.putalpha(mask.convert("L")) | |
| return out | |
| def overlay_from_front_underlay(front_img: Image.Image, target_w: int, target_h: int) -> Tuple[Image.Image, Dict[str, Any]]: | |
| fw, fh = front_img.size | |
| ratio = target_w / max(target_h, 1) | |
| cover_w = int(fw * 0.64) | |
| cover_h = int(cover_w / max(ratio, 0.001)) | |
| if cover_h > int(fh * 0.34): | |
| cover_h = int(fh * 0.30) | |
| cover_w = int(cover_h * ratio) | |
| x1 = max(0, int((fw - cover_w) / 2)) | |
| y1 = min(max(0, int(fh * 0.58)), max(0, fh - cover_h)) | |
| x2, y2 = min(fw, x1 + cover_w), min(fh, y1 + cover_h) | |
| crop = front_img.crop((x1, y1, x2, y2)).resize((target_w, target_h), Image.Resampling.LANCZOS) | |
| return crop, { | |
| "method": "sample_rendered_front_underlay", | |
| "front_coverage_xyxy": [x1, y1, x2, y2], | |
| "rule": "pocket/flap/overlay inherits front render, not a fresh crop", | |
| } | |
| def make_contact_sheet(paths: List[str], out_path: Path) -> None: | |
| thumbs = [] | |
| for p in paths: | |
| im = Image.open(p).convert("RGBA") | |
| bg = Image.new("RGBA", im.size, (255, 255, 255, 255)) | |
| bg.alpha_composite(im) | |
| bg = bg.convert("RGB") | |
| bg.thumbnail((260, 260)) | |
| tile = Image.new("RGB", (290, 330), "white") | |
| tile.paste(bg, ((290 - bg.width) // 2, 10)) | |
| d = ImageDraw.Draw(tile) | |
| d.text((10, 292), Path(p).name[:36], fill=(0, 0, 0)) | |
| thumbs.append(tile) | |
| cols = 3 | |
| rows = max(1, math.ceil(len(thumbs) / cols)) | |
| sheet = Image.new("RGB", (cols * 290, rows * 330), "white") | |
| for i, im in enumerate(thumbs): | |
| sheet.paste(im, ((i % cols) * 290, (i // cols) * 330)) | |
| sheet.save(out_path, quality=92) | |
| def find_plan(selected: LayoutCandidate, piece_key: str, role: str) -> PiecePlan: | |
| for plan in selected.piece_plans: | |
| if plan.piece_id == piece_key: | |
| return plan | |
| for plan in selected.piece_plans: | |
| if plan.role == role: | |
| return plan | |
| return PiecePlan(piece_key, role, [0.5, 0.5], 1.2, [], ["face", "text"], "fallback role-safe plan") | |
| # ============================================================================= | |
| # Main pipeline | |
| # ============================================================================= | |
| def run_pipeline( | |
| artwork_file, | |
| template_zip_file, | |
| focal_hint: str, | |
| branch_count: int, | |
| require_pocket_continuity: bool, | |
| export_full_canvases: bool, | |
| ): | |
| if artwork_file is None: | |
| raise gr.Error("Upload artwork first.") | |
| if template_zip_file is None: | |
| raise gr.Error("Upload an AOP template ZIP first.") | |
| work = Path(tempfile.mkdtemp(prefix="soul_threading_v12_")) | |
| out_dir = work / "outputs" | |
| print_dir = out_dir / "print" | |
| manifest_dir = out_dir / "manifest" | |
| preview_dir = out_dir / "preview" | |
| for d in [print_dir, manifest_dir, preview_dir]: | |
| d.mkdir(parents=True, exist_ok=True) | |
| artwork = Image.open(artwork_file).convert("RGBA") | |
| analysis = analyze_artwork(artwork, focal_hint=focal_hint or "") | |
| template_files = template_files_from_zip(template_zip_file, work) | |
| scan_reports: List[Dict[str, Any]] = [] | |
| piece_meta: List[Dict[str, Any]] = [] | |
| roles_for_brancher: List[Tuple[str, str]] = [] | |
| # Pre-scan so brancher sees the whole garment. | |
| scanned_templates = [] | |
| for template_path in template_files: | |
| width, height, comps, scan_report = extract_components(template_path) | |
| scan_reports.append(scan_report) | |
| scanned_templates.append((template_path, width, height, comps)) | |
| for idx, comp in enumerate(comps): | |
| role = classify_piece(template_path.name, idx, comp["bbox"], (width, height)) | |
| piece_key = f"{safe_name(template_path.stem)}__piece_{idx + 1:02d}_{role}" | |
| roles_for_brancher.append((piece_key, role)) | |
| if not roles_for_brancher: | |
| raise gr.Error("No printable white areas found in the template ZIP.") | |
| candidates = branch_layouts(roles_for_brancher, analysis, int(branch_count)) | |
| candidates = [quality_tribunal(c, roles_for_brancher, analysis, require_pocket_continuity) for c in candidates] | |
| selected = choose_best_candidate(candidates) | |
| gallery: List[str] = [] | |
| front_underlay: Optional[Image.Image] = None | |
| piece_reports: List[Dict[str, Any]] = [] | |
| for template_path, width, height, comps in scanned_templates: | |
| if not comps: | |
| piece_reports.append({"template": template_path.name, "status": "no white printable pieces found"}) | |
| continue | |
| full_canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0)) | |
| for idx, comp in enumerate(comps): | |
| x, y, pw, ph = comp["bbox"] | |
| role = classify_piece(template_path.name, idx, comp["bbox"], (width, height)) | |
| piece_key = f"{safe_name(template_path.stem)}__piece_{idx + 1:02d}_{role}" | |
| plan = find_plan(selected, piece_key, role) | |
| mask = Image.fromarray(comp["mask"], "L").crop((x, y, x + pw, y + ph)) | |
| if role in ["overlay_pocket", "overlay"] and front_underlay is not None and require_pocket_continuity: | |
| crop, crop_report = overlay_from_front_underlay(front_underlay, pw, ph) | |
| reason = "overlay continuity: sampled from rendered front underlay" | |
| else: | |
| crop, crop_report = crop_cover(artwork, pw, ph, plan.crop_center, plan.zoom) | |
| crop_report["composition_mode"] = plan.composition_mode | |
| reason = plan.reason | |
| piece = apply_mask(crop, mask) | |
| piece_name = f"{piece_key}.png" | |
| piece_path = print_dir / piece_name | |
| piece.save(piece_path) | |
| gallery.append(str(piece_path)) | |
| full_canvas.alpha_composite(piece, (x, y)) | |
| if role == "front_body" and front_underlay is None: | |
| front_underlay = piece.copy() | |
| piece_reports.append({ | |
| "template": template_path.name, | |
| "piece": piece_name, | |
| "role": role, | |
| "bbox": comp["bbox"], | |
| "assignment_reason": reason, | |
| "crop_report": crop_report, | |
| "white_only_scanner": True, | |
| "colored_lines_used_as_pieces": False, | |
| "status": "mapped", | |
| }) | |
| if export_full_canvases: | |
| full_canvas.save(print_dir / f"{safe_name(template_path.stem)}__full_canvas.png") | |
| sheet_path = preview_dir / "contact_sheet.jpg" | |
| make_contact_sheet(gallery, sheet_path) | |
| status_msg = "Production ready." if selected.production_ready else "Needs review: Quality Tribunal found risks." | |
| pipeline_output = PipelineOutput( | |
| version=APP_VERSION, | |
| selected_candidate_id=selected.candidate_id, | |
| production_ready=selected.production_ready, | |
| quality_score=selected.score, | |
| status_message=status_msg, | |
| warnings=selected.warnings, | |
| files={ | |
| "piece_png_count": len(gallery), | |
| "contact_sheet": "preview/contact_sheet.jpg", | |
| "zip_name": "soul_threading_v12_primeo_fused_outputs.zip", | |
| }, | |
| analysis=analysis, | |
| selected_plan=asdict(selected), | |
| all_candidates=[asdict(c) for c in candidates], | |
| scan_reports=scan_reports, | |
| piece_reports=piece_reports, | |
| ) | |
| manifest = asdict(pipeline_output) | |
| (manifest_dir / "mapping_manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") | |
| (manifest_dir / "mapping_process_log.txt").write_text( | |
| "v12 Primeo-fused pipeline: one HF Space, white-only scanner, divergent layout brancher, Quality Tribunal, pocket continuity.\n", | |
| encoding="utf-8", | |
| ) | |
| zip_path = work / "soul_threading_v12_primeo_fused_outputs.zip" | |
| with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as z: | |
| for p in out_dir.rglob("*"): | |
| z.write(p, p.relative_to(out_dir)) | |
| return str(zip_path), gallery[:60], str(sheet_path), json.dumps(manifest, indent=2) | |
| # ============================================================================= | |
| # Gradio UI | |
| # ============================================================================= | |
| with gr.Blocks(title="Soul Threading v12 Primeo-Fused") as demo: | |
| gr.Markdown( | |
| """ | |
| # Soul Threading v12 Primeo-Fused 🧵 | |
| **One-platform Hugging Face / ZeroGPU-ready mapper.** | |
| This folds Primeo's pipeline thinking into the v11 production core: | |
| - white-only real-template scanner | |
| - no hero spam | |
| - pocket/flap/overlay continuity | |
| - divergent layout brancher | |
| - Quality Tribunal | |
| - production ZIP export | |
| """ | |
| ) | |
| with gr.Tab("Map Garment"): | |
| with gr.Row(): | |
| artwork = gr.Image(type="filepath", label="Artwork") | |
| template_zip = gr.File(file_types=[".zip"], label="AOP template ZIP") | |
| with gr.Row(): | |
| focal_hint = gr.Textbox(label="Focal hint", value="main character / face / hero subject", lines=1) | |
| branch_count = gr.Slider(1, 4, value=4, step=1, label="Divergent layout branches") | |
| require_pocket = gr.Checkbox(value=True, label="Require pocket/flap continuity") | |
| export_full = gr.Checkbox(value=True, label="Export full template canvases too") | |
| btn = gr.Button("Run Soul Threading v12 Mapper", variant="primary") | |
| out_zip = gr.File(label="Download production ZIP") | |
| gallery = gr.Gallery(label="Mapped print pieces", columns=3, height=720) | |
| sheet = gr.Image(label="Contact sheet") | |
| manifest = gr.Textbox(label="Manifest / Quality Tribunal", lines=28) | |
| btn.click( | |
| run_pipeline, | |
| inputs=[artwork, template_zip, focal_hint, branch_count, require_pocket, export_full], | |
| outputs=[out_zip, gallery, sheet, manifest], | |
| api_name="map_aop_v12", | |
| ) | |
| with gr.Tab("Rules"): | |
| gr.Markdown( | |
| """ | |
| ## Locked production rules | |
| ```txt | |
| Only white printable template areas become garment pieces. | |
| Colored seam / bleed / blur / safe lines are restrictions, never pieces. | |
| The front body gets the hero by default. | |
| Back, hood, sleeves, trim and label get support crops. | |
| Pocket/flap/overlay inherits the rendered parent underlay. | |
| Exported print PNGs are transparent outside the piece shape. | |
| ``` | |
| ## Primeo ideas fused in | |
| ```txt | |
| PipelineOutput | |
| PiecePlan | |
| qualityScore | |
| warnings | |
| productionReady | |
| divergent branch candidates | |
| Quality Tribunal | |
| ``` | |
| """ | |
| ) | |
| with gr.Tab("Next Upgrade Slots"): | |
| gr.Markdown( | |
| """ | |
| ## ZeroGPU model slots | |
| This app is ZeroGPU-ready and uses `@spaces.GPU` on the pipeline call. | |
| Next optional upgrades can happen inside this same Space: | |
| ```txt | |
| Grounding DINO detector | |
| SAM/SAM2 segmentation assist | |
| Florence OCR/text-region assist | |
| VLM art director | |
| template memory database | |
| ``` | |
| The deterministic export engine stays in charge of final production math. | |
| """ | |
| ) | |
| demo.queue(max_size=8).launch() | |