Spaces:
Runtime error
Runtime error
File size: 9,397 Bytes
75e0882 746317a 75e0882 226ca6c 75e0882 226ca6c 75e0882 6ca17ee 75e0882 226ca6c 14e7ce6 75e0882 14e7ce6 75e0882 226ca6c 75e0882 6ca17ee 75e0882 226ca6c 75e0882 226ca6c 75e0882 14e7ce6 75e0882 226ca6c 75e0882 226ca6c 75e0882 14e7ce6 75e0882 226ca6c 14e7ce6 226ca6c 14e7ce6 226ca6c ba65fe9 5be2181 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 | """Vision pipeline: extract garment attributes from images using a VLM.
The pipeline uses a pluggable detector to locate individual garments in
an image, crops each one, then sends each crop to Gemma 3 4B via
llama-cpp-python for structured attribute extraction. Each garment gets
its own unique thumbnail.
"""
import base64
import io
import json
import logging
import re
from pathlib import Path
from PIL import Image
from .model_loader import model_manager, GARMENT_TYPES
from .detector import detect_and_crop
logger = logging.getLogger(__name__)
MAX_IMAGE_PIXELS = 512
SINGLE_GARMENT_PROMPT = """Analyze this image of a single clothing item carefully. Return a JSON object with these exact fields:
- "type": garment type (e.g. "sweater", "shirt", "jeans", "boots", "hat", "scarf", "belt", "bag")
- "color": primary color (e.g. "red", "blue", "black", "brown", "white", "beige")
- "material": fabric/material (e.g. "knit", "denim", "leather", "cotton", "silk", "polyester"), or "unknown"
- "pattern": pattern type ("solid", "checkered", "striped", "floral", "cable-knit", "plaid"), or "solid"
- "season": best season ("spring", "summer", "autumn", "winter", "all")
- "formality": style level ("casual", "smart-casual", "formal")
- "description": a short natural language description (1-2 sentences) of the garment including its style, fit, and any notable visual details. Example: "Chunky cable-knit oversized sweater in deep red with crew neck, warm and cozy for layering in cold weather."
Return ONLY a valid JSON object. No explanation, no markdown fences."""
MULTI_GARMENT_PROMPT = """Analyze this image of clothing items carefully. For EACH visible garment, shoe, or fashion accessory, return a JSON array of objects.
Each object MUST have these exact fields:
- "type": garment type (e.g. "sweater", "shirt", "jeans", "boots", "hat", "scarf", "belt", "bag")
- "color": primary color (e.g. "red", "blue", "black", "brown", "white", "beige")
- "material": fabric/material (e.g. "knit", "denim", "leather", "cotton", "silk", "polyester"), or "unknown"
- "pattern": pattern type ("solid", "checkered", "striped", "floral", "cable-knit", "plaid"), or "solid"
- "season": best season ("spring", "summer", "autumn", "winter", "all")
- "formality": style level ("casual", "smart-casual", "formal")
- "description": a short natural language description (1-2 sentences) of the garment including its style, fit, and any notable visual details. Example: "Chunky cable-knit oversized sweater in deep red with crew neck, warm and cozy for layering in cold weather."
IMPORTANT: Only include clothing items, shoes, and fashion accessories. Do NOT include cameras, electronics, decorations, or other non-clothing objects.
Return ONLY a valid JSON array. No explanation, no markdown fences."""
def _image_bytes_to_data_uri(jpeg_bytes: bytes) -> str:
"""Convert JPEG bytes to a base64 data URI."""
b64 = base64.b64encode(jpeg_bytes).decode("utf-8")
return f"data:image/jpeg;base64,{b64}"
def _prepare_image(image_path: str) -> tuple[str, bytes]:
"""Resize image and convert to base64 data URI.
Returns (data_uri, jpeg_bytes) so the thumbnail can be persisted.
"""
img = Image.open(image_path)
if img.mode == "RGBA":
img = img.convert("RGB")
img.thumbnail((MAX_IMAGE_PIXELS, MAX_IMAGE_PIXELS), Image.LANCZOS)
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=85)
jpeg_bytes = buffer.getvalue()
b64 = base64.b64encode(jpeg_bytes).decode("utf-8")
return f"data:image/jpeg;base64,{b64}", jpeg_bytes
def _parse_json_response(text: str) -> list[dict]:
"""Extract JSON array from model response, handling common formatting issues."""
cleaned = text.strip()
fence_match = re.search(r"```(?:json)?\s*\n?(.*?)```", cleaned, re.DOTALL)
if fence_match:
cleaned = fence_match.group(1).strip()
try:
parsed = json.loads(cleaned)
if isinstance(parsed, list):
return parsed
if isinstance(parsed, dict):
return [parsed]
except json.JSONDecodeError:
pass
start = cleaned.find("[")
end = cleaned.rfind("]")
if start != -1 and end != -1 and end > start:
try:
return json.loads(cleaned[start:end + 1])
except json.JSONDecodeError:
pass
obj_start = cleaned.find("{")
obj_end = cleaned.rfind("}")
if obj_start != -1 and obj_end != -1 and obj_end > obj_start:
try:
parsed = json.loads(cleaned[obj_start:obj_end + 1])
if isinstance(parsed, dict):
return [parsed]
except json.JSONDecodeError:
pass
logger.warning("Could not parse JSON from response: %s", cleaned[:200])
return []
def _is_clothing_item(item: dict) -> bool:
"""Filter out non-clothing items that the model might detect."""
item_type = item.get("type", "").lower().strip()
if item_type in GARMENT_TYPES:
return True
for garment in GARMENT_TYPES:
if garment in item_type or item_type in garment:
return True
return False
def _normalize_garment(item: dict) -> dict:
"""Ensure all required fields exist and are normalized."""
return {
"type": item.get("type", "unknown").lower().strip(),
"color": item.get("color", "unknown").lower().strip(),
"material": item.get("material", "unknown").lower().strip(),
"pattern": item.get("pattern", "solid").lower().strip(),
"season": item.get("season", "all").lower().strip(),
"formality": item.get("formality", "casual").lower().strip(),
"description": item.get("description", "").strip(),
}
def _extract_single_garment(crop_bytes: bytes) -> dict | None:
"""Send a single crop to the VLM and extract one garment."""
llm = model_manager.get_vision_model()
data_uri = _image_bytes_to_data_uri(crop_bytes)
response = llm.create_chat_completion(
messages=[{
"role": "user",
"content": [
{"type": "text", "text": SINGLE_GARMENT_PROMPT},
{"type": "image_url", "image_url": {"url": data_uri}},
],
}],
max_tokens=512,
temperature=0.1,
)
raw_text = response["choices"][0]["message"]["content"]
logger.debug("VLM single-garment response: %s", raw_text[:200])
items = _parse_json_response(raw_text)
if not items:
return None
item = items[0]
if not _is_clothing_item(item):
return None
return _normalize_garment(item)
def _extract_from_full_image(image_path: str) -> tuple[list[dict], bytes]:
"""Fallback: extract multiple garments from the full image (no YOLO)."""
llm = model_manager.get_vision_model()
data_uri, image_bytes = _prepare_image(image_path)
response = llm.create_chat_completion(
messages=[{
"role": "user",
"content": [
{"type": "text", "text": MULTI_GARMENT_PROMPT},
{"type": "image_url", "image_url": {"url": data_uri}},
],
}],
max_tokens=2048,
temperature=0.1,
)
raw_text = response["choices"][0]["message"]["content"]
logger.debug("VLM multi-garment response: %s", raw_text[:200])
items = _parse_json_response(raw_text)
garments = [_normalize_garment(item) for item in items if _is_clothing_item(item)]
return garments, image_bytes
def extract_garments(image_path: str) -> list[tuple[dict, bytes]]:
"""Extract garments from an image with individual crops.
Uses YOLO to detect garment bounding boxes, crops each one, then
sends each crop to the VLM individually for attribute extraction.
Falls back to full-image analysis if YOLO detects nothing.
Returns list of (garment_dict, crop_jpeg_bytes) tuples.
"""
logger.info("Processing image: %s", image_path)
crops = detect_and_crop(image_path)
if crops:
results = []
for i, crop_bytes in enumerate(crops):
logger.info("Analyzing crop %d/%d", i + 1, len(crops))
garment = _extract_single_garment(crop_bytes)
if garment:
results.append((garment, crop_bytes))
if results:
logger.info("Extracted %d garments from %d crops", len(results), len(crops))
return results
logger.info("Falling back to full-image analysis")
garments, full_bytes = _extract_from_full_image(image_path)
return [(g, full_bytes) for g in garments]
def extract_single_from_path(image_path: str) -> list[tuple[dict, bytes]]:
"""Extract a single garment from a photo (no YOLO, direct VLM).
Use when the user photographs one garment at a time.
Returns a list with 0 or 1 (garment_dict, image_bytes) tuples.
"""
logger.info("Single-garment mode: %s", image_path)
_, image_bytes = _prepare_image(image_path)
garment = _extract_single_garment(image_bytes)
if garment:
return [(garment, image_bytes)]
return []
def extract_from_crop_bytes(crop_bytes: bytes) -> tuple[dict, bytes] | None:
"""Extract a garment from pre-cropped JPEG bytes (manual bbox).
Returns (garment_dict, crop_bytes) or None if extraction fails.
"""
garment = _extract_single_garment(crop_bytes)
if garment:
return (garment, crop_bytes)
return None
|