Soul_Threading / app.py
3rdaiOhpinFully's picture
Deploy v12 Primeo-fused single-space mapper
ae62fae
Raw
History Blame Contribute Delete
29.7 kB
"""
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
@staticmethod
def GPU(*args, **kwargs):
def deco(fn):
return fn
return deco
# =============================================================================
# Primeo-inspired pipeline data shapes
# =============================================================================
@dataclass
class BoundingBox:
x1: float
y1: float
x2: float
y2: float
@property
def width(self) -> float:
return self.x2 - self.x1
@property
def height(self) -> float:
return self.y2 - self.y1
def to_xyxy(self) -> List[float]:
return [self.x1, self.y1, self.x2, self.y2]
@dataclass
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"
@dataclass
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
@dataclass
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
# =============================================================================
@spaces.GPU(duration=180)
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()