Spaces:
Sleeping
Sleeping
Upload app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,1332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
๏ปฟfrom __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import asyncio
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import threading
|
| 9 |
+
import time
|
| 10 |
+
import traceback
|
| 11 |
+
import uuid
|
| 12 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
+
from io import BytesIO
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
import uvicorn
|
| 19 |
+
from fastapi import FastAPI, File, Form, UploadFile
|
| 20 |
+
from fastapi.responses import FileResponse, JSONResponse, Response
|
| 21 |
+
from fastapi.staticfiles import StaticFiles
|
| 22 |
+
from google import genai
|
| 23 |
+
from google.genai import types
|
| 24 |
+
from huggingface_hub import HfApi
|
| 25 |
+
from openai import OpenAI
|
| 26 |
+
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
APP_TITLE = "AI ModelCut Studio"
|
| 30 |
+
BASE_DIR = Path(__file__).parent
|
| 31 |
+
ASSETS_DIR = BASE_DIR / "assets"
|
| 32 |
+
PRESET_FACE_CANDIDATES = [
|
| 33 |
+
ASSETS_DIR / "model_face_preset.png",
|
| 34 |
+
BASE_DIR / "model_face_preset.png",
|
| 35 |
+
]
|
| 36 |
+
OPENAI_DEFAULT_IMAGE_MODEL = os.environ.get("OPENAI_IMAGE_MODEL", "gpt-image-2")
|
| 37 |
+
GEMINI_DEFAULT_IMAGE_MODEL = os.environ.get("GEMINI_IMAGE_MODEL", "gemini-3.1-flash-image-preview")
|
| 38 |
+
TARGET_SIZES = {
|
| 39 |
+
"1K": (1024, 1536),
|
| 40 |
+
"2K": (2048, 3072),
|
| 41 |
+
}
|
| 42 |
+
DEMO_FALLBACK = os.environ.get("DEMO_FALLBACK", "").lower() == "true"
|
| 43 |
+
API_INPUT_MAX_SIDE = int(os.environ.get("API_INPUT_MAX_SIDE", "2048"))
|
| 44 |
+
# Max concurrent image-generation calls (batch shots / Gemini candidates run in parallel).
|
| 45 |
+
GEN_MAX_WORKERS = max(1, int(os.environ.get("GEN_MAX_WORKERS", "4")))
|
| 46 |
+
# Proportion policy. When a body reference exists, its proportions are always matched.
|
| 47 |
+
# With NO body reference: IDEALIZE_PROPORTIONS=true โ force 8.2-8.5 heads editorial look;
|
| 48 |
+
# otherwise leave proportions neutral (no forced head-shrink / leg elongation).
|
| 49 |
+
IDEALIZE_PROPORTIONS = os.environ.get("IDEALIZE_PROPORTIONS", "").lower() == "true"
|
| 50 |
+
# Post-process: re-crop full-body output so the subject occupies the same vertical band
|
| 51 |
+
# (head-top / feet-bottom margins) as the body reference image. Set to "false" to disable.
|
| 52 |
+
MATCH_REFERENCE_FRAMING = os.environ.get("MATCH_REFERENCE_FRAMING", "true").lower() != "false"
|
| 53 |
+
# Color distance (0-255) above which a pixel counts as subject vs background.
|
| 54 |
+
SUBJECT_BG_TOLERANCE = max(1, int(os.environ.get("SUBJECT_BG_TOLERANCE", "32")))
|
| 55 |
+
# Optional manual framing override (fractions of height, e.g. "0.10"). If BOTH are set they
|
| 56 |
+
# replace the reference-derived margins โ head sits at TOP, feet at (1 - BOTTOM).
|
| 57 |
+
FRAMING_TOP_MARGIN = os.environ.get("FRAMING_TOP_MARGIN", "").strip()
|
| 58 |
+
FRAMING_BOTTOM_MARGIN = os.environ.get("FRAMING_BOTTOM_MARGIN", "").strip()
|
| 59 |
+
HF_DATASET_REPO_DEFAULT = "sunyoung00/ROEM_TEST"
|
| 60 |
+
STUDIO_BACKGROUND_PROMPT = (
|
| 61 |
+
"Use a clean seamless studio background in solid warm light gray color #E8E7E2. "
|
| 62 |
+
"Ignore the background from all reference images. "
|
| 63 |
+
"Keep only a natural soft floor shadow. "
|
| 64 |
+
"Do not add props, walls, patterns, gradients, or colored lighting."
|
| 65 |
+
)
|
| 66 |
+
FULL_BODY_PROPORTION_PROMPT = (
|
| 67 |
+
"Use elegant fashion model proportions with a naturally smaller head-to-body ratio, "
|
| 68 |
+
"approximately 8.2 to 8.5 heads tall. Keep the face identity exactly the same, but scale "
|
| 69 |
+
"the head naturally smaller relative to the full body. Use long legs, balanced shoulders, "
|
| 70 |
+
"and realistic runway/editorial model proportions. Do not distort the face, neck, hands, "
|
| 71 |
+
"feet, or garment shape."
|
| 72 |
+
)
|
| 73 |
+
PROPORTION_MATCH_PROMPT = (
|
| 74 |
+
"Match the model's head SIZE, face size, neck length, torso-to-leg ratio, and overall "
|
| 75 |
+
"head-to-body proportions to the body-type reference image. Reproduce the natural proportions "
|
| 76 |
+
"shown in that reference. Do NOT elongate the legs, do NOT shrink the head, and do NOT apply "
|
| 77 |
+
"exaggerated runway/editorial proportions. Do not distort the face, neck, hands, feet, or garment shape."
|
| 78 |
+
)
|
| 79 |
+
FACE_ARTIFACT_PREVENTION_PROMPT = (
|
| 80 |
+
"Keep facial features clean, smooth, and natural. Do not over-sharpen the face, add skin "
|
| 81 |
+
"texture noise, mottling, patchy artifacts, speckles, blotches, or uneven discoloration. "
|
| 82 |
+
"Preserve clear eyes, nose, lips, brows, and natural skin tone without repainting the identity."
|
| 83 |
+
)
|
| 84 |
+
FULL_BODY_FRAMING_LOCK_PROMPT = (
|
| 85 |
+
"Preserve the subject scale, crop, and camera distance from the selected base image. "
|
| 86 |
+
"The selected base image controls the final framing, not the pose reference image. "
|
| 87 |
+
"Match the selected base image subject bounding box: keep the head top, shoe bottom, body center, "
|
| 88 |
+
"and full-body height in nearly the same pixel positions. Do not zoom out, do not make the model "
|
| 89 |
+
"smaller in the frame, and do not copy the margins from the pose reference image. If pose and "
|
| 90 |
+
"framing conflict, prioritize the selected base image framing."
|
| 91 |
+
)
|
| 92 |
+
SKIN_TONE_LOCK_PROMPT = (
|
| 93 |
+
"Preserve the original skin tone and facial exposure from the selected base image. "
|
| 94 |
+
"Do not whiten, pale, brighten, over-smooth, or overexpose the face."
|
| 95 |
+
)
|
| 96 |
+
DETAIL_SHOT_PROMPT = (
|
| 97 |
+
"Create an EXTREME close-up macro detail shot of the garment only. "
|
| 98 |
+
"NO MODEL, NO FACE, NO BODY PARTS, NO HAIR, NO SKIN. "
|
| 99 |
+
"Zoom in tightly to showcase the fabric texture, stitching, and construction quality of the target area. "
|
| 100 |
+
"Keep the exact same garment color, material, and design as the source image."
|
| 101 |
+
)
|
| 102 |
+
FULL_BODY_FRAMING_BLOCK = (
|
| 103 |
+
"FRAMING (FULL BODY, NON-NEGOTIABLE): wide full-length shot. "
|
| 104 |
+
"The standing figure must occupy approximately 75-80% of the frame height, with clear empty space on all four sides. "
|
| 105 |
+
"Leave at least 8% empty space above the top of the hair/head, at least 8% below the soles of the shoes, and "
|
| 106 |
+
"about 5% on the left and right. Every body part must be visible: head, face, shoulders, torso, waist, knees, "
|
| 107 |
+
"ankles, and feet with complete shoes. If ANY body part is cropped, the result is wrong. "
|
| 108 |
+
"No bags, no phones, no extra accessories held in the hands."
|
| 109 |
+
)
|
| 110 |
+
# Precise English transform instruction per Korean shot label.
|
| 111 |
+
# Used to image-to-image transform the selected base cut while keeping identity/outfit locked.
|
| 112 |
+
SHOT_TRANSFORM_INSTRUCTIONS = {
|
| 113 |
+
"์ ์ (์ ๋ฉด)": "Front-facing FULL-BODY standing shot of the exact same model and outfit.",
|
| 114 |
+
"์ ์ (์๋ฉด)": "Front-facing FULL-BODY standing shot of the exact same model and outfit.",
|
| 115 |
+
"์ ์ (์์ ํฌ์ฆ)": (
|
| 116 |
+
"FULL-BODY shot of the exact same model and outfit in a natural, relaxed editorial pose. "
|
| 117 |
+
"Keep both feet and the complete standing figure visible."
|
| 118 |
+
),
|
| 119 |
+
"์ ์ (์ธก๋ฉด)": (
|
| 120 |
+
"Rotate the model to a SIDE PROFILE (about 90 degrees) to show the silhouette of the exact same outfit "
|
| 121 |
+
"as a full-body shot."
|
| 122 |
+
),
|
| 123 |
+
"์ ์ (ํ๋ฉด)": (
|
| 124 |
+
"Rotate the model 180 degrees to show the BACK of the exact same outfit as a full-body shot. "
|
| 125 |
+
"Show the back construction details of the garment clearly."
|
| 126 |
+
),
|
| 127 |
+
"์๋ฐ์ ": (
|
| 128 |
+
"MEDIUM CLOSE-UP UPPER-BODY portrait, framed from approximately the waist up to above the top of the head. "
|
| 129 |
+
"The entire head including the complete crown of hair MUST be fully visible โ leave at least 8% empty space "
|
| 130 |
+
"above the hair, never crop the top of the head. Sharp focus on the upper garment."
|
| 131 |
+
),
|
| 132 |
+
"์๋ฐ์ (์๋ฉด)": (
|
| 133 |
+
"Front-facing MEDIUM CLOSE-UP UPPER-BODY portrait, framed from approximately the waist up to above the top "
|
| 134 |
+
"of the head. The entire head and hair crown MUST be fully visible โ leave at least 8% empty space above "
|
| 135 |
+
"the hair, never crop the top of the head. Sharp focus on the upper garment."
|
| 136 |
+
),
|
| 137 |
+
"์๋ฐ์ (์ธก๋ฉด)": (
|
| 138 |
+
"SIDE-PROFILE (about 90 degrees) UPPER-BODY portrait, framed from approximately the waist up to above the "
|
| 139 |
+
"top of the head. Keep the whole head and hair crown visible. Show the side silhouette of the upper garment."
|
| 140 |
+
),
|
| 141 |
+
"์๋ฐ์ (ํ๋ฉด)": (
|
| 142 |
+
"Rotate the model 180 degrees and frame an UPPER-BODY BACK portrait from the waist up. "
|
| 143 |
+
"Keep the whole head and hair crown visible. Show the back neckline and upper-back construction of the same garment."
|
| 144 |
+
),
|
| 145 |
+
"์๋ฐ์ (ํด๋ก์ฆ์
)": (
|
| 146 |
+
"TIGHT CLOSE-UP of the upper chest, neckline, and collar/tie area of the same garment, including the lower "
|
| 147 |
+
"face and shoulders. Show the fabric texture and neckline construction in sharp detail. Keep the same model identity."
|
| 148 |
+
),
|
| 149 |
+
"ํ๋ฐ์ ": (
|
| 150 |
+
"LOWER-BODY shot framed from the waist down to the soles of the shoes. "
|
| 151 |
+
"Keep both feet and the complete shoes fully visible. Sharp focus on the lower garment, hem, and shoes."
|
| 152 |
+
),
|
| 153 |
+
"ํ๋ฐ์ (์์ ํฌ์ฆ)": (
|
| 154 |
+
"LOWER-BODY shot from the waist down in a natural, relaxed stance. "
|
| 155 |
+
"Both feet and complete shoes must be fully visible. Sharp focus on the lower garment and footwear."
|
| 156 |
+
),
|
| 157 |
+
"ํ๋ฐ์ (ํด๋ก์ฆ์
)": (
|
| 158 |
+
"EXTREME CLOSE-UP macro of the lower-garment detail (waistband, tie, hem, or fabric texture). "
|
| 159 |
+
"Garment only โ no face. Show the construction and texture in sharp detail."
|
| 160 |
+
),
|
| 161 |
+
"๋ํ
์ผ(์์)": "Focus the detail shot on the TOP garment area (collar, placket, sleeve, or main fabric texture).",
|
| 162 |
+
"๋ํ
์ผ(ํฌ์ผ)": "Focus the detail shot on the POCKET area, showing stitching and construction.",
|
| 163 |
+
"๋ํ
์ผ(์ ๋ฐ)": "Focus the detail shot on the SHOES / footwear.",
|
| 164 |
+
"๋ํ
์ผ(ํ๋ฉด)": (
|
| 165 |
+
"Focus the detail shot on the BACK construction of the garment (back neckline, zipper, seams, or fabric "
|
| 166 |
+
"texture from behind)."
|
| 167 |
+
),
|
| 168 |
+
}
|
| 169 |
+
# Garment-only macro shots (no model/face/skin).
|
| 170 |
+
_DETAIL_SHOTS = {"๋ํ
์ผ(์์)", "๋ํ
์ผ(ํฌ์ผ)", "๋ํ
์ผ(์ ๋ฐ)", "๋ํ
์ผ(ํ๋ฉด)", "ํ๋ฐ์ (ํด๋ก์ฆ์
)"}
|
| 171 |
+
# Shots where the deterministic crop-to-reference is skipped (extreme crops / no clear full subject).
|
| 172 |
+
_NO_REFRAME_SHOTS = _DETAIL_SHOTS | {"์๋ฐ์ (ํด๋ก์ฆ์
)"}
|
| 173 |
+
|
| 174 |
+
# ---- Per-shot reference library + body-type reference --------------------------
|
| 175 |
+
# Each shot button maps 1:1 to a reference image in assets/poses/ whose filename is the
|
| 176 |
+
# shot label with parentheses turned into underscores, e.g.:
|
| 177 |
+
# "์ ์ (์๋ฉด)" -> assets/poses/์ ์ _์๋ฉด_.(png|jpg|jpeg|webp)
|
| 178 |
+
# "์๋ฐ์ (ํด๋ก์ฆ์
)" -> assets/poses/์๋ฐ์ _ํด๋ก์ฆ์
_.(...)
|
| 179 |
+
# "ํ๋ฐ์ " -> assets/poses/ํ๋ฐ์ .(...)
|
| 180 |
+
# The reference defines pose, camera angle, and crop/framing for that shot.
|
| 181 |
+
POSES_DIR = ASSETS_DIR / "poses"
|
| 182 |
+
POSE_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp")
|
| 183 |
+
# Model body-type reference (physique only; face stays from the face preset).
|
| 184 |
+
BODY_PRESET_CANDIDATES = [
|
| 185 |
+
ASSETS_DIR / "model_body_preset.png",
|
| 186 |
+
BASE_DIR / "model_body_preset.png",
|
| 187 |
+
]
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _shot_reference_stems(shot_type: str) -> list[str]:
|
| 191 |
+
"""Candidate filename stems for a shot label, in priority order.
|
| 192 |
+
|
| 193 |
+
Supports both naming styles so references resolve regardless of how they were saved:
|
| 194 |
+
1) label as-is, with parentheses kept -> "์ ์ (์๋ฉด)" -> ์ ์ (์๋ฉด).jpeg
|
| 195 |
+
2) parentheses replaced with underscores -> "์ ์ _์๋ฉด_" -> ์ ์ _์๋ฉด_.jpeg
|
| 196 |
+
"""
|
| 197 |
+
label = (shot_type or "").strip()
|
| 198 |
+
if not label:
|
| 199 |
+
return []
|
| 200 |
+
underscore = label.replace("(", "_").replace(")", "_")
|
| 201 |
+
stems = [label]
|
| 202 |
+
if underscore != label:
|
| 203 |
+
stems.append(underscore)
|
| 204 |
+
return stems
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _shot_reference_stem(shot_type: str) -> str:
|
| 208 |
+
"""Primary (parens-kept) filename stem for a shot label."""
|
| 209 |
+
stems = _shot_reference_stems(shot_type)
|
| 210 |
+
return stems[0] if stems else ""
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
BODY_REFERENCE_PROMPT = (
|
| 214 |
+
"A BODY-TYPE reference image is provided. Match the model's physique to it: overall height "
|
| 215 |
+
"impression, body build, shoulder width, limb proportions, AND the head-to-body ratio โ i.e. how "
|
| 216 |
+
"large the head and face appear relative to the full body. Use ONLY the body type and proportions "
|
| 217 |
+
"from that image. Do NOT copy its face, hairstyle, skin tone, clothing, pose, or background โ those "
|
| 218 |
+
"come from the other reference images."
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _reference_legend(has_face: bool, has_body: bool, product_count: int, has_pose: bool) -> str:
|
| 223 |
+
"""Describe each reference image by its position so the model never confuses roles."""
|
| 224 |
+
roles: list[str] = []
|
| 225 |
+
if has_face:
|
| 226 |
+
roles.append("FACE identity (copy this exact face, hairline, and features)")
|
| 227 |
+
if has_body:
|
| 228 |
+
roles.append("BODY-TYPE physique (match build/proportions only; ignore its face, hair, clothing, pose)")
|
| 229 |
+
if has_pose:
|
| 230 |
+
roles.append("POSE/FRAMING guide (follow its body pose, camera angle, viewing direction and crop only; ignore its face, clothing, body type, background)")
|
| 231 |
+
for index in range(product_count):
|
| 232 |
+
roles.append(f"PRODUCT garment {index + 1} (preserve its design, color, logo, and texture exactly)")
|
| 233 |
+
if not roles:
|
| 234 |
+
return ""
|
| 235 |
+
legend = "; ".join(f"image {index + 1} = {role}" for index, role in enumerate(roles))
|
| 236 |
+
return "REFERENCE IMAGE ROLES (in this exact order): " + legend + "."
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
app = FastAPI(title=APP_TITLE)
|
| 240 |
+
ASSETS_DIR.mkdir(exist_ok=True)
|
| 241 |
+
app.mount("/assets", StaticFiles(directory=ASSETS_DIR), name="assets")
|
| 242 |
+
_OPENAI_CLIENT: Optional[OpenAI] = None
|
| 243 |
+
_GEMINI_CLIENT: Optional[genai.Client] = None
|
| 244 |
+
_CLIENT_LOCK = threading.Lock()
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _log(message: str, request_id: str = "-") -> None:
|
| 248 |
+
print(f"[MODEL-CUT][{request_id}] {message}", flush=True)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _create_fallback_face() -> Image.Image:
|
| 252 |
+
canvas = Image.new("RGB", (768, 1024), (248, 248, 248))
|
| 253 |
+
draw = ImageDraw.Draw(canvas)
|
| 254 |
+
draw.ellipse((210, 120, 558, 468), fill=(36, 28, 26))
|
| 255 |
+
draw.rounded_rectangle((258, 210, 510, 560), radius=118, fill=(238, 211, 195))
|
| 256 |
+
draw.ellipse((276, 315, 332, 344), fill=(74, 64, 58))
|
| 257 |
+
draw.ellipse((436, 315, 492, 344), fill=(74, 64, 58))
|
| 258 |
+
draw.arc((340, 378, 428, 438), 15, 165, fill=(170, 116, 110), width=5)
|
| 259 |
+
draw.line((300, 283, 348, 272), fill=(72, 52, 45), width=7)
|
| 260 |
+
draw.line((420, 272, 468, 283), fill=(72, 52, 45), width=7)
|
| 261 |
+
draw.rounded_rectangle((120, 558, 648, 980), radius=140, fill=(238, 211, 195))
|
| 262 |
+
draw.rectangle((188, 782, 580, 1024), fill=(255, 255, 255))
|
| 263 |
+
draw.line((384, 104, 384, 258), fill=(82, 70, 66), width=5)
|
| 264 |
+
return canvas
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def load_preset_face() -> Image.Image:
|
| 268 |
+
for preset_path in PRESET_FACE_CANDIDATES:
|
| 269 |
+
if preset_path.exists():
|
| 270 |
+
return ImageOps.exif_transpose(Image.open(preset_path)).convert("RGB")
|
| 271 |
+
|
| 272 |
+
return _create_fallback_face()
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def load_body_reference() -> Optional[Image.Image]:
|
| 276 |
+
"""Optional model body-type reference. Returns None if no preset is present."""
|
| 277 |
+
for preset_path in BODY_PRESET_CANDIDATES:
|
| 278 |
+
if preset_path.exists():
|
| 279 |
+
return ImageOps.exif_transpose(Image.open(preset_path)).convert("RGB")
|
| 280 |
+
return None
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def load_shot_reference(shot_type: str) -> Optional[Image.Image]:
|
| 284 |
+
"""Load the reference image that defines pose/angle/crop for the given shot label.
|
| 285 |
+
|
| 286 |
+
Looks in assets/poses/ (then assets/) for a file whose name matches the shot label,
|
| 287 |
+
accepting both parens-kept ("์ ์ (์๋ฉด).jpeg") and underscore ("์ ์ _์๋ฉด_.jpeg")
|
| 288 |
+
naming. Returns None if no matching reference file is present.
|
| 289 |
+
"""
|
| 290 |
+
stems = _shot_reference_stems(shot_type)
|
| 291 |
+
if not stems:
|
| 292 |
+
return None
|
| 293 |
+
for directory in (POSES_DIR, ASSETS_DIR):
|
| 294 |
+
if not directory.exists():
|
| 295 |
+
continue
|
| 296 |
+
for stem in stems:
|
| 297 |
+
for ext in POSE_IMAGE_EXTENSIONS:
|
| 298 |
+
candidate = directory / f"{stem}{ext}"
|
| 299 |
+
if candidate.exists():
|
| 300 |
+
return ImageOps.exif_transpose(Image.open(candidate)).convert("RGB")
|
| 301 |
+
return None
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
async def _read_upload(upload: Optional[UploadFile]) -> Optional[Image.Image]:
|
| 305 |
+
if upload is None or not upload.filename:
|
| 306 |
+
return None
|
| 307 |
+
|
| 308 |
+
content = await upload.read()
|
| 309 |
+
if not content:
|
| 310 |
+
return None
|
| 311 |
+
|
| 312 |
+
return ImageOps.exif_transpose(Image.open(BytesIO(content))).convert("RGB")
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def _read_data_url_image(data_url: str) -> Optional[Image.Image]:
|
| 316 |
+
if not data_url or not data_url.startswith("data:image/") or ";base64," not in data_url:
|
| 317 |
+
return None
|
| 318 |
+
|
| 319 |
+
encoded = data_url.split(";base64,", 1)[1]
|
| 320 |
+
raw = base64.b64decode(encoded)
|
| 321 |
+
return ImageOps.exif_transpose(Image.open(BytesIO(raw))).convert("RGB")
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def _get_openai_client() -> OpenAI:
|
| 325 |
+
global _OPENAI_CLIENT
|
| 326 |
+
if _OPENAI_CLIENT is None:
|
| 327 |
+
with _CLIENT_LOCK:
|
| 328 |
+
if _OPENAI_CLIENT is None:
|
| 329 |
+
_OPENAI_CLIENT = OpenAI()
|
| 330 |
+
return _OPENAI_CLIENT
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def _get_gemini_client(api_key: str) -> genai.Client:
|
| 334 |
+
global _GEMINI_CLIENT
|
| 335 |
+
if _GEMINI_CLIENT is None:
|
| 336 |
+
with _CLIENT_LOCK:
|
| 337 |
+
if _GEMINI_CLIENT is None:
|
| 338 |
+
_GEMINI_CLIENT = genai.Client(api_key=api_key)
|
| 339 |
+
return _GEMINI_CLIENT
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def _prepare_api_reference(image: Image.Image) -> Image.Image:
|
| 343 |
+
prepared = ImageOps.exif_transpose(image).convert("RGB")
|
| 344 |
+
prepared.thumbnail((API_INPUT_MAX_SIDE, API_INPUT_MAX_SIDE), Image.Resampling.LANCZOS)
|
| 345 |
+
return prepared
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def _image_summary(image: Optional[Image.Image]) -> str:
|
| 349 |
+
if image is None:
|
| 350 |
+
return "none"
|
| 351 |
+
return f"{image.width}x{image.height}"
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def _fit_image(image: Image.Image, size: tuple[int, int]) -> Image.Image:
|
| 355 |
+
image = ImageOps.exif_transpose(image).convert("RGBA")
|
| 356 |
+
image.thumbnail(size, Image.Resampling.LANCZOS)
|
| 357 |
+
canvas = Image.new("RGBA", size, (246, 243, 239, 255))
|
| 358 |
+
x = (size[0] - image.width) // 2
|
| 359 |
+
y = (size[1] - image.height) // 2
|
| 360 |
+
canvas.alpha_composite(image, (x, y))
|
| 361 |
+
return canvas
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _draw_model_cut(
|
| 365 |
+
product_image: Optional[Image.Image],
|
| 366 |
+
model_face: Image.Image,
|
| 367 |
+
label: str,
|
| 368 |
+
resolution: str,
|
| 369 |
+
pose_shift: int,
|
| 370 |
+
shot_type: str = "?๊พฉ๋(?๋บฃใ)",
|
| 371 |
+
) -> Image.Image:
|
| 372 |
+
size = (1024, 1280) if resolution == "1K" else (1536, 1920)
|
| 373 |
+
canvas = Image.new("RGB", size, (246, 243, 239))
|
| 374 |
+
draw = ImageDraw.Draw(canvas)
|
| 375 |
+
|
| 376 |
+
grid = max(size[0] // 24, 36)
|
| 377 |
+
for x in range(0, size[0], grid):
|
| 378 |
+
draw.line((x, 0, x, size[1]), fill=(235, 232, 226), width=1)
|
| 379 |
+
for y in range(0, size[1], grid):
|
| 380 |
+
draw.line((0, y, size[0], y), fill=(235, 232, 226), width=1)
|
| 381 |
+
|
| 382 |
+
cx = size[0] // 2 + pose_shift
|
| 383 |
+
head_r = size[0] // 15
|
| 384 |
+
is_upper = "์๋ฐ์ " in shot_type or "?๊ณท์ปฒ" in shot_type
|
| 385 |
+
is_lower = "ํ๋ฐ์ " in shot_type or "?์์ปฒ" in shot_type
|
| 386 |
+
is_detail = "๋ํ
์ผ" in shot_type or "?๋ท๋" in shot_type
|
| 387 |
+
is_back = "ํ๋ฉด" in shot_type or "?๊พจใ" in shot_type
|
| 388 |
+
|
| 389 |
+
if is_upper:
|
| 390 |
+
head_r = size[0] // 11
|
| 391 |
+
if is_detail:
|
| 392 |
+
head_r = size[0] // 18
|
| 393 |
+
|
| 394 |
+
draw.ellipse((cx - head_r, size[1] // 8, cx + head_r, size[1] // 8 + head_r * 2), fill=(232, 204, 184))
|
| 395 |
+
draw.arc(
|
| 396 |
+
(cx - head_r - 8, size[1] // 8 - 6, cx + head_r + 8, size[1] // 8 + head_r * 2),
|
| 397 |
+
190,
|
| 398 |
+
350,
|
| 399 |
+
fill=(24, 24, 26),
|
| 400 |
+
width=max(8, size[0] // 70),
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
face = ImageOps.fit(model_face, (head_r * 2, head_r * 2), method=Image.Resampling.LANCZOS, centering=(0.5, 0.34))
|
| 404 |
+
face_mask = Image.new("L", face.size, 0)
|
| 405 |
+
mask_draw = ImageDraw.Draw(face_mask)
|
| 406 |
+
mask_draw.ellipse((0, 0, face.width, face.height), fill=230)
|
| 407 |
+
canvas.paste(face, (cx - head_r, size[1] // 8), face_mask.filter(ImageFilter.GaussianBlur(0.6)))
|
| 408 |
+
|
| 409 |
+
shoulder_y = size[1] // 4
|
| 410 |
+
hem_y = int(size[1] * 0.72)
|
| 411 |
+
if is_upper:
|
| 412 |
+
shoulder_y = size[1] // 3
|
| 413 |
+
hem_y = int(size[1] * 0.92)
|
| 414 |
+
if is_lower:
|
| 415 |
+
shoulder_y = size[1] // 7
|
| 416 |
+
hem_y = int(size[1] * 0.82)
|
| 417 |
+
if is_back:
|
| 418 |
+
draw.rectangle((cx - head_r, size[1] // 8, cx + head_r, size[1] // 8 + head_r * 2), fill=(31, 28, 27))
|
| 419 |
+
|
| 420 |
+
body = [
|
| 421 |
+
(cx - size[0] // 6, shoulder_y),
|
| 422 |
+
(cx + size[0] // 6, shoulder_y),
|
| 423 |
+
(cx + size[0] // 8, hem_y),
|
| 424 |
+
(cx - size[0] // 8, hem_y),
|
| 425 |
+
]
|
| 426 |
+
draw.polygon(body, fill=(29, 32, 36))
|
| 427 |
+
|
| 428 |
+
if product_image:
|
| 429 |
+
product_box = (size[0] // 3, int(size[1] * 0.44))
|
| 430 |
+
if is_upper:
|
| 431 |
+
product_box = (size[0] // 2, int(size[1] * 0.5))
|
| 432 |
+
if is_lower:
|
| 433 |
+
product_box = (size[0] // 2, int(size[1] * 0.58))
|
| 434 |
+
if is_detail:
|
| 435 |
+
product_box = (int(size[0] * 0.72), int(size[1] * 0.55))
|
| 436 |
+
|
| 437 |
+
product = _fit_image(product_image, product_box)
|
| 438 |
+
product_mask = Image.new("L", product.size, 0)
|
| 439 |
+
product_mask_draw = ImageDraw.Draw(product_mask)
|
| 440 |
+
product_mask_draw.rounded_rectangle((0, 0, product.width, product.height), radius=18, fill=210)
|
| 441 |
+
px = cx - product.width // 2
|
| 442 |
+
py = shoulder_y + size[1] // 18
|
| 443 |
+
if is_lower:
|
| 444 |
+
py = int(size[1] * 0.36)
|
| 445 |
+
if is_detail:
|
| 446 |
+
py = int(size[1] * 0.26)
|
| 447 |
+
canvas.paste(product.convert("RGB"), (px, py), product_mask.filter(ImageFilter.GaussianBlur(1.2)))
|
| 448 |
+
|
| 449 |
+
leg_y = hem_y
|
| 450 |
+
if not is_upper and not is_detail:
|
| 451 |
+
draw.line((cx - size[0] // 14, leg_y, cx - size[0] // 9, int(size[1] * 0.9)), fill=(24, 26, 29), width=size[0] // 34)
|
| 452 |
+
draw.line((cx + size[0] // 14, leg_y, cx + size[0] // 9, int(size[1] * 0.9)), fill=(24, 26, 29), width=size[0] // 34)
|
| 453 |
+
draw.ellipse((24, 24, 82, 82), fill=(20, 22, 24))
|
| 454 |
+
draw.text((41, 42), "AI", fill=(255, 255, 255))
|
| 455 |
+
draw.text((24, size[1] - 64), label, fill=(30, 34, 38))
|
| 456 |
+
|
| 457 |
+
return canvas
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
def _image_to_data_url(image: Image.Image, resolution: str = "1K") -> str:
|
| 461 |
+
output = BytesIO()
|
| 462 |
+
if resolution == "2K":
|
| 463 |
+
image.convert("RGB").save(output, format="JPEG", quality=92, optimize=True, progressive=True, subsampling=0)
|
| 464 |
+
encoded = base64.b64encode(output.getvalue()).decode("ascii")
|
| 465 |
+
return f"data:image/jpeg;base64,{encoded}"
|
| 466 |
+
|
| 467 |
+
image.save(output, format="PNG", optimize=True)
|
| 468 |
+
encoded = base64.b64encode(output.getvalue()).decode("ascii")
|
| 469 |
+
return f"data:image/png;base64,{encoded}"
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def _image_to_png_bytes(image: Image.Image) -> bytes:
|
| 473 |
+
output = BytesIO()
|
| 474 |
+
image.save(output, format="PNG")
|
| 475 |
+
output.seek(0)
|
| 476 |
+
return output.getvalue()
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def _image_to_jpeg_bytes(image: Image.Image) -> bytes:
|
| 480 |
+
output = BytesIO()
|
| 481 |
+
image.convert("RGB").save(output, format="JPEG", quality=95, optimize=True, subsampling=0)
|
| 482 |
+
output.seek(0)
|
| 483 |
+
return output.getvalue()
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def _safe_dataset_name(value: str) -> str:
|
| 487 |
+
cleaned = re.sub(r"[^0-9A-Za-z๊ฐ-ํฃ_.()-]+", "_", value.strip())
|
| 488 |
+
return cleaned.strip("_")[:80] or "modelcut"
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def _upload_generation_to_dataset(
|
| 492 |
+
images: list[Image.Image],
|
| 493 |
+
labels: list[str],
|
| 494 |
+
metadata: dict,
|
| 495 |
+
request_id: str,
|
| 496 |
+
) -> None:
|
| 497 |
+
dataset_repo = os.environ.get("HF_DATASET_REPO", HF_DATASET_REPO_DEFAULT).strip()
|
| 498 |
+
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")
|
| 499 |
+
if not dataset_repo:
|
| 500 |
+
_log("dataset upload skipped: HF_DATASET_REPO is empty", request_id)
|
| 501 |
+
return
|
| 502 |
+
if not token:
|
| 503 |
+
_log("dataset upload skipped: HF_TOKEN is not set", request_id)
|
| 504 |
+
return
|
| 505 |
+
|
| 506 |
+
try:
|
| 507 |
+
api = HfApi(token=token)
|
| 508 |
+
api.create_repo(repo_id=dataset_repo, repo_type="dataset", exist_ok=True)
|
| 509 |
+
|
| 510 |
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
| 511 |
+
folder = f"generated/{timestamp}_{request_id}"
|
| 512 |
+
uploaded_files = []
|
| 513 |
+
|
| 514 |
+
for index, image in enumerate(images, start=1):
|
| 515 |
+
label = labels[index - 1] if index - 1 < len(labels) else f"image-{index}"
|
| 516 |
+
filename = f"{index:02d}_{_safe_dataset_name(label)}.png"
|
| 517 |
+
path_in_repo = f"{folder}/{filename}"
|
| 518 |
+
api.upload_file(
|
| 519 |
+
path_or_fileobj=_image_to_png_bytes(image),
|
| 520 |
+
path_in_repo=path_in_repo,
|
| 521 |
+
repo_id=dataset_repo,
|
| 522 |
+
repo_type="dataset",
|
| 523 |
+
commit_message=f"Add generated model cut {request_id}",
|
| 524 |
+
)
|
| 525 |
+
uploaded_files.append(path_in_repo)
|
| 526 |
+
|
| 527 |
+
metadata_payload = {
|
| 528 |
+
**metadata,
|
| 529 |
+
"request_id": request_id,
|
| 530 |
+
"created_at": timestamp,
|
| 531 |
+
"files": uploaded_files,
|
| 532 |
+
}
|
| 533 |
+
api.upload_file(
|
| 534 |
+
path_or_fileobj=json.dumps(metadata_payload, ensure_ascii=False, indent=2).encode("utf-8"),
|
| 535 |
+
path_in_repo=f"{folder}/metadata.json",
|
| 536 |
+
repo_id=dataset_repo,
|
| 537 |
+
repo_type="dataset",
|
| 538 |
+
commit_message=f"Add model cut metadata {request_id}",
|
| 539 |
+
)
|
| 540 |
+
_log(f"dataset upload done repo={dataset_repo} files={len(uploaded_files)} folder={folder}", request_id)
|
| 541 |
+
except Exception as error:
|
| 542 |
+
_log(f"dataset upload failed repo={dataset_repo} error={error}", request_id)
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
def _normalize_output_size(image: Image.Image, resolution: str) -> Image.Image:
|
| 546 |
+
target = TARGET_SIZES.get(resolution, TARGET_SIZES["1K"])
|
| 547 |
+
image = ImageOps.exif_transpose(image).convert("RGB")
|
| 548 |
+
if image.size == target:
|
| 549 |
+
return image
|
| 550 |
+
|
| 551 |
+
fitted = ImageOps.contain(image, target, method=Image.Resampling.LANCZOS)
|
| 552 |
+
canvas = Image.new("RGB", target, (246, 243, 239))
|
| 553 |
+
x = (target[0] - fitted.width) // 2
|
| 554 |
+
y = (target[1] - fitted.height) // 2
|
| 555 |
+
canvas.paste(fitted, (x, y))
|
| 556 |
+
return canvas
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
def _estimate_bg_color(image: Image.Image) -> tuple[int, int, int]:
|
| 560 |
+
"""Estimate the (solid studio) background color from the image corners."""
|
| 561 |
+
rgb = image.convert("RGB")
|
| 562 |
+
w, h = rgb.size
|
| 563 |
+
patch = max(4, min(w, h) // 50)
|
| 564 |
+
samples: list[tuple[int, int, int]] = []
|
| 565 |
+
for cx, cy in [(0, 0), (w - patch, 0), (0, h - patch), (w - patch, h - patch)]:
|
| 566 |
+
region = rgb.crop((cx, cy, cx + patch, cy + patch))
|
| 567 |
+
samples.append(tuple(int(v) for v in region.resize((1, 1), Image.Resampling.LANCZOS).getpixel((0, 0))))
|
| 568 |
+
samples.sort(key=lambda c: c[0] + c[1] + c[2])
|
| 569 |
+
return samples[len(samples) // 2] # median-ish corner
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
def _subject_bbox(image: Image.Image, tolerance: int) -> Optional[tuple[int, int, int, int]]:
|
| 573 |
+
"""Bounding box (left, top, right, bottom) of the subject vs a near-solid background."""
|
| 574 |
+
rgb = image.convert("RGB")
|
| 575 |
+
bg = Image.new("RGB", rgb.size, _estimate_bg_color(rgb))
|
| 576 |
+
diff = ImageChops.difference(rgb, bg)
|
| 577 |
+
r, g, b = diff.split()
|
| 578 |
+
per_pixel_max = ImageChops.lighter(ImageChops.lighter(r, g), b) # strongest channel diff
|
| 579 |
+
mask = per_pixel_max.point(lambda p: 255 if p >= tolerance else 0)
|
| 580 |
+
return mask.getbbox()
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
def _reframe_to_reference(
|
| 584 |
+
image: Image.Image,
|
| 585 |
+
reference: Optional[Image.Image],
|
| 586 |
+
target_size: tuple[int, int],
|
| 587 |
+
) -> Image.Image:
|
| 588 |
+
"""Re-crop/scale a full-body output so the subject occupies the same vertical band
|
| 589 |
+
(head-top and feet-bottom margins) as the reference image โ or as explicit env margins.
|
| 590 |
+
Falls back to the unchanged image (at target size) if detection looks unreliable."""
|
| 591 |
+
width, height = target_size
|
| 592 |
+
base = image.convert("RGB")
|
| 593 |
+
|
| 594 |
+
# 1) Determine the target vertical band (fractions of final height).
|
| 595 |
+
top_frac: Optional[float] = None
|
| 596 |
+
bottom_frac: Optional[float] = None
|
| 597 |
+
if FRAMING_TOP_MARGIN and FRAMING_BOTTOM_MARGIN:
|
| 598 |
+
try:
|
| 599 |
+
top_frac = float(FRAMING_TOP_MARGIN)
|
| 600 |
+
bottom_frac = 1.0 - float(FRAMING_BOTTOM_MARGIN)
|
| 601 |
+
except ValueError:
|
| 602 |
+
top_frac = bottom_frac = None
|
| 603 |
+
if top_frac is None and reference is not None:
|
| 604 |
+
ref_box = _subject_bbox(reference, SUBJECT_BG_TOLERANCE)
|
| 605 |
+
if ref_box:
|
| 606 |
+
top_frac = ref_box[1] / reference.height
|
| 607 |
+
bottom_frac = ref_box[3] / reference.height
|
| 608 |
+
if top_frac is None or bottom_frac is None:
|
| 609 |
+
return _normalize_output_size(base, "1K" if target_size == TARGET_SIZES["1K"] else "2K")
|
| 610 |
+
|
| 611 |
+
subject_frac = bottom_frac - top_frac
|
| 612 |
+
if not (0.2 < subject_frac < 0.98): # sanity: reference detection failed
|
| 613 |
+
return _normalize_output_size(base, "1K" if target_size == TARGET_SIZES["1K"] else "2K")
|
| 614 |
+
|
| 615 |
+
# 2) Find the subject in the generated image.
|
| 616 |
+
gen_box = _subject_bbox(base, SUBJECT_BG_TOLERANCE)
|
| 617 |
+
if not gen_box:
|
| 618 |
+
return _normalize_output_size(base, "1K" if target_size == TARGET_SIZES["1K"] else "2K")
|
| 619 |
+
gen_subject_h = gen_box[3] - gen_box[1]
|
| 620 |
+
if gen_subject_h <= 0:
|
| 621 |
+
return _normalize_output_size(base, "1K" if target_size == TARGET_SIZES["1K"] else "2K")
|
| 622 |
+
|
| 623 |
+
# 3) Scale so the subject height matches the target band, then place it.
|
| 624 |
+
scale = (subject_frac * height) / gen_subject_h
|
| 625 |
+
new_w = max(1, round(base.width * scale))
|
| 626 |
+
new_h = max(1, round(base.height * scale))
|
| 627 |
+
scaled = base.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
| 628 |
+
|
| 629 |
+
subject_cx = ((gen_box[0] + gen_box[2]) / 2) * scale
|
| 630 |
+
subject_top = gen_box[1] * scale
|
| 631 |
+
paste_x = round(width / 2 - subject_cx)
|
| 632 |
+
paste_y = round(top_frac * height - subject_top)
|
| 633 |
+
|
| 634 |
+
canvas = Image.new("RGB", (width, height), _estimate_bg_color(base))
|
| 635 |
+
canvas.paste(scaled, (paste_x, paste_y))
|
| 636 |
+
return canvas
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
def _openai_size_for_model(model: str, resolution: str) -> str:
|
| 640 |
+
if model == "gpt-image-2":
|
| 641 |
+
return "2048x3072" if resolution == "2K" else "1024x1536"
|
| 642 |
+
|
| 643 |
+
return "1024x1536"
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
def _gemini_image_config(model: str, resolution: str) -> types.ImageConfig:
|
| 647 |
+
image_config = {"aspect_ratio": "2:3"}
|
| 648 |
+
if model in {"gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview"}:
|
| 649 |
+
image_config["image_size"] = resolution
|
| 650 |
+
return types.ImageConfig(**image_config)
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
def _compose_generation_prompt(
|
| 654 |
+
category: str,
|
| 655 |
+
fit: str,
|
| 656 |
+
length: str,
|
| 657 |
+
style: str,
|
| 658 |
+
prompt: str,
|
| 659 |
+
pose: str,
|
| 660 |
+
total_length_cm: str,
|
| 661 |
+
generation_mode: str,
|
| 662 |
+
shot_type: str,
|
| 663 |
+
selected_base_index: int,
|
| 664 |
+
has_body_reference: bool = False,
|
| 665 |
+
has_pose_reference: bool = False,
|
| 666 |
+
product_count: int = 0,
|
| 667 |
+
) -> str:
|
| 668 |
+
shot_instruction = "?๊พฉ๋(?๋บฃใ) ่ใ
ปใง??๏งโค๋ฝ่??๊พจ๋ซ็??์น๊ฝฆ?์๊ฝญ??"
|
| 669 |
+
is_full_body = generation_mode != "shot_variant" or "์ ์ " in shot_type or "?๊พฉ๋" in shot_type
|
| 670 |
+
if generation_mode == "shot_variant":
|
| 671 |
+
shot_instruction = (
|
| 672 |
+
f"?์ข๊นฎ??ๆนฒ๊ณ? ่?{selected_base_index + 1}??๏งโค๋ฝ ?์จ๋ฌ, ?ใ
ผ๋ผฑ, ๏งฃ๋์, ?์๊ธฝ, ?๋ฑ๊ธฝ, ?๋ฏ์ฑ, ๆฟก์ํฌ, "
|
| 673 |
+
f"?ใ
ป๏ผ?๏ฝ? ?์ข??์ํฌ ??ๆดั๋ฃ๏ง?'{shot_type}'ๆฟก?่นยๅฏ์๋ธฏ?๋ช์. "
|
| 674 |
+
"Use the selected base image reference as the source photo to transform; do not create a new unrelated model."
|
| 675 |
+
)
|
| 676 |
+
|
| 677 |
+
length_text = f"{length}, ?๋๊ธฝ ็ฅ์น์ฃ {total_length_cm}cm" if total_length_cm else length
|
| 678 |
+
legend = _reference_legend(
|
| 679 |
+
has_face=True,
|
| 680 |
+
has_body=has_body_reference,
|
| 681 |
+
product_count=product_count,
|
| 682 |
+
has_pose=has_pose_reference,
|
| 683 |
+
)
|
| 684 |
+
# Proportion policy: a body reference always wins (match it). Without one, only
|
| 685 |
+
# apply the idealized 8.2-8.5 head look when explicitly enabled.
|
| 686 |
+
if not is_full_body:
|
| 687 |
+
proportion_prompt = ""
|
| 688 |
+
elif has_body_reference:
|
| 689 |
+
proportion_prompt = PROPORTION_MATCH_PROMPT
|
| 690 |
+
elif IDEALIZE_PROPORTIONS:
|
| 691 |
+
proportion_prompt = FULL_BODY_PROPORTION_PROMPT
|
| 692 |
+
else:
|
| 693 |
+
proportion_prompt = ""
|
| 694 |
+
return "\n".join(
|
| 695 |
+
[
|
| 696 |
+
"Create a high-resolution fashion ecommerce AI model photo.",
|
| 697 |
+
legend,
|
| 698 |
+
"CRITICAL IDENTITY LOCK: Use the face reference (image 1) as the exact persona model.",
|
| 699 |
+
"All generated candidates must show the same person, not a similar-looking new model.",
|
| 700 |
+
"Preserve the same face shape, jawline, eye shape, eye spacing, nose, lips, eyebrows, skin tone, and hairline from the face reference.",
|
| 701 |
+
"Do not beautify, age-shift, ethnicity-shift, change makeup style, or invent a different face.",
|
| 702 |
+
"If generating multiple candidates, keep the face identity identical across every candidate.",
|
| 703 |
+
BODY_REFERENCE_PROMPT if has_body_reference else "",
|
| 704 |
+
proportion_prompt,
|
| 705 |
+
FACE_ARTIFACT_PREVENTION_PROMPT if is_full_body else "",
|
| 706 |
+
FULL_BODY_FRAMING_BLOCK if is_full_body else "",
|
| 707 |
+
"Preserve the original skin tone and facial exposure from the face reference. Do not whiten, pale, brighten, over-smooth, or overexpose the face.",
|
| 708 |
+
shot_instruction,
|
| 709 |
+
f"Garment category: {category}. Fit: {fit}. Length: {length_text}.",
|
| 710 |
+
f"Style: {style}. Pose reference: {pose}.",
|
| 711 |
+
STUDIO_BACKGROUND_PROMPT,
|
| 712 |
+
"Use sharp fabric texture and accurate garment edges.",
|
| 713 |
+
"Preserve the uploaded product image details as faithfully as possible.",
|
| 714 |
+
"Do not alter logos, buttons, patterns, colors, or silhouette.",
|
| 715 |
+
"Output should be suitable for a shopping mall product detail page.",
|
| 716 |
+
prompt.strip(),
|
| 717 |
+
]
|
| 718 |
+
).strip()
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
def _compose_transform_prompt(
|
| 722 |
+
shot_type: str,
|
| 723 |
+
prompt: str,
|
| 724 |
+
total_length_cm: str,
|
| 725 |
+
selected_base_index: int,
|
| 726 |
+
has_pose_reference: bool = False,
|
| 727 |
+
) -> str:
|
| 728 |
+
is_detail = shot_type in _DETAIL_SHOTS
|
| 729 |
+
is_full_body = "์ ์ " in shot_type
|
| 730 |
+
shot_instruction = SHOT_TRANSFORM_INSTRUCTIONS.get(
|
| 731 |
+
shot_type, f"Create this shot composition: {shot_type}."
|
| 732 |
+
)
|
| 733 |
+
extra = f"Additional instruction: {prompt.strip()}" if prompt.strip() else ""
|
| 734 |
+
pose_reference = (
|
| 735 |
+
"A POSE/FRAMING reference image is also provided. Match its body pose, camera angle, viewing "
|
| 736 |
+
"direction (front / side / back), and crop/framing as closely as possible. Take ONLY pose, angle "
|
| 737 |
+
"and framing from it โ identity, face, outfit, garment color and texture must come from the source "
|
| 738 |
+
"(first) image, never from the pose reference."
|
| 739 |
+
if has_pose_reference
|
| 740 |
+
else ""
|
| 741 |
+
)
|
| 742 |
+
|
| 743 |
+
lines = [
|
| 744 |
+
"Edit the FIRST image. Use it as the source photo to transform; do NOT create a new, unrelated model.",
|
| 745 |
+
]
|
| 746 |
+
if is_detail:
|
| 747 |
+
lines.append(
|
| 748 |
+
"Keep the exact same garment color, fabric texture, material, silhouette, logos, and design as the first image."
|
| 749 |
+
)
|
| 750 |
+
else:
|
| 751 |
+
lines.append(
|
| 752 |
+
"Keep the exact same person, face, skin tone, hair style, outfit, garment color, fabric texture, "
|
| 753 |
+
"silhouette, shoes, and background from the first image."
|
| 754 |
+
)
|
| 755 |
+
lines.append("Do not repaint the face, do not beautify, and do not change the clothing design.")
|
| 756 |
+
lines.append(f"TARGET SHOT: {shot_type}.")
|
| 757 |
+
lines.append(shot_instruction)
|
| 758 |
+
if is_detail:
|
| 759 |
+
lines.append(DETAIL_SHOT_PROMPT)
|
| 760 |
+
else:
|
| 761 |
+
# Person is in frame โ preserve skin tone; lock scale/crop only for full-body shots.
|
| 762 |
+
lines.append(SKIN_TONE_LOCK_PROMPT)
|
| 763 |
+
if is_full_body:
|
| 764 |
+
lines.append(FULL_BODY_FRAMING_LOCK_PROMPT)
|
| 765 |
+
lines.append(FULL_BODY_FRAMING_BLOCK)
|
| 766 |
+
lines.append(pose_reference)
|
| 767 |
+
lines.append("Keep the edit natural and close to the source image.")
|
| 768 |
+
lines.append(extra)
|
| 769 |
+
return "\n".join(line for line in lines if line).strip()
|
| 770 |
+
|
| 771 |
+
|
| 772 |
+
def _split_provider_model(image_model: str) -> tuple[str, str]:
|
| 773 |
+
if ":" not in image_model:
|
| 774 |
+
return "openai", image_model
|
| 775 |
+
|
| 776 |
+
provider, model = image_model.split(":", 1)
|
| 777 |
+
return provider, model
|
| 778 |
+
|
| 779 |
+
|
| 780 |
+
def _resolve_model(provider: str, model: str) -> str:
|
| 781 |
+
if provider == "openai":
|
| 782 |
+
return os.environ.get("OPENAI_IMAGE_MODEL", model or OPENAI_DEFAULT_IMAGE_MODEL)
|
| 783 |
+
if provider == "gemini":
|
| 784 |
+
return os.environ.get("GEMINI_IMAGE_MODEL", model or GEMINI_DEFAULT_IMAGE_MODEL)
|
| 785 |
+
return model
|
| 786 |
+
|
| 787 |
+
|
| 788 |
+
def _generate_with_openai(
|
| 789 |
+
references: list[Optional[Image.Image]],
|
| 790 |
+
model: str,
|
| 791 |
+
prompt: str,
|
| 792 |
+
resolution: str,
|
| 793 |
+
count: int,
|
| 794 |
+
request_id: str = "-",
|
| 795 |
+
) -> list[Image.Image]:
|
| 796 |
+
if not os.environ.get("OPENAI_API_KEY"):
|
| 797 |
+
raise RuntimeError("OPENAI_API_KEY is not set.")
|
| 798 |
+
|
| 799 |
+
client = _get_openai_client()
|
| 800 |
+
references = [_prepare_api_reference(image) for image in references if image is not None]
|
| 801 |
+
size = _openai_size_for_model(model, resolution)
|
| 802 |
+
image_files = []
|
| 803 |
+
try:
|
| 804 |
+
started = time.perf_counter()
|
| 805 |
+
_log(
|
| 806 |
+
f"openai start model={model} size={size} count={count} refs={len(references)} "
|
| 807 |
+
f"ref_sizes={[f'{image.width}x{image.height}' for image in references]} prompt_chars={len(prompt)}",
|
| 808 |
+
request_id,
|
| 809 |
+
)
|
| 810 |
+
for index, image in enumerate(references):
|
| 811 |
+
payload = BytesIO(_image_to_jpeg_bytes(image))
|
| 812 |
+
payload.name = f"reference_{index}.jpg"
|
| 813 |
+
image_files.append(payload)
|
| 814 |
+
|
| 815 |
+
if image_files:
|
| 816 |
+
response = client.images.edit(
|
| 817 |
+
model=model,
|
| 818 |
+
image=image_files,
|
| 819 |
+
prompt=prompt,
|
| 820 |
+
size=size,
|
| 821 |
+
quality="high",
|
| 822 |
+
n=count,
|
| 823 |
+
)
|
| 824 |
+
else:
|
| 825 |
+
response = client.images.generate(
|
| 826 |
+
model=model,
|
| 827 |
+
prompt=prompt,
|
| 828 |
+
size=size,
|
| 829 |
+
quality="high",
|
| 830 |
+
n=count,
|
| 831 |
+
)
|
| 832 |
+
|
| 833 |
+
images = []
|
| 834 |
+
for item in response.data:
|
| 835 |
+
if getattr(item, "b64_json", None):
|
| 836 |
+
raw = base64.b64decode(item.b64_json)
|
| 837 |
+
images.append(_normalize_output_size(Image.open(BytesIO(raw)), resolution))
|
| 838 |
+
elif getattr(item, "url", None):
|
| 839 |
+
raise RuntimeError("OpenAI returned an image URL, but URL fetching is disabled in this container.")
|
| 840 |
+
|
| 841 |
+
if not images:
|
| 842 |
+
raise RuntimeError("OpenAI did not return image data.")
|
| 843 |
+
_log(f"openai done images={len(images)} elapsed={time.perf_counter() - started:.1f}s", request_id)
|
| 844 |
+
return images
|
| 845 |
+
finally:
|
| 846 |
+
for file in image_files:
|
| 847 |
+
file.close()
|
| 848 |
+
|
| 849 |
+
|
| 850 |
+
def _generate_with_gemini(
|
| 851 |
+
references: list[Optional[Image.Image]],
|
| 852 |
+
model: str,
|
| 853 |
+
prompt: str,
|
| 854 |
+
resolution: str,
|
| 855 |
+
count: int,
|
| 856 |
+
request_id: str = "-",
|
| 857 |
+
) -> list[Image.Image]:
|
| 858 |
+
api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
|
| 859 |
+
if not api_key:
|
| 860 |
+
raise RuntimeError("GEMINI_API_KEY or GOOGLE_API_KEY is not set.")
|
| 861 |
+
|
| 862 |
+
client = _get_gemini_client(api_key)
|
| 863 |
+
references = [_prepare_api_reference(image) for image in references if image is not None]
|
| 864 |
+
contents = [*references, prompt]
|
| 865 |
+
started = time.perf_counter()
|
| 866 |
+
_log(
|
| 867 |
+
f"gemini start model={model} count={count} refs={len(references)} "
|
| 868 |
+
f"ref_sizes={[f'{image.width}x{image.height}' for image in references]} prompt_chars={len(prompt)}",
|
| 869 |
+
request_id,
|
| 870 |
+
)
|
| 871 |
+
|
| 872 |
+
def _one_candidate(_index: int) -> Optional[Image.Image]:
|
| 873 |
+
response = client.models.generate_content(
|
| 874 |
+
model=model,
|
| 875 |
+
contents=contents,
|
| 876 |
+
config=types.GenerateContentConfig(
|
| 877 |
+
response_modalities=["TEXT", "IMAGE"],
|
| 878 |
+
image_config=_gemini_image_config(model, resolution),
|
| 879 |
+
),
|
| 880 |
+
)
|
| 881 |
+
parts = getattr(response, "parts", None)
|
| 882 |
+
if parts is None and getattr(response, "candidates", None):
|
| 883 |
+
parts = response.candidates[0].content.parts
|
| 884 |
+
for part in parts or []:
|
| 885 |
+
inline_data = getattr(part, "inline_data", None)
|
| 886 |
+
if inline_data and inline_data.data:
|
| 887 |
+
raw = inline_data.data
|
| 888 |
+
if isinstance(raw, str):
|
| 889 |
+
raw = base64.b64decode(raw)
|
| 890 |
+
return _normalize_output_size(Image.open(BytesIO(raw)), resolution)
|
| 891 |
+
return None
|
| 892 |
+
|
| 893 |
+
if count <= 1:
|
| 894 |
+
images = [image for image in [_one_candidate(0)] if image is not None]
|
| 895 |
+
else:
|
| 896 |
+
# Fan out the candidate calls; executor.map preserves input order.
|
| 897 |
+
with ThreadPoolExecutor(max_workers=min(count, GEN_MAX_WORKERS)) as executor:
|
| 898 |
+
images = [image for image in executor.map(_one_candidate, range(count)) if image is not None]
|
| 899 |
+
|
| 900 |
+
if not images:
|
| 901 |
+
raise RuntimeError("Gemini did not return image data.")
|
| 902 |
+
_log(f"gemini done images={len(images)} elapsed={time.perf_counter() - started:.1f}s", request_id)
|
| 903 |
+
return images
|
| 904 |
+
|
| 905 |
+
|
| 906 |
+
def generate_model_cuts(
|
| 907 |
+
product_images: list[Optional[Image.Image]],
|
| 908 |
+
model_face: Image.Image,
|
| 909 |
+
selected_reference_image: Optional[Image.Image],
|
| 910 |
+
pose_reference_image: Optional[Image.Image],
|
| 911 |
+
image_model: str,
|
| 912 |
+
selected_product: str,
|
| 913 |
+
category: str,
|
| 914 |
+
fit: str,
|
| 915 |
+
length: str,
|
| 916 |
+
style: str,
|
| 917 |
+
prompt: str,
|
| 918 |
+
pose: str,
|
| 919 |
+
resolution: str,
|
| 920 |
+
total_length_cm: str,
|
| 921 |
+
generation_mode: str,
|
| 922 |
+
shot_type: str,
|
| 923 |
+
shot_types: list[str],
|
| 924 |
+
selected_base_index: int,
|
| 925 |
+
only_selected_cut: bool,
|
| 926 |
+
model_body: Optional[Image.Image] = None,
|
| 927 |
+
request_id: str = "-",
|
| 928 |
+
) -> tuple[list[Image.Image], list[str]]:
|
| 929 |
+
product_match = re.search(r"\d+", selected_product or "")
|
| 930 |
+
product_index = max(0, min(3, int(product_match.group(0)) - 1 if product_match else 0))
|
| 931 |
+
selected_pair = product_images[product_index * 2 : product_index * 2 + 2]
|
| 932 |
+
primary_product = next((image for image in selected_pair + product_images if image is not None), None)
|
| 933 |
+
length_label = f"{length} / {total_length_cm}cm" if total_length_cm else length
|
| 934 |
+
provider, requested_model = _split_provider_model(image_model)
|
| 935 |
+
model = _resolve_model(provider, requested_model)
|
| 936 |
+
# Body-type reference: explicit upload wins, otherwise fall back to assets preset (may be None).
|
| 937 |
+
body_reference = model_body or load_body_reference()
|
| 938 |
+
front_products = [image for image in product_images if image is not None]
|
| 939 |
+
_log(
|
| 940 |
+
f"compose mode={generation_mode} provider={provider} model={model} resolution={resolution} "
|
| 941 |
+
f"selected_product={selected_product} selected_pair={[_image_summary(image) for image in selected_pair]} "
|
| 942 |
+
f"selected_reference={_image_summary(selected_reference_image)} pose_reference={_image_summary(pose_reference_image)} "
|
| 943 |
+
f"face={_image_summary(model_face)} body_reference={_image_summary(body_reference)} "
|
| 944 |
+
f"shot_type={shot_type or '-'} shot_types={shot_types or []}",
|
| 945 |
+
request_id,
|
| 946 |
+
)
|
| 947 |
+
composed_prompt = _compose_generation_prompt(
|
| 948 |
+
category=category,
|
| 949 |
+
fit=fit,
|
| 950 |
+
length=length,
|
| 951 |
+
style=style,
|
| 952 |
+
prompt=prompt,
|
| 953 |
+
pose=pose,
|
| 954 |
+
total_length_cm=total_length_cm,
|
| 955 |
+
generation_mode=generation_mode,
|
| 956 |
+
shot_type=shot_type,
|
| 957 |
+
selected_base_index=selected_base_index,
|
| 958 |
+
has_body_reference=body_reference is not None,
|
| 959 |
+
has_pose_reference=False,
|
| 960 |
+
product_count=len(front_products),
|
| 961 |
+
)
|
| 962 |
+
|
| 963 |
+
if generation_mode in {"shot_variant", "shot_batch"}:
|
| 964 |
+
selected_shots = shot_types if generation_mode == "shot_batch" and shot_types else [shot_type or "?๊พฉ๋(?๋จฏ์?ั์ซฐ)"]
|
| 965 |
+
reference_face = None if selected_reference_image is not None else model_face
|
| 966 |
+
|
| 967 |
+
def _render_shot(selected_shot: str) -> list[Image.Image]:
|
| 968 |
+
# User-uploaded pose wins; otherwise load the named reference for this exact shot.
|
| 969 |
+
shot_pose = pose_reference_image or load_shot_reference(selected_shot)
|
| 970 |
+
# Order matters: base image first, then the pose/framing reference.
|
| 971 |
+
references = [
|
| 972 |
+
image
|
| 973 |
+
for image in [reference_face, selected_reference_image, shot_pose]
|
| 974 |
+
if image is not None
|
| 975 |
+
]
|
| 976 |
+
_log(
|
| 977 |
+
f"transform shot={selected_shot} refs={len(references)} "
|
| 978 |
+
f"ref_sizes={[_image_summary(image) for image in references]} "
|
| 979 |
+
f"pose={'upload' if pose_reference_image is not None else ('named' if shot_pose is not None else 'none')}",
|
| 980 |
+
request_id,
|
| 981 |
+
)
|
| 982 |
+
shot_prompt = _compose_transform_prompt(
|
| 983 |
+
shot_type=selected_shot,
|
| 984 |
+
prompt=prompt,
|
| 985 |
+
total_length_cm=total_length_cm,
|
| 986 |
+
selected_base_index=selected_base_index,
|
| 987 |
+
has_pose_reference=shot_pose is not None,
|
| 988 |
+
)
|
| 989 |
+
if provider == "openai":
|
| 990 |
+
shot_images = _generate_with_openai(references, model, shot_prompt, resolution, 1, request_id)
|
| 991 |
+
else:
|
| 992 |
+
shot_images = _generate_with_gemini(references, model, shot_prompt, resolution, 1, request_id)
|
| 993 |
+
# Crop/scale the output to match the reference's framing โ except for extreme
|
| 994 |
+
# garment crops (detail / close-up) where subject detection is unreliable.
|
| 995 |
+
if MATCH_REFERENCE_FRAMING and shot_pose is not None and selected_shot not in _NO_REFRAME_SHOTS:
|
| 996 |
+
target_size = TARGET_SIZES.get(resolution, TARGET_SIZES["1K"])
|
| 997 |
+
shot_images = [_reframe_to_reference(image, shot_pose, target_size) for image in shot_images]
|
| 998 |
+
return shot_images
|
| 999 |
+
|
| 1000 |
+
try:
|
| 1001 |
+
if provider in {"openai", "gemini"}:
|
| 1002 |
+
if len(selected_shots) <= 1:
|
| 1003 |
+
results = [_render_shot(selected_shots[0])]
|
| 1004 |
+
else:
|
| 1005 |
+
# Shots are independent โ fan out. executor.map keeps the input order,
|
| 1006 |
+
# so images stay aligned with their labels.
|
| 1007 |
+
with ThreadPoolExecutor(max_workers=min(len(selected_shots), GEN_MAX_WORKERS)) as executor:
|
| 1008 |
+
results = list(executor.map(_render_shot, selected_shots))
|
| 1009 |
+
images = [image for shot_images in results for image in shot_images]
|
| 1010 |
+
labels = list(selected_shots)
|
| 1011 |
+
return images, labels
|
| 1012 |
+
except Exception as error:
|
| 1013 |
+
if not DEMO_FALLBACK:
|
| 1014 |
+
raise
|
| 1015 |
+
print(f"Real image generation failed, using demo renderer: {error}")
|
| 1016 |
+
|
| 1017 |
+
elif generation_mode in {"front_candidates", "front_candidate"}:
|
| 1018 |
+
front_count = 1 if generation_mode == "front_candidate" else 3
|
| 1019 |
+
# Reference order: face (identity) โ body-type (physique) โ product garments.
|
| 1020 |
+
front_references = [model_face]
|
| 1021 |
+
if body_reference is not None:
|
| 1022 |
+
front_references.append(body_reference)
|
| 1023 |
+
front_references.extend(front_products)
|
| 1024 |
+
try:
|
| 1025 |
+
if provider == "openai":
|
| 1026 |
+
images = _generate_with_openai(front_references, model, composed_prompt, resolution, front_count, request_id)
|
| 1027 |
+
elif provider == "gemini":
|
| 1028 |
+
images = _generate_with_gemini(front_references, model, composed_prompt, resolution, front_count, request_id)
|
| 1029 |
+
else:
|
| 1030 |
+
images = None
|
| 1031 |
+
if images is not None:
|
| 1032 |
+
# Re-crop so the subject sits in the same vertical band as the framing reference.
|
| 1033 |
+
# Prefer the dedicated ์ ์ (์๋ฉด) reference, else fall back to the body reference.
|
| 1034 |
+
framing_ref = load_shot_reference("์ ์ (์๋ฉด)") or body_reference
|
| 1035 |
+
if MATCH_REFERENCE_FRAMING and (framing_ref is not None or (FRAMING_TOP_MARGIN and FRAMING_BOTTOM_MARGIN)):
|
| 1036 |
+
target_size = TARGET_SIZES.get(resolution, TARGET_SIZES["1K"])
|
| 1037 |
+
reframed = [_reframe_to_reference(image, framing_ref, target_size) for image in images]
|
| 1038 |
+
_log(f"reframe applied to {len(reframed)} front candidate(s) target={target_size}", request_id)
|
| 1039 |
+
images = reframed
|
| 1040 |
+
return images, [f"์ ์ (์ ๋ฉด) ํ๋ณด {index + 1}" for index in range(front_count)]
|
| 1041 |
+
except Exception as error:
|
| 1042 |
+
if not DEMO_FALLBACK:
|
| 1043 |
+
raise
|
| 1044 |
+
print(f"Real image generation failed, using demo renderer: {error}")
|
| 1045 |
+
|
| 1046 |
+
if generation_mode in {"shot_variant", "shot_batch"}:
|
| 1047 |
+
selected_shots = shot_types if generation_mode == "shot_batch" and shot_types else [shot_type or "?๊พฉ๋(?๋จฏ์?ั์ซฐ)"]
|
| 1048 |
+
images = []
|
| 1049 |
+
labels = []
|
| 1050 |
+
base_label = f"?์ข๊นฎ ่?{selected_base_index + 1}"
|
| 1051 |
+
shift_map = {
|
| 1052 |
+
"์ ์ (์์ ํฌ์ฆ)": -36,
|
| 1053 |
+
"์ ์ (์ธก๋ฉด)": 42,
|
| 1054 |
+
"์ ์ (ํ๋ฉด)": 0,
|
| 1055 |
+
"์๋ฐ์ ": 0,
|
| 1056 |
+
"์๋ฐ์ (ํ๋ฉด)": 18,
|
| 1057 |
+
"ํ๋ฐ์ ": -18,
|
| 1058 |
+
"ํ๋ฐ์ (์์ ํฌ์ฆ)": 34,
|
| 1059 |
+
"๋ํ
์ผ(์์)": 0,
|
| 1060 |
+
"๋ํ
์ผ(ํฌ์ผ)": -22,
|
| 1061 |
+
"๋ํ
์ผ(์ ๋ฐ)": 22,
|
| 1062 |
+
}
|
| 1063 |
+
for shot_label in selected_shots:
|
| 1064 |
+
label = f"{shot_label} / {base_label}"
|
| 1065 |
+
image = _draw_model_cut(primary_product, model_face, label, resolution, shift_map.get(shot_label, 0), shot_label)
|
| 1066 |
+
images.append(image)
|
| 1067 |
+
labels.append(shot_label)
|
| 1068 |
+
return images, labels
|
| 1069 |
+
|
| 1070 |
+
fallback_count = 1 if generation_mode == "front_candidate" else 3
|
| 1071 |
+
labels = [
|
| 1072 |
+
f"์ ์ (์ ๋ฉด) ํ๋ณด 1 / {category} / {fit} / {length_label}",
|
| 1073 |
+
f"์ ์ (์ ๋ฉด) ํ๋ณด 2 / {style}",
|
| 1074 |
+
f"์ ์ (์ ๋ฉด) ํ๋ณด 3 / {pose}",
|
| 1075 |
+
][:fallback_count]
|
| 1076 |
+
shifts = [0, -18, 18][:fallback_count]
|
| 1077 |
+
images = [
|
| 1078 |
+
_draw_model_cut(primary_product, model_face, label, resolution, shift, "์ ์ (์ ๋ฉด)")
|
| 1079 |
+
for label, shift in zip(labels, shifts)
|
| 1080 |
+
]
|
| 1081 |
+
return images, [f"์ ์ (์ ๋ฉด) ํ๋ณด {index + 1}" for index in range(fallback_count)]
|
| 1082 |
+
|
| 1083 |
+
|
| 1084 |
+
@app.get("/")
|
| 1085 |
+
def index() -> FileResponse:
|
| 1086 |
+
return FileResponse(BASE_DIR / "index.html")
|
| 1087 |
+
|
| 1088 |
+
|
| 1089 |
+
@app.get("/styles.css")
|
| 1090 |
+
def styles() -> FileResponse:
|
| 1091 |
+
return FileResponse(BASE_DIR / "styles.css")
|
| 1092 |
+
|
| 1093 |
+
|
| 1094 |
+
@app.get("/script.js")
|
| 1095 |
+
def script() -> FileResponse:
|
| 1096 |
+
return FileResponse(BASE_DIR / "script.js")
|
| 1097 |
+
|
| 1098 |
+
|
| 1099 |
+
@app.get("/model_face_preset.png")
|
| 1100 |
+
def model_face_preset() -> Response:
|
| 1101 |
+
for preset_path in PRESET_FACE_CANDIDATES:
|
| 1102 |
+
if preset_path.exists():
|
| 1103 |
+
return FileResponse(preset_path)
|
| 1104 |
+
|
| 1105 |
+
return Response(content=_image_to_png_bytes(_create_fallback_face()), media_type="image/png")
|
| 1106 |
+
|
| 1107 |
+
|
| 1108 |
+
@app.get("/health")
|
| 1109 |
+
def health() -> dict[str, str]:
|
| 1110 |
+
return {"status": "ok"}
|
| 1111 |
+
|
| 1112 |
+
|
| 1113 |
+
@app.post("/api/generate")
|
| 1114 |
+
async def generate(
|
| 1115 |
+
product_1_front: Optional[UploadFile] = File(None),
|
| 1116 |
+
product_1_back: Optional[UploadFile] = File(None),
|
| 1117 |
+
product_2_front: Optional[UploadFile] = File(None),
|
| 1118 |
+
product_2_back: Optional[UploadFile] = File(None),
|
| 1119 |
+
product_3_front: Optional[UploadFile] = File(None),
|
| 1120 |
+
product_3_back: Optional[UploadFile] = File(None),
|
| 1121 |
+
product_4_front: Optional[UploadFile] = File(None),
|
| 1122 |
+
product_4_back: Optional[UploadFile] = File(None),
|
| 1123 |
+
model_face: Optional[UploadFile] = File(None),
|
| 1124 |
+
model_body: Optional[UploadFile] = File(None),
|
| 1125 |
+
face_source: str = Form("์ฒจ๋ถ ์ผ๊ตด ํ๋ฆฌ์
"),
|
| 1126 |
+
image_model: str = Form("openai:gpt-image-2"),
|
| 1127 |
+
selected_product: str = Form("์ ํ 1"),
|
| 1128 |
+
category: str = Form("์์ฐํฐ"),
|
| 1129 |
+
fit: str = Form("ํ๏ฟฝ๏ฟฝ"),
|
| 1130 |
+
length: str = Form("๋ฌด๋ฆ"),
|
| 1131 |
+
style: str = Form("์ปค๋จธ์ค ๋ฃฉ๋ถ"),
|
| 1132 |
+
prompt: str = Form(""),
|
| 1133 |
+
pose: str = Form("์ ๋ฉด"),
|
| 1134 |
+
resolution: str = Form("1K"),
|
| 1135 |
+
total_length_cm: str = Form(""),
|
| 1136 |
+
generation_mode: str = Form("front_candidates"),
|
| 1137 |
+
shot_type: str = Form(""),
|
| 1138 |
+
shot_types: str = Form(""),
|
| 1139 |
+
selected_base_index: int = Form(0),
|
| 1140 |
+
selected_reference_image: Optional[UploadFile] = File(None),
|
| 1141 |
+
pose_reference_image: Optional[UploadFile] = File(None),
|
| 1142 |
+
only_selected_cut: bool = Form(False),
|
| 1143 |
+
) -> JSONResponse:
|
| 1144 |
+
request_id = uuid.uuid4().hex[:8]
|
| 1145 |
+
request_started = time.perf_counter()
|
| 1146 |
+
_log(
|
| 1147 |
+
f"request start mode={generation_mode} shot_type={shot_type or '-'} shot_types={shot_types or '-'} "
|
| 1148 |
+
f"model={image_model} resolution={resolution} selected_product={selected_product}",
|
| 1149 |
+
request_id,
|
| 1150 |
+
)
|
| 1151 |
+
uploads = [
|
| 1152 |
+
product_1_front,
|
| 1153 |
+
product_1_back,
|
| 1154 |
+
product_2_front,
|
| 1155 |
+
product_2_back,
|
| 1156 |
+
product_3_front,
|
| 1157 |
+
product_3_back,
|
| 1158 |
+
product_4_front,
|
| 1159 |
+
product_4_back,
|
| 1160 |
+
]
|
| 1161 |
+
product_images = [await _read_upload(upload) for upload in uploads]
|
| 1162 |
+
selected_reference = await _read_upload(selected_reference_image)
|
| 1163 |
+
pose_reference = await _read_upload(pose_reference_image)
|
| 1164 |
+
uploaded_face = await _read_upload(model_face)
|
| 1165 |
+
uploaded_body = await _read_upload(model_body)
|
| 1166 |
+
_log(
|
| 1167 |
+
f"uploads products={sum(image is not None for image in product_images)}/8 "
|
| 1168 |
+
f"product_sizes={[_image_summary(image) for image in product_images if image is not None]} "
|
| 1169 |
+
f"selected_reference={_image_summary(selected_reference)} pose_reference={_image_summary(pose_reference)} "
|
| 1170 |
+
f"uploaded_face={_image_summary(uploaded_face)}",
|
| 1171 |
+
request_id,
|
| 1172 |
+
)
|
| 1173 |
+
if face_source == "?๋
์ค???์จ๋ฌ" and uploaded_face:
|
| 1174 |
+
selected_face = uploaded_face
|
| 1175 |
+
elif any(preset_path.exists() for preset_path in PRESET_FACE_CANDIDATES):
|
| 1176 |
+
selected_face = load_preset_face()
|
| 1177 |
+
elif DEMO_FALLBACK:
|
| 1178 |
+
selected_face = load_preset_face()
|
| 1179 |
+
else:
|
| 1180 |
+
return JSONResponse(
|
| 1181 |
+
{
|
| 1182 |
+
"error": "?์โ
ค?๋ฎ๊ตน ?์จ๋ฌ ?๊พจโ?๋ญ์ ?๋๋ฟ?๋๋. assets/model_face_preset.png ?๋จฎ๋ ็ทโฆ๋ model_face_preset.png็??ัโๅซ๊ณ๊ตน ?๋ถพใ?๋จฏ๊ฝ ๏งโค๋ฝ ?์จ๋ฌ???๋
์ค?์๋ธฏ?๋ช์.",
|
| 1183 |
+
"provider": _split_provider_model(image_model)[0],
|
| 1184 |
+
"model": _resolve_model(*_split_provider_model(image_model)),
|
| 1185 |
+
"generation_mode": generation_mode,
|
| 1186 |
+
"resolution": resolution,
|
| 1187 |
+
},
|
| 1188 |
+
status_code=400,
|
| 1189 |
+
)
|
| 1190 |
+
try:
|
| 1191 |
+
images, labels = await asyncio.to_thread(
|
| 1192 |
+
generate_model_cuts,
|
| 1193 |
+
product_images=product_images,
|
| 1194 |
+
model_face=selected_face,
|
| 1195 |
+
selected_reference_image=selected_reference,
|
| 1196 |
+
pose_reference_image=pose_reference,
|
| 1197 |
+
image_model=image_model,
|
| 1198 |
+
selected_product=selected_product,
|
| 1199 |
+
category=category,
|
| 1200 |
+
fit=fit,
|
| 1201 |
+
length=length,
|
| 1202 |
+
style=style,
|
| 1203 |
+
prompt=prompt,
|
| 1204 |
+
pose=pose,
|
| 1205 |
+
resolution=resolution,
|
| 1206 |
+
total_length_cm=total_length_cm,
|
| 1207 |
+
generation_mode=generation_mode,
|
| 1208 |
+
shot_type=shot_type,
|
| 1209 |
+
shot_types=[item for item in shot_types.split("|") if item],
|
| 1210 |
+
selected_base_index=selected_base_index,
|
| 1211 |
+
only_selected_cut=only_selected_cut,
|
| 1212 |
+
model_body=uploaded_body,
|
| 1213 |
+
request_id=request_id,
|
| 1214 |
+
)
|
| 1215 |
+
_log(f"request done images={len(images)} labels={labels} elapsed={time.perf_counter() - request_started:.1f}s", request_id)
|
| 1216 |
+
asyncio.create_task(
|
| 1217 |
+
asyncio.to_thread(
|
| 1218 |
+
_upload_generation_to_dataset,
|
| 1219 |
+
images,
|
| 1220 |
+
labels,
|
| 1221 |
+
{
|
| 1222 |
+
"kind": "generate",
|
| 1223 |
+
"image_model": image_model,
|
| 1224 |
+
"selected_product": selected_product,
|
| 1225 |
+
"category": category,
|
| 1226 |
+
"fit": fit,
|
| 1227 |
+
"length": length,
|
| 1228 |
+
"style": style,
|
| 1229 |
+
"pose": pose,
|
| 1230 |
+
"resolution": resolution,
|
| 1231 |
+
"total_length_cm": total_length_cm,
|
| 1232 |
+
"generation_mode": generation_mode,
|
| 1233 |
+
"shot_type": shot_type,
|
| 1234 |
+
"shot_types": [item for item in shot_types.split("|") if item],
|
| 1235 |
+
"selected_base_index": selected_base_index,
|
| 1236 |
+
"labels": labels,
|
| 1237 |
+
},
|
| 1238 |
+
request_id,
|
| 1239 |
+
)
|
| 1240 |
+
)
|
| 1241 |
+
except Exception as error:
|
| 1242 |
+
provider, requested_model = _split_provider_model(image_model)
|
| 1243 |
+
resolved_model = _resolve_model(provider, requested_model)
|
| 1244 |
+
traceback.print_exc()
|
| 1245 |
+
_log(f"request failed error={error} elapsed={time.perf_counter() - request_started:.1f}s", request_id)
|
| 1246 |
+
return JSONResponse(
|
| 1247 |
+
{
|
| 1248 |
+
"error": str(error),
|
| 1249 |
+
"provider": provider,
|
| 1250 |
+
"model": resolved_model,
|
| 1251 |
+
"generation_mode": generation_mode,
|
| 1252 |
+
"resolution": resolution,
|
| 1253 |
+
},
|
| 1254 |
+
status_code=500,
|
| 1255 |
+
)
|
| 1256 |
+
|
| 1257 |
+
return JSONResponse({"images": [_image_to_data_url(image, resolution) for image in images], "labels": labels})
|
| 1258 |
+
|
| 1259 |
+
|
| 1260 |
+
@app.post("/api/edit")
|
| 1261 |
+
async def edit_image(
|
| 1262 |
+
base_image: UploadFile = File(...),
|
| 1263 |
+
reference_images: Optional[list[UploadFile]] = File(None),
|
| 1264 |
+
image_model: str = Form("openai:gpt-image-2"),
|
| 1265 |
+
instruction: str = Form(""),
|
| 1266 |
+
background: str = Form(""),
|
| 1267 |
+
resolution: str = Form("1K"),
|
| 1268 |
+
) -> JSONResponse:
|
| 1269 |
+
try:
|
| 1270 |
+
base = await _read_upload(base_image)
|
| 1271 |
+
if base is None:
|
| 1272 |
+
return JSONResponse({"error": "?์์ ??ๆนฒ๊ณ? ?๋?๏งยๅชย ?๋๋ฟ?๋๋."}, status_code=400)
|
| 1273 |
+
|
| 1274 |
+
refs = []
|
| 1275 |
+
for upload in reference_images or []:
|
| 1276 |
+
image = await _read_upload(upload)
|
| 1277 |
+
if image is not None:
|
| 1278 |
+
refs.append(image)
|
| 1279 |
+
|
| 1280 |
+
provider, requested_model = _split_provider_model(image_model)
|
| 1281 |
+
model = _resolve_model(provider, requested_model)
|
| 1282 |
+
edit_prompt = "\n".join(
|
| 1283 |
+
[
|
| 1284 |
+
"Edit this fashion model image while preserving the same model identity, outfit, garment color, fabric texture, silhouette, and product details.",
|
| 1285 |
+
"Only apply the requested changes. Do not change the face or clothing unless explicitly requested.",
|
| 1286 |
+
f"Background preset: {background or 'keep current background'}",
|
| 1287 |
+
f"User edit instruction: {instruction or 'Regenerate naturally with the same settings.'}",
|
| 1288 |
+
]
|
| 1289 |
+
)
|
| 1290 |
+
|
| 1291 |
+
if provider == "openai":
|
| 1292 |
+
images = _generate_with_openai([base, *refs], model, edit_prompt, resolution, 1)
|
| 1293 |
+
elif provider == "gemini":
|
| 1294 |
+
images = _generate_with_gemini([base, *refs], model, edit_prompt, resolution, 1)
|
| 1295 |
+
else:
|
| 1296 |
+
return JSONResponse({"error": f"๏งย?๋จฐ๋ธฏ๏งย ?๋
๋ provider?๋
๋ฒ?? {provider}"}, status_code=400)
|
| 1297 |
+
|
| 1298 |
+
edit_request_id = uuid.uuid4().hex[:8]
|
| 1299 |
+
asyncio.create_task(
|
| 1300 |
+
asyncio.to_thread(
|
| 1301 |
+
_upload_generation_to_dataset,
|
| 1302 |
+
images,
|
| 1303 |
+
["์์ ์ด๋ฏธ์ง"],
|
| 1304 |
+
{
|
| 1305 |
+
"kind": "edit",
|
| 1306 |
+
"image_model": image_model,
|
| 1307 |
+
"resolution": resolution,
|
| 1308 |
+
"background": background,
|
| 1309 |
+
"instruction": instruction,
|
| 1310 |
+
"labels": ["์์ ์ด๋ฏธ์ง"],
|
| 1311 |
+
},
|
| 1312 |
+
edit_request_id,
|
| 1313 |
+
)
|
| 1314 |
+
)
|
| 1315 |
+
return JSONResponse({"images": [_image_to_data_url(image, resolution) for image in images], "labels": ["?์์ ?๋?๏งย"]})
|
| 1316 |
+
except Exception as error:
|
| 1317 |
+
provider, requested_model = _split_provider_model(image_model)
|
| 1318 |
+
traceback.print_exc()
|
| 1319 |
+
return JSONResponse(
|
| 1320 |
+
{
|
| 1321 |
+
"error": str(error),
|
| 1322 |
+
"provider": provider,
|
| 1323 |
+
"model": _resolve_model(provider, requested_model),
|
| 1324 |
+
"resolution": resolution,
|
| 1325 |
+
},
|
| 1326 |
+
status_code=500,
|
| 1327 |
+
)
|
| 1328 |
+
|
| 1329 |
+
|
| 1330 |
+
if __name__ == "__main__":
|
| 1331 |
+
port = int(os.environ.get("PORT", "7860"))
|
| 1332 |
+
uvicorn.run("app:app", host="0.0.0.0", port=port)
|