hdremover commited on
Commit
ea56446
Β·
verified Β·
1 Parent(s): 47d4b05

Update engine.py

Browse files
Files changed (1) hide show
  1. engine.py +359 -8
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` before it's
995
- # consumed by crop_to_spec() below. Square crop, generous padding
996
- # around the detected box so the thumbnail reads as "a headshot," not
997
- # a tight bounding-box rectangle. Clamped to image bounds β€” no padding
998
- # added here (unlike crop_to_spec) since this is a preview, not a
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
- cropped = crop_to_spec(matted, face, spec, zoom=zoom, x_offset=x_offset, y_offset=y_offset)
 
 
 
 
 
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: