Spaces:
Sleeping
Sleeping
Update engine.py
Browse files
engine.py
CHANGED
|
@@ -159,6 +159,21 @@ def warm_up() -> None:
|
|
| 159 |
gc.collect()
|
| 160 |
_ensure_yunet_model() # pre-download face detector weights too
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
# ---------------------------------------------------------------------------
|
| 164 |
# Standards matrix
|
|
@@ -916,6 +931,307 @@ def format_compliance_markdown(checks: list["ComplianceCheck"]) -> str:
|
|
| 916 |
return "\n".join(lines)
|
| 917 |
|
| 918 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 919 |
# ---------------------------------------------------------------------------
|
| 920 |
# Orchestration β the single entry point ui.py calls
|
| 921 |
# ---------------------------------------------------------------------------
|
|
@@ -928,6 +1244,7 @@ def process_photo(
|
|
| 928 |
x_offset: float = 0.0,
|
| 929 |
y_offset: float = 0.0,
|
| 930 |
auto_straighten: bool = True,
|
|
|
|
| 931 |
) -> tuple[Image.Image, Optional[Image.Image], list["ComplianceCheck"], Image.Image, Image.Image, float]:
|
| 932 |
"""Full pipeline. Returns (single_photo, print_sheet_or_None,
|
| 933 |
compliance_checks, bg_removed_preview, face_only_thumbnail,
|
|
@@ -936,7 +1253,9 @@ def process_photo(
|
|
| 936 |
bg_removed_preview: the alpha-matted subject on transparent background,
|
| 937 |
at the same size as `bounded` β this is a display artifact for the UI's
|
| 938 |
stage-by-stage view (mirrors what cutout.pro shows as its "Result"
|
| 939 |
-
step), not used further in the pipeline itself.
|
|
|
|
|
|
|
| 940 |
|
| 941 |
face_only_thumbnail: a tight square crop around the detected face,
|
| 942 |
also transparent-background β display-only, same purpose.
|
|
@@ -948,6 +1267,10 @@ def process_photo(
|
|
| 948 |
see straighten_image()'s docstring for why this is the physically
|
| 949 |
correct operation for a single-subject photo, not a head-only crop.
|
| 950 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 951 |
Raises ValueError with a user-facing message on any recoverable
|
| 952 |
failure (no face found, bad spec key, etc) β ui.py surfaces these via
|
| 953 |
gr.Error rather than letting a raw traceback reach the user.
|
|
@@ -988,15 +1311,34 @@ def process_photo(
|
|
| 988 |
checks = check_compliance(bounded, face, spec)
|
| 989 |
|
| 990 |
matted = segment_alpha(bounded)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 991 |
del bounded
|
| 992 |
gc.collect()
|
| 993 |
|
| 994 |
-
# --- display-only face thumbnail, built from `matted`
|
| 995 |
-
# consumed by crop_to_spec() below. Square crop, generous
|
| 996 |
-
# around the detected box so the thumbnail reads as "a
|
| 997 |
-
# a tight bounding-box rectangle. Clamped to image
|
| 998 |
-
# added here (unlike crop_to_spec) since this is
|
| 999 |
-
# spec-exact deliverable.
|
| 1000 |
pad = int(max(face.w, face.h) * 0.6)
|
| 1001 |
side = max(face.w, face.h) + 2 * pad
|
| 1002 |
fx0 = max(0, int(face.cx - side / 2))
|
|
@@ -1007,8 +1349,15 @@ def process_photo(
|
|
| 1007 |
|
| 1008 |
bg_removed_preview = matted.copy()
|
| 1009 |
|
| 1010 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1011 |
del matted
|
|
|
|
|
|
|
| 1012 |
gc.collect()
|
| 1013 |
|
| 1014 |
final_photo = composite_background(cropped, effective_bg)
|
|
@@ -1044,6 +1393,7 @@ def process_batch(
|
|
| 1044 |
x_offset: float = 0.0,
|
| 1045 |
y_offset: float = 0.0,
|
| 1046 |
auto_straighten: bool = True,
|
|
|
|
| 1047 |
) -> list[BatchResult]:
|
| 1048 |
"""Run process_photo across multiple images. Never lets one bad image
|
| 1049 |
(no face detected, corrupt file, etc) abort the whole batch β each
|
|
@@ -1070,6 +1420,7 @@ def process_batch(
|
|
| 1070 |
x_offset=x_offset,
|
| 1071 |
y_offset=y_offset,
|
| 1072 |
auto_straighten=auto_straighten,
|
|
|
|
| 1073 |
)
|
| 1074 |
results.append(BatchResult(filename, photo, None))
|
| 1075 |
except ValueError as e:
|
|
|
|
| 159 |
gc.collect()
|
| 160 |
_ensure_yunet_model() # pre-download face detector weights too
|
| 161 |
|
| 162 |
+
# Pre-download + warm the pose model too (outfit overlay feature).
|
| 163 |
+
# Wrapped in try/except: outfit overlay is an optional enhancement,
|
| 164 |
+
# so a warm-up failure here (e.g. onnxruntime missing, HF Hub hiccup)
|
| 165 |
+
# should not crash Space boot β apply_outfit()'s own error handling
|
| 166 |
+
# already degrades gracefully per-request if the pose model is
|
| 167 |
+
# unavailable.
|
| 168 |
+
try:
|
| 169 |
+
session = _get_pose_session()
|
| 170 |
+
dummy_pose = np.zeros((1, _MOVENET_INPUT_SIZE, _MOVENET_INPUT_SIZE, 3), dtype=np.int32)
|
| 171 |
+
session.run(None, {session.get_inputs()[0].name: dummy_pose})
|
| 172 |
+
del dummy_pose
|
| 173 |
+
gc.collect()
|
| 174 |
+
except Exception:
|
| 175 |
+
pass
|
| 176 |
+
|
| 177 |
|
| 178 |
# ---------------------------------------------------------------------------
|
| 179 |
# Standards matrix
|
|
|
|
| 931 |
return "\n".join(lines)
|
| 932 |
|
| 933 |
|
| 934 |
+
# ---------------------------------------------------------------------------
|
| 935 |
+
# Stage 5.5 β outfit overlay (garment compositing)
|
| 936 |
+
# ---------------------------------------------------------------------------
|
| 937 |
+
# How this works, honestly: this is composite-based garment overlay, the
|
| 938 |
+
# same technique cutout.pro's passport tool uses (confirmed by inspecting
|
| 939 |
+
# their garment assets β pre-rendered transparent PNGs cropped at the
|
| 940 |
+
# collar/shoulder line, not full-body generative reclothing). We detect
|
| 941 |
+
# the subject's shoulder keypoints, scale a pre-made garment PNG to match
|
| 942 |
+
# their shoulder width, and composite it over the torso region β under
|
| 943 |
+
# the face, over the original clothing. This is NOT clothing-aware
|
| 944 |
+
# (it won't preserve the person's actual shirt collar poking through a
|
| 945 |
+
# V-neck garment, for example) β it is a neck-down garment swap, which is
|
| 946 |
+
# exactly what passport-photo outfit tools need since only the shoulders-
|
| 947 |
+
# up region matters for the final crop.
|
| 948 |
+
try:
|
| 949 |
+
import onnxruntime as ort
|
| 950 |
+
_ONNXRUNTIME_AVAILABLE = True
|
| 951 |
+
except ImportError:
|
| 952 |
+
_ONNXRUNTIME_AVAILABLE = False
|
| 953 |
+
|
| 954 |
+
_MOVENET_MODEL_ID = "Xenova/movenet-singlepose-lightning"
|
| 955 |
+
_MOVENET_FILENAME = "onnx/model.onnx"
|
| 956 |
+
_MOVENET_INPUT_SIZE = 192 # MoveNet Lightning's fixed input resolution β
|
| 957 |
+
# not configurable per the model architecture, unlike YuNet's setInputSize.
|
| 958 |
+
|
| 959 |
+
_pose_session_lock = threading.Lock()
|
| 960 |
+
_pose_session: Optional["ort.InferenceSession"] = None
|
| 961 |
+
|
| 962 |
+
# COCO-style 17 keypoint indices MoveNet outputs, in order. We only need
|
| 963 |
+
# shoulders, but documenting the full layout avoids future confusion if
|
| 964 |
+
# more keypoints (hips, for a future full-body feature) get used later.
|
| 965 |
+
_KP_LEFT_SHOULDER = 5
|
| 966 |
+
_KP_RIGHT_SHOULDER = 6
|
| 967 |
+
|
| 968 |
+
|
| 969 |
+
def _get_pose_session() -> "ort.InferenceSession":
|
| 970 |
+
global _pose_session
|
| 971 |
+
if not _ONNXRUNTIME_AVAILABLE:
|
| 972 |
+
raise RuntimeError(
|
| 973 |
+
"onnxruntime is not installed β outfit overlay requires it. "
|
| 974 |
+
"Check requirements.txt."
|
| 975 |
+
)
|
| 976 |
+
with _pose_session_lock:
|
| 977 |
+
if _pose_session is None:
|
| 978 |
+
from huggingface_hub import hf_hub_download
|
| 979 |
+
|
| 980 |
+
model_path = hf_hub_download(_MOVENET_MODEL_ID, _MOVENET_FILENAME)
|
| 981 |
+
# CPUExecutionProvider deliberately β MoveNet Lightning is a
|
| 982 |
+
# ~9MB model that runs in single-digit milliseconds on CPU;
|
| 983 |
+
# routing it through the ZeroGPU @spaces.GPU machinery would
|
| 984 |
+
# add GPU-attach overhead (seconds) for a task that doesn't
|
| 985 |
+
# need it. Only BiRefNet's much heavier segmentation pass
|
| 986 |
+
# (Stage 2) is worth the GPU round-trip.
|
| 987 |
+
_pose_session = ort.InferenceSession(
|
| 988 |
+
model_path, providers=["CPUExecutionProvider"]
|
| 989 |
+
)
|
| 990 |
+
return _pose_session
|
| 991 |
+
|
| 992 |
+
|
| 993 |
+
@dataclass(frozen=True)
|
| 994 |
+
class ShoulderKeypoints:
|
| 995 |
+
left_x: float
|
| 996 |
+
left_y: float
|
| 997 |
+
right_x: float
|
| 998 |
+
right_y: float
|
| 999 |
+
confidence: float # min of the two keypoint confidences
|
| 1000 |
+
|
| 1001 |
+
@property
|
| 1002 |
+
def width_px(self) -> float:
|
| 1003 |
+
return abs(self.right_x - self.left_x)
|
| 1004 |
+
|
| 1005 |
+
@property
|
| 1006 |
+
def center_x(self) -> float:
|
| 1007 |
+
return (self.left_x + self.right_x) / 2
|
| 1008 |
+
|
| 1009 |
+
@property
|
| 1010 |
+
def center_y(self) -> float:
|
| 1011 |
+
return (self.left_y + self.right_y) / 2
|
| 1012 |
+
|
| 1013 |
+
|
| 1014 |
+
def detect_shoulders(image_rgb: Image.Image) -> Optional[ShoulderKeypoints]:
|
| 1015 |
+
"""Run MoveNet Lightning on the full bounded frame and return shoulder
|
| 1016 |
+
keypoints in the image's own pixel coordinates. Returns None (not a
|
| 1017 |
+
raised error) if confidence is too low β outfit overlay is an
|
| 1018 |
+
optional enhancement, so a low-confidence pose read should silently
|
| 1019 |
+
disable the feature for this photo rather than fail the whole
|
| 1020 |
+
pipeline the way a missing face does.
|
| 1021 |
+
"""
|
| 1022 |
+
session = _get_pose_session()
|
| 1023 |
+
img = image_rgb.convert("RGB")
|
| 1024 |
+
orig_w, orig_h = img.size
|
| 1025 |
+
|
| 1026 |
+
resized = img.resize(
|
| 1027 |
+
(_MOVENET_INPUT_SIZE, _MOVENET_INPUT_SIZE), Image.Resampling.BILINEAR
|
| 1028 |
+
)
|
| 1029 |
+
# MoveNet's published input contract: int32 tensor, NHWC, [0,255] raw
|
| 1030 |
+
# pixel values (no normalization) β this is the model's own expected
|
| 1031 |
+
# format, not a convention we chose.
|
| 1032 |
+
inp = np.array(resized, dtype=np.int32)[np.newaxis, ...]
|
| 1033 |
+
del resized
|
| 1034 |
+
|
| 1035 |
+
outputs = session.run(None, {session.get_inputs()[0].name: inp})
|
| 1036 |
+
del inp
|
| 1037 |
+
# Output shape (1,1,17,3): [y, x, confidence] per keypoint, normalized
|
| 1038 |
+
# to [0,1] against the model's own 192x192 input frame.
|
| 1039 |
+
keypoints = outputs[0][0, 0]
|
| 1040 |
+
del outputs
|
| 1041 |
+
gc.collect()
|
| 1042 |
+
|
| 1043 |
+
ly, lx, lc = keypoints[_KP_LEFT_SHOULDER]
|
| 1044 |
+
ry, rx, rc = keypoints[_KP_RIGHT_SHOULDER]
|
| 1045 |
+
confidence = float(min(lc, rc))
|
| 1046 |
+
|
| 1047 |
+
if confidence < 0.3:
|
| 1048 |
+
return None
|
| 1049 |
+
|
| 1050 |
+
return ShoulderKeypoints(
|
| 1051 |
+
left_x=float(lx) * orig_w,
|
| 1052 |
+
left_y=float(ly) * orig_h,
|
| 1053 |
+
right_x=float(rx) * orig_w,
|
| 1054 |
+
right_y=float(ry) * orig_h,
|
| 1055 |
+
confidence=confidence,
|
| 1056 |
+
)
|
| 1057 |
+
|
| 1058 |
+
|
| 1059 |
+
# ---------------------------------------------------------------------------
|
| 1060 |
+
# Garment asset registry
|
| 1061 |
+
# ---------------------------------------------------------------------------
|
| 1062 |
+
_GARMENTS_DIR = os.path.join(os.path.dirname(__file__), "assets", "garments")
|
| 1063 |
+
|
| 1064 |
+
|
| 1065 |
+
@dataclass(frozen=True)
|
| 1066 |
+
class GarmentAsset:
|
| 1067 |
+
garment_id: str
|
| 1068 |
+
label: str
|
| 1069 |
+
image: Image.Image # pre-loaded RGBA, cached for process lifetime
|
| 1070 |
+
shoulder_width_px: int
|
| 1071 |
+
collar_y_px: int
|
| 1072 |
+
collar_cx_px: int
|
| 1073 |
+
|
| 1074 |
+
|
| 1075 |
+
_garment_cache: dict[str, GarmentAsset] = {}
|
| 1076 |
+
_garment_cache_lock = threading.Lock()
|
| 1077 |
+
|
| 1078 |
+
# Display label per garment_id β kept separate from the filename/id so the
|
| 1079 |
+
# UI can show something human-friendly without renaming asset files.
|
| 1080 |
+
GARMENT_LABELS: dict[str, str] = {
|
| 1081 |
+
"mens_navy_suit_tie": "Men's Navy Suit + Tie",
|
| 1082 |
+
"mens_navy_suit_pocket_square": "Men's Navy Suit + Pocket Square",
|
| 1083 |
+
"womens_blush_blazer": "Women's Blush Blazer",
|
| 1084 |
+
}
|
| 1085 |
+
|
| 1086 |
+
|
| 1087 |
+
def list_garments() -> list[str]:
|
| 1088 |
+
"""Return available garment display labels, discovered from whatever
|
| 1089 |
+
normalized .png/.json pairs exist in assets/garments/ β so dropping in
|
| 1090 |
+
a new pair (via normalize_garments.py) makes it available without a
|
| 1091 |
+
code change here.
|
| 1092 |
+
"""
|
| 1093 |
+
if not os.path.isdir(_GARMENTS_DIR):
|
| 1094 |
+
return []
|
| 1095 |
+
ids = sorted(
|
| 1096 |
+
f[:-5] for f in os.listdir(_GARMENTS_DIR) if f.endswith(".json")
|
| 1097 |
+
)
|
| 1098 |
+
return [GARMENT_LABELS.get(gid, gid) for gid in ids]
|
| 1099 |
+
|
| 1100 |
+
|
| 1101 |
+
def _label_to_id(label: str) -> Optional[str]:
|
| 1102 |
+
for gid, lbl in GARMENT_LABELS.items():
|
| 1103 |
+
if lbl == label:
|
| 1104 |
+
return gid
|
| 1105 |
+
# Fallback: label IS the id (covers any garment dropped in without a
|
| 1106 |
+
# GARMENT_LABELS entry β list_garments() would have returned the raw
|
| 1107 |
+
# id as its own label in that case).
|
| 1108 |
+
if os.path.exists(os.path.join(_GARMENTS_DIR, f"{label}.json")):
|
| 1109 |
+
return label
|
| 1110 |
+
return None
|
| 1111 |
+
|
| 1112 |
+
|
| 1113 |
+
def _load_garment(garment_id: str) -> GarmentAsset:
|
| 1114 |
+
with _garment_cache_lock:
|
| 1115 |
+
if garment_id in _garment_cache:
|
| 1116 |
+
return _garment_cache[garment_id]
|
| 1117 |
+
|
| 1118 |
+
json_path = os.path.join(_GARMENTS_DIR, f"{garment_id}.json")
|
| 1119 |
+
png_path = os.path.join(_GARMENTS_DIR, f"{garment_id}.png")
|
| 1120 |
+
if not (os.path.exists(json_path) and os.path.exists(png_path)):
|
| 1121 |
+
raise ValueError(f"Unknown garment: {garment_id}")
|
| 1122 |
+
|
| 1123 |
+
import json
|
| 1124 |
+
|
| 1125 |
+
with open(json_path) as f:
|
| 1126 |
+
anchor = json.load(f)
|
| 1127 |
+
|
| 1128 |
+
img = Image.open(png_path).convert("RGBA")
|
| 1129 |
+
asset = GarmentAsset(
|
| 1130 |
+
garment_id=garment_id,
|
| 1131 |
+
label=GARMENT_LABELS.get(garment_id, garment_id),
|
| 1132 |
+
image=img,
|
| 1133 |
+
shoulder_width_px=anchor["shoulder_width_px"],
|
| 1134 |
+
collar_y_px=anchor["collar_y_px"],
|
| 1135 |
+
collar_cx_px=anchor["collar_cx_px"],
|
| 1136 |
+
)
|
| 1137 |
+
_garment_cache[garment_id] = asset
|
| 1138 |
+
return asset
|
| 1139 |
+
|
| 1140 |
+
|
| 1141 |
+
# Fallback ratio used when live shoulder detection fails/low-confidence:
|
| 1142 |
+
# garment shoulder-width as a multiple of face width. Derived from typical
|
| 1143 |
+
# adult head-to-shoulder proportions (shoulder span ~= 2.2-2.6x face
|
| 1144 |
+
# width for a frontal passport-style pose) β a reasonable default, not a
|
| 1145 |
+
# substitute for the real pose read when it's available.
|
| 1146 |
+
_FALLBACK_SHOULDER_TO_FACE_RATIO = 2.4
|
| 1147 |
+
|
| 1148 |
+
|
| 1149 |
+
def apply_outfit(
|
| 1150 |
+
bounded: Image.Image,
|
| 1151 |
+
matted: Image.Image,
|
| 1152 |
+
face: "FaceBox",
|
| 1153 |
+
garment_label: str,
|
| 1154 |
+
) -> Image.Image:
|
| 1155 |
+
"""Composite the chosen garment onto `matted` (the alpha-matted
|
| 1156 |
+
subject), aligned to detected shoulder keypoints when confidently
|
| 1157 |
+
available, falling back to a face-width-derived estimate otherwise.
|
| 1158 |
+
|
| 1159 |
+
Returns a NEW RGBA image β does not mutate `matted` in place, so the
|
| 1160 |
+
caller's reference to the pre-outfit matte stays valid if needed
|
| 1161 |
+
elsewhere (e.g. the bg_removed_preview stage thumbnail should show
|
| 1162 |
+
the ORIGINAL matte, not the outfit-composited one, matching what
|
| 1163 |
+
cutout.pro's own stage breakdown shows).
|
| 1164 |
+
|
| 1165 |
+
Garment is composited BELOW the face region β we paste the garment
|
| 1166 |
+
layer first, then paste the ORIGINAL matted subject's head/face
|
| 1167 |
+
region back on top, so the person's real face is never occluded by
|
| 1168 |
+
the garment PNG even if the garment's collar extends higher than the
|
| 1169 |
+
detected shoulder line.
|
| 1170 |
+
"""
|
| 1171 |
+
garment_id = _label_to_id(garment_label)
|
| 1172 |
+
if garment_id is None:
|
| 1173 |
+
raise ValueError(f"Unknown garment: {garment_label}")
|
| 1174 |
+
garment = _load_garment(garment_id)
|
| 1175 |
+
|
| 1176 |
+
shoulders = detect_shoulders(bounded)
|
| 1177 |
+
|
| 1178 |
+
if shoulders is not None and shoulders.confidence >= 0.3:
|
| 1179 |
+
target_width_px = shoulders.width_px
|
| 1180 |
+
target_cx = shoulders.center_x
|
| 1181 |
+
# Collar should sit at the shoulder line, not above/below it β
|
| 1182 |
+
# use the shoulder keypoints' own y as the collar target.
|
| 1183 |
+
target_collar_y = shoulders.center_y
|
| 1184 |
+
else:
|
| 1185 |
+
# Fallback: derive an approximate shoulder position from the
|
| 1186 |
+
# already-known face box, since we always have that.
|
| 1187 |
+
target_width_px = face.w * _FALLBACK_SHOULDER_TO_FACE_RATIO
|
| 1188 |
+
target_cx = face.cx
|
| 1189 |
+
# Shoulders sit below the chin by roughly one more face-height β
|
| 1190 |
+
# same order-of-magnitude approximation used elsewhere in this
|
| 1191 |
+
# file for anatomy without a direct measurement.
|
| 1192 |
+
target_collar_y = face.y + face.h * 1.9
|
| 1193 |
+
|
| 1194 |
+
scale = target_width_px / garment.shoulder_width_px
|
| 1195 |
+
new_w = max(1, int(round(garment.image.width * scale)))
|
| 1196 |
+
new_h = max(1, int(round(garment.image.height * scale)))
|
| 1197 |
+
scaled_garment = garment.image.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
| 1198 |
+
|
| 1199 |
+
# Position so the garment's own collar anchor point lands exactly at
|
| 1200 |
+
# (target_cx, target_collar_y) in the destination image.
|
| 1201 |
+
scaled_collar_x = garment.collar_cx_px * scale
|
| 1202 |
+
scaled_collar_y = garment.collar_y_px * scale
|
| 1203 |
+
paste_x = int(round(target_cx - scaled_collar_x))
|
| 1204 |
+
paste_y = int(round(target_collar_y - scaled_collar_y))
|
| 1205 |
+
|
| 1206 |
+
# Composite: start from a copy of matted, paste garment on top (using
|
| 1207 |
+
# its own alpha as the mask so transparent garment-PNG pixels don't
|
| 1208 |
+
# overwrite the subject), THEN paste the original head/shoulders
|
| 1209 |
+
# region from `matted` back on top of that β guarantees the face is
|
| 1210 |
+
# never covered by garment pixels regardless of alignment error.
|
| 1211 |
+
result = matted.copy()
|
| 1212 |
+
result.paste(scaled_garment, (paste_x, paste_y), scaled_garment)
|
| 1213 |
+
del scaled_garment
|
| 1214 |
+
gc.collect()
|
| 1215 |
+
|
| 1216 |
+
# Re-apply the original face region on top. Padding matches the
|
| 1217 |
+
# face-thumbnail crop elsewhere in this file (0.6x face size margin)
|
| 1218 |
+
# so the reinstated patch comfortably covers the whole head with a
|
| 1219 |
+
# soft-enough boundary that a hard rectangle edge is unlikely to fall
|
| 1220 |
+
# across a hairline in a way that reads as an obvious seam.
|
| 1221 |
+
pad = int(max(face.w, face.h) * 0.6)
|
| 1222 |
+
side = max(face.w, face.h) + 2 * pad
|
| 1223 |
+
fx0 = max(0, int(face.cx - side / 2))
|
| 1224 |
+
fy0 = max(0, int(face.cy - side / 2))
|
| 1225 |
+
fx1 = min(matted.width, fx0 + side)
|
| 1226 |
+
fy1 = min(matted.height, fy0 + side)
|
| 1227 |
+
face_patch = matted.crop((fx0, fy0, fx1, fy1))
|
| 1228 |
+
result.paste(face_patch, (fx0, fy0), face_patch)
|
| 1229 |
+
del face_patch
|
| 1230 |
+
gc.collect()
|
| 1231 |
+
|
| 1232 |
+
return result
|
| 1233 |
+
|
| 1234 |
+
|
| 1235 |
# ---------------------------------------------------------------------------
|
| 1236 |
# Orchestration β the single entry point ui.py calls
|
| 1237 |
# ---------------------------------------------------------------------------
|
|
|
|
| 1244 |
x_offset: float = 0.0,
|
| 1245 |
y_offset: float = 0.0,
|
| 1246 |
auto_straighten: bool = True,
|
| 1247 |
+
outfit_label: Optional[str] = None,
|
| 1248 |
) -> tuple[Image.Image, Optional[Image.Image], list["ComplianceCheck"], Image.Image, Image.Image, float]:
|
| 1249 |
"""Full pipeline. Returns (single_photo, print_sheet_or_None,
|
| 1250 |
compliance_checks, bg_removed_preview, face_only_thumbnail,
|
|
|
|
| 1253 |
bg_removed_preview: the alpha-matted subject on transparent background,
|
| 1254 |
at the same size as `bounded` β this is a display artifact for the UI's
|
| 1255 |
stage-by-stage view (mirrors what cutout.pro shows as its "Result"
|
| 1256 |
+
step), not used further in the pipeline itself. Shows the ORIGINAL
|
| 1257 |
+
matte even when outfit_label is set β outfit compositing is a
|
| 1258 |
+
downstream step, not part of what "background removed" should depict.
|
| 1259 |
|
| 1260 |
face_only_thumbnail: a tight square crop around the detected face,
|
| 1261 |
also transparent-background β display-only, same purpose.
|
|
|
|
| 1267 |
see straighten_image()'s docstring for why this is the physically
|
| 1268 |
correct operation for a single-subject photo, not a head-only crop.
|
| 1269 |
|
| 1270 |
+
outfit_label: display label of a garment from list_garments(), or
|
| 1271 |
+
None/"" to skip outfit overlay entirely (default β the original
|
| 1272 |
+
photo's clothing is used, exactly as before this feature existed).
|
| 1273 |
+
|
| 1274 |
Raises ValueError with a user-facing message on any recoverable
|
| 1275 |
failure (no face found, bad spec key, etc) β ui.py surfaces these via
|
| 1276 |
gr.Error rather than letting a raw traceback reach the user.
|
|
|
|
| 1311 |
checks = check_compliance(bounded, face, spec)
|
| 1312 |
|
| 1313 |
matted = segment_alpha(bounded)
|
| 1314 |
+
|
| 1315 |
+
# Outfit overlay needs `bounded` (real RGB pixels, for MoveNet's pose
|
| 1316 |
+
# read) β must run before `bounded` is freed below. If no outfit was
|
| 1317 |
+
# requested, skip entirely: zero cost, zero behavior change from
|
| 1318 |
+
# before this feature existed.
|
| 1319 |
+
outfitted = None
|
| 1320 |
+
if outfit_label:
|
| 1321 |
+
try:
|
| 1322 |
+
outfitted = apply_outfit(bounded, matted, face, outfit_label)
|
| 1323 |
+
except ValueError:
|
| 1324 |
+
raise # unknown garment label β genuine user-facing error
|
| 1325 |
+
except Exception:
|
| 1326 |
+
# Pose detection or compositing failed for a reason that
|
| 1327 |
+
# isn't the user's fault (e.g. onnxruntime hiccup) β degrade
|
| 1328 |
+
# gracefully to the original photo rather than failing the
|
| 1329 |
+
# whole generate. Outfit overlay is an enhancement, not a
|
| 1330 |
+
# core guarantee the way face detection is.
|
| 1331 |
+
outfitted = None
|
| 1332 |
+
|
| 1333 |
del bounded
|
| 1334 |
gc.collect()
|
| 1335 |
|
| 1336 |
+
# --- display-only face thumbnail, built from the ORIGINAL `matted`
|
| 1337 |
+
# before it's consumed by crop_to_spec() below. Square crop, generous
|
| 1338 |
+
# padding around the detected box so the thumbnail reads as "a
|
| 1339 |
+
# headshot," not a tight bounding-box rectangle. Clamped to image
|
| 1340 |
+
# bounds β no padding added here (unlike crop_to_spec) since this is
|
| 1341 |
+
# a preview, not a spec-exact deliverable.
|
| 1342 |
pad = int(max(face.w, face.h) * 0.6)
|
| 1343 |
side = max(face.w, face.h) + 2 * pad
|
| 1344 |
fx0 = max(0, int(face.cx - side / 2))
|
|
|
|
| 1349 |
|
| 1350 |
bg_removed_preview = matted.copy()
|
| 1351 |
|
| 1352 |
+
# Crop the outfitted version if one was produced, otherwise fall back
|
| 1353 |
+
# to the original matte β this is the only place the two diverge, so
|
| 1354 |
+
# everything downstream (crop/composite/print-sheet) is identical
|
| 1355 |
+
# code regardless of whether an outfit was applied.
|
| 1356 |
+
source_for_crop = outfitted if outfitted is not None else matted
|
| 1357 |
+
cropped = crop_to_spec(source_for_crop, face, spec, zoom=zoom, x_offset=x_offset, y_offset=y_offset)
|
| 1358 |
del matted
|
| 1359 |
+
if outfitted is not None:
|
| 1360 |
+
del outfitted
|
| 1361 |
gc.collect()
|
| 1362 |
|
| 1363 |
final_photo = composite_background(cropped, effective_bg)
|
|
|
|
| 1393 |
x_offset: float = 0.0,
|
| 1394 |
y_offset: float = 0.0,
|
| 1395 |
auto_straighten: bool = True,
|
| 1396 |
+
outfit_label: Optional[str] = None,
|
| 1397 |
) -> list[BatchResult]:
|
| 1398 |
"""Run process_photo across multiple images. Never lets one bad image
|
| 1399 |
(no face detected, corrupt file, etc) abort the whole batch β each
|
|
|
|
| 1420 |
x_offset=x_offset,
|
| 1421 |
y_offset=y_offset,
|
| 1422 |
auto_straighten=auto_straighten,
|
| 1423 |
+
outfit_label=outfit_label,
|
| 1424 |
)
|
| 1425 |
results.append(BatchResult(filename, photo, None))
|
| 1426 |
except ValueError as e:
|