Spaces:
Running
Running
Commit Β·
14a628b
1
Parent(s): eb9857b
Phase 1: bulk-upload endpoints (bulk-crops, screen-types, anchors)
Browse files
README.md
CHANGED
|
@@ -35,10 +35,10 @@ See the source repo for the full API reference. Main surface:
|
|
| 35 |
- `GET /health` β service status
|
| 36 |
- `GET /knowledge` β merged icon knowledge base (phash β name)
|
| 37 |
- `GET /model/version` β latest trained model metadata
|
| 38 |
-
- `POST /contribute` β single crop + label
|
| 39 |
-
- `POST /contribute/bulk` β batch of crops
|
| 40 |
-
- `POST /upload/screen-types` β screen-type
|
| 41 |
-
- `POST /upload/anchors` β anchor
|
| 42 |
- `POST /admin/merge` β admin-only knowledge merge
|
| 43 |
|
| 44 |
## Secrets
|
|
@@ -48,6 +48,7 @@ Configured in Space Settings β Secrets:
|
|
| 48 |
- `HF_TOKEN` β fine-grained write token, scoped to both
|
| 49 |
`datasets/sets-sto/warp-knowledge` (current `/contribute`) and
|
| 50 |
`datasets/sets-sto/sto-icon-dataset` (Phase 1 bulk endpoints)
|
| 51 |
-
- `HF_REPO_ID` β e.g. `sets-sto/warp-knowledge`
|
|
|
|
| 52 |
- `ADMIN_KEY` β guards `/admin/*` endpoints
|
| 53 |
- `MAX_REQ_PER_IP` β per-IP daily rate cap
|
|
|
|
| 35 |
- `GET /health` β service status
|
| 36 |
- `GET /knowledge` β merged icon knowledge base (phash β name)
|
| 37 |
- `GET /model/version` β latest trained model metadata
|
| 38 |
+
- `POST /contribute` β single crop + label (legacy, β `sets-sto/warp-knowledge`)
|
| 39 |
+
- `POST /contribute/bulk-crops` β batch of confirmed crops + annotations (β `sets-sto/sto-icon-dataset`)
|
| 40 |
+
- `POST /upload/screen-types` β batch of screen-type screenshots (β `sets-sto/sto-icon-dataset`)
|
| 41 |
+
- `POST /upload/anchors` β batch of anchor grids (β `sets-sto/sto-icon-dataset`)
|
| 42 |
- `POST /admin/merge` β admin-only knowledge merge
|
| 43 |
|
| 44 |
## Secrets
|
|
|
|
| 48 |
- `HF_TOKEN` β fine-grained write token, scoped to both
|
| 49 |
`datasets/sets-sto/warp-knowledge` (current `/contribute`) and
|
| 50 |
`datasets/sets-sto/sto-icon-dataset` (Phase 1 bulk endpoints)
|
| 51 |
+
- `HF_REPO_ID` β e.g. `sets-sto/warp-knowledge` (target for `/contribute`, `/admin/merge`)
|
| 52 |
+
- `HF_ICONS_REPO_ID` β e.g. `sets-sto/sto-icon-dataset` (target for Phase 1 bulk endpoints; defaults to that value if unset)
|
| 53 |
- `ADMIN_KEY` β guards `/admin/*` endpoints
|
| 54 |
- `MAX_REQ_PER_IP` β per-IP daily rate cap
|
main.py
CHANGED
|
@@ -64,10 +64,30 @@ def _load_env():
|
|
| 64 |
_load_env()
|
| 65 |
|
| 66 |
# ββ Config from environment ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 67 |
-
HF_TOKEN
|
| 68 |
-
HF_REPO_ID
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
# GitHub Config for automated training triggers
|
| 73 |
GH_TOKEN = os.environ.get('GH_TOKEN', '')
|
|
@@ -123,6 +143,52 @@ class ContributeRequest(BaseModel):
|
|
| 123 |
return re.sub(r'[^a-zA-Z0-9\-_]', '', v)[:64]
|
| 124 |
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
# ββ Endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 127 |
|
| 128 |
@app.get('/health')
|
|
@@ -222,6 +288,225 @@ async def contribute(req: ContributeRequest, request: Request):
|
|
| 222 |
return {'ok': True, 'contribution_id': contrib_id}
|
| 223 |
|
| 224 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
@app.post('/webhooks/hf-dataset')
|
| 226 |
async def hf_dataset_webhook(request: Request):
|
| 227 |
"""
|
|
@@ -319,6 +604,62 @@ async def admin_merge(
|
|
| 319 |
|
| 320 |
# ββ Validation helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 321 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
def is_valid_crop(png_bytes: bytes) -> bool:
|
| 323 |
"""Checks if crop is valid (not garbage/too uniform)."""
|
| 324 |
try:
|
|
@@ -334,10 +675,20 @@ def is_valid_crop(png_bytes: bytes) -> bool:
|
|
| 334 |
|
| 335 |
# ββ HF Dataset helpers ββββββββββββββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββββββββββ
|
| 336 |
|
| 337 |
-
def _hf_upload_files(
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
return False
|
| 342 |
try:
|
| 343 |
from huggingface_hub import HfApi, CommitOperationAdd
|
|
@@ -347,17 +698,42 @@ def _hf_upload_files(files: dict[str, bytes], message: str = 'WARP auto-upload')
|
|
| 347 |
for path, content in files.items()
|
| 348 |
]
|
| 349 |
api.create_commit(
|
| 350 |
-
repo_id=
|
| 351 |
repo_type='dataset',
|
| 352 |
operations=operations,
|
| 353 |
commit_message=message,
|
| 354 |
)
|
| 355 |
return True
|
| 356 |
except Exception as e:
|
| 357 |
-
log.error(f'HF atomic upload failed: {e}')
|
| 358 |
return False
|
| 359 |
|
| 360 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
def _load_model_version_from_hf() -> dict:
|
| 362 |
"""Download models/model_version.json from HF."""
|
| 363 |
if not HF_REPO_ID:
|
|
|
|
| 64 |
_load_env()
|
| 65 |
|
| 66 |
# ββ Config from environment ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 67 |
+
HF_TOKEN = os.environ.get('HF_TOKEN', '')
|
| 68 |
+
HF_REPO_ID = os.environ.get('HF_REPO_ID', 'sets-sto/warp-knowledge')
|
| 69 |
+
# Icon-dataset target for bulk-crops, screen-types and anchor-grid uploads
|
| 70 |
+
# (Phase 1 of backend-proxy migration). Separate from HF_REPO_ID because
|
| 71 |
+
# the icon training data lives in its own dataset.
|
| 72 |
+
HF_ICONS_REPO_ID = os.environ.get('HF_ICONS_REPO_ID', 'sets-sto/sto-icon-dataset')
|
| 73 |
+
ADMIN_KEY = os.environ.get('ADMIN_KEY', '')
|
| 74 |
+
MAX_REQ_PER_IP = int(os.environ.get('MAX_REQ_PER_IP', '500'))
|
| 75 |
+
|
| 76 |
+
# Bulk-endpoint payload limits β guards against accidental floods and HF
|
| 77 |
+
# commit-size limits. Per-item caps mirror client-side validation in
|
| 78 |
+
# warp/trainer/sync.py.
|
| 79 |
+
MAX_BULK_CROPS = 50
|
| 80 |
+
MAX_BULK_SCREEN_TYPES = 20
|
| 81 |
+
MAX_BULK_ANCHOR_GRIDS = 20
|
| 82 |
+
MAX_CROP_PNG_BYTES = 150_000 # icon crop
|
| 83 |
+
MAX_SCREEN_PNG_BYTES = 2_500_000 # full screenshot
|
| 84 |
+
MIN_CROP_PX = 16
|
| 85 |
+
MIN_TEXT_CROP_H = 10
|
| 86 |
+
MIN_TEXT_CROP_W = 50
|
| 87 |
+
MAX_NAME_LEN = 120
|
| 88 |
+
_TEXT_CROP_PREFIXES = ('ship_type_', 'ship_tier_')
|
| 89 |
+
_INSTALL_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{8,64}$')
|
| 90 |
+
_SCREEN_TYPE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,40}$')
|
| 91 |
|
| 92 |
# GitHub Config for automated training triggers
|
| 93 |
GH_TOKEN = os.environ.get('GH_TOKEN', '')
|
|
|
|
| 143 |
return re.sub(r'[^a-zA-Z0-9\-_]', '', v)[:64]
|
| 144 |
|
| 145 |
|
| 146 |
+
# ββ Phase 1: bulk-upload request models βββββββββββββββββββββββββββββββββββββββ
|
| 147 |
+
#
|
| 148 |
+
# These mirror the payloads previously built by warp/trainer/sync.py on the
|
| 149 |
+
# client side. The client used to call HfApi.create_commit directly with a
|
| 150 |
+
# write-scoped HF token; in Phase 2 it will POST these payloads to the
|
| 151 |
+
# backend instead, so the token never leaves the server.
|
| 152 |
+
|
| 153 |
+
class _BulkCropItem(BaseModel):
|
| 154 |
+
slot: str = Field(..., min_length=1, max_length=80)
|
| 155 |
+
name: str = Field(..., min_length=1, max_length=MAX_NAME_LEN)
|
| 156 |
+
crop_png_b64: str = Field(..., min_length=100, max_length=200_000)
|
| 157 |
+
ml_name: str = Field('', max_length=MAX_NAME_LEN)
|
| 158 |
+
|
| 159 |
+
@field_validator('name', 'ml_name', 'slot')
|
| 160 |
+
@classmethod
|
| 161 |
+
def _strip_ctrl(cls, v: str) -> str:
|
| 162 |
+
return re.sub(r'[\x00-\x1f\x7f]', '', v).strip()
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
class BulkCropsRequest(BaseModel):
|
| 166 |
+
install_id: str = Field(..., min_length=8, max_length=64)
|
| 167 |
+
items: list[_BulkCropItem] = Field(..., min_length=1, max_length=MAX_BULK_CROPS)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
class _ScreenTypeItem(BaseModel):
|
| 171 |
+
png_b64: str = Field(..., min_length=100, max_length=4_000_000)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
class ScreenTypesRequest(BaseModel):
|
| 175 |
+
install_id: str = Field(..., min_length=8, max_length=64)
|
| 176 |
+
screen_type: str = Field(..., min_length=1, max_length=40)
|
| 177 |
+
items: list[_ScreenTypeItem] = Field(..., min_length=1, max_length=MAX_BULK_SCREEN_TYPES)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
class _AnchorGrid(BaseModel):
|
| 181 |
+
build_type: str = Field(..., min_length=1, max_length=40)
|
| 182 |
+
aspect: str | None = Field(None, max_length=16)
|
| 183 |
+
resolution: str = Field('', max_length=16)
|
| 184 |
+
slots: dict[str, list[float]] = Field(..., min_length=3)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class AnchorsRequest(BaseModel):
|
| 188 |
+
install_id: str = Field(..., min_length=8, max_length=64)
|
| 189 |
+
grids: list[_AnchorGrid] = Field(..., min_length=1, max_length=MAX_BULK_ANCHOR_GRIDS)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
# ββ Endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 193 |
|
| 194 |
@app.get('/health')
|
|
|
|
| 288 |
return {'ok': True, 'contribution_id': contrib_id}
|
| 289 |
|
| 290 |
|
| 291 |
+
# ββ Phase 1: bulk-upload endpoints ββββββββββββββββββββββββββββββββββββββββββββ
|
| 292 |
+
#
|
| 293 |
+
# These replace the direct HF writes that warp/trainer/sync.py used to
|
| 294 |
+
# perform with a client-side write token. Each endpoint accepts a batch,
|
| 295 |
+
# validates per-item, and produces a single HF commit so we stay well
|
| 296 |
+
# inside HF API rate limits.
|
| 297 |
+
|
| 298 |
+
@app.post('/contribute/bulk-crops')
|
| 299 |
+
async def contribute_bulk_crops(req: BulkCropsRequest, request: Request):
|
| 300 |
+
"""Accept a batch of confirmed crops + annotations.
|
| 301 |
+
|
| 302 |
+
Mirrors warp/trainer/sync.py:_upload(): writes PNGs to
|
| 303 |
+
staging/<install_id>/crops/<sha>.png and rewrites
|
| 304 |
+
staging/<install_id>/annotations.jsonl with last-wins dedup per sha.
|
| 305 |
+
All writes happen in a single HF commit.
|
| 306 |
+
"""
|
| 307 |
+
client_ip = _get_client_ip(request)
|
| 308 |
+
if not await _check_and_increment_rate_limit(client_ip):
|
| 309 |
+
raise HTTPException(429, 'Rate limit exceeded. Try again tomorrow.')
|
| 310 |
+
|
| 311 |
+
install_id = req.install_id.strip()
|
| 312 |
+
if not _INSTALL_ID_RE.match(install_id):
|
| 313 |
+
raise HTTPException(400, 'Invalid install_id format')
|
| 314 |
+
|
| 315 |
+
today = date.today().isoformat()
|
| 316 |
+
staging_dir = f'staging/{install_id}'
|
| 317 |
+
staging_crop = f'{staging_dir}/crops'
|
| 318 |
+
staging_anno = f'{staging_dir}/annotations.jsonl'
|
| 319 |
+
|
| 320 |
+
files_to_upload: dict[str, bytes] = {}
|
| 321 |
+
new_entries: list[dict] = []
|
| 322 |
+
accepted = 0
|
| 323 |
+
rejected = 0
|
| 324 |
+
reasons: list[str] = []
|
| 325 |
+
|
| 326 |
+
for item in req.items:
|
| 327 |
+
name = item.name.strip()
|
| 328 |
+
slot = item.slot.strip()
|
| 329 |
+
if not name or not slot:
|
| 330 |
+
rejected += 1
|
| 331 |
+
reasons.append('empty name/slot')
|
| 332 |
+
continue
|
| 333 |
+
if not name.isprintable():
|
| 334 |
+
rejected += 1
|
| 335 |
+
reasons.append('non-printable name')
|
| 336 |
+
continue
|
| 337 |
+
if name.startswith('__') or name == 'Test Item Name':
|
| 338 |
+
rejected += 1
|
| 339 |
+
reasons.append(f'poison label {name!r}')
|
| 340 |
+
continue
|
| 341 |
+
|
| 342 |
+
try:
|
| 343 |
+
png_bytes = base64.b64decode(item.crop_png_b64)
|
| 344 |
+
except Exception:
|
| 345 |
+
rejected += 1
|
| 346 |
+
reasons.append('b64 decode failed')
|
| 347 |
+
continue
|
| 348 |
+
if not png_bytes.startswith(b'\x89PNG'):
|
| 349 |
+
rejected += 1
|
| 350 |
+
reasons.append('not a PNG')
|
| 351 |
+
continue
|
| 352 |
+
if len(png_bytes) > MAX_CROP_PNG_BYTES:
|
| 353 |
+
rejected += 1
|
| 354 |
+
reasons.append(f'PNG too large ({len(png_bytes)} B)')
|
| 355 |
+
continue
|
| 356 |
+
|
| 357 |
+
err = _check_crop_dims(png_bytes, slot)
|
| 358 |
+
if err:
|
| 359 |
+
rejected += 1
|
| 360 |
+
reasons.append(err)
|
| 361 |
+
continue
|
| 362 |
+
|
| 363 |
+
sha = hashlib.sha256(png_bytes).hexdigest()[:32]
|
| 364 |
+
crop_path = f'{staging_crop}/{sha}.png'
|
| 365 |
+
files_to_upload[crop_path] = png_bytes
|
| 366 |
+
|
| 367 |
+
entry = {
|
| 368 |
+
'slot': slot,
|
| 369 |
+
'name': name,
|
| 370 |
+
'crop_sha256': sha,
|
| 371 |
+
'date': today,
|
| 372 |
+
}
|
| 373 |
+
if item.ml_name:
|
| 374 |
+
entry['ml_name'] = item.ml_name
|
| 375 |
+
new_entries.append(entry)
|
| 376 |
+
accepted += 1
|
| 377 |
+
|
| 378 |
+
if not new_entries:
|
| 379 |
+
raise HTTPException(400, f'All {rejected} items rejected: {reasons[:3]}')
|
| 380 |
+
|
| 381 |
+
# Merge annotations.jsonl with last-wins per sha (matches client logic).
|
| 382 |
+
merged_jsonl = _merge_annotations_jsonl(install_id, new_entries)
|
| 383 |
+
files_to_upload[staging_anno] = merged_jsonl
|
| 384 |
+
|
| 385 |
+
ok = _hf_upload_files(
|
| 386 |
+
files_to_upload,
|
| 387 |
+
message=f'WARP bulk: {accepted} crops + annotations ({today})',
|
| 388 |
+
repo_id=HF_ICONS_REPO_ID,
|
| 389 |
+
)
|
| 390 |
+
if not ok:
|
| 391 |
+
raise HTTPException(503, 'Storage unavailable, please try later')
|
| 392 |
+
|
| 393 |
+
log.info(f'Bulk crops accepted: install={install_id[:8]} accepted={accepted} rejected={rejected}')
|
| 394 |
+
return {'ok': True, 'accepted': accepted, 'rejected': rejected,
|
| 395 |
+
'rejected_reasons': reasons[:10] if rejected else []}
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
@app.post('/upload/screen-types')
|
| 399 |
+
async def upload_screen_types(req: ScreenTypesRequest, request: Request):
|
| 400 |
+
"""Accept a batch of screen-type screenshots for one screen_type label."""
|
| 401 |
+
client_ip = _get_client_ip(request)
|
| 402 |
+
if not await _check_and_increment_rate_limit(client_ip):
|
| 403 |
+
raise HTTPException(429, 'Rate limit exceeded. Try again tomorrow.')
|
| 404 |
+
|
| 405 |
+
install_id = req.install_id.strip()
|
| 406 |
+
if not _INSTALL_ID_RE.match(install_id):
|
| 407 |
+
raise HTTPException(400, 'Invalid install_id format')
|
| 408 |
+
stype = req.screen_type.strip()
|
| 409 |
+
if not _SCREEN_TYPE_RE.match(stype):
|
| 410 |
+
raise HTTPException(400, 'Invalid screen_type')
|
| 411 |
+
|
| 412 |
+
base_dir = f'staging/{install_id}/screen_types/{stype}'
|
| 413 |
+
files_to_upload: dict[str, bytes] = {}
|
| 414 |
+
accepted = 0
|
| 415 |
+
rejected = 0
|
| 416 |
+
reasons: list[str] = []
|
| 417 |
+
|
| 418 |
+
for item in req.items:
|
| 419 |
+
try:
|
| 420 |
+
png_bytes = base64.b64decode(item.png_b64)
|
| 421 |
+
except Exception:
|
| 422 |
+
rejected += 1
|
| 423 |
+
reasons.append('b64 decode failed')
|
| 424 |
+
continue
|
| 425 |
+
if not png_bytes.startswith(b'\x89PNG'):
|
| 426 |
+
rejected += 1
|
| 427 |
+
reasons.append('not a PNG')
|
| 428 |
+
continue
|
| 429 |
+
if len(png_bytes) > MAX_SCREEN_PNG_BYTES:
|
| 430 |
+
rejected += 1
|
| 431 |
+
reasons.append(f'PNG too large ({len(png_bytes)} B)')
|
| 432 |
+
continue
|
| 433 |
+
|
| 434 |
+
sha = hashlib.sha256(png_bytes).hexdigest()[:32]
|
| 435 |
+
files_to_upload[f'{base_dir}/{sha}.png'] = png_bytes
|
| 436 |
+
accepted += 1
|
| 437 |
+
|
| 438 |
+
if not files_to_upload:
|
| 439 |
+
raise HTTPException(400, f'All {rejected} items rejected: {reasons[:3]}')
|
| 440 |
+
|
| 441 |
+
ok = _hf_upload_files(
|
| 442 |
+
files_to_upload,
|
| 443 |
+
message=f'WARP screen types: {accepted} {stype} screenshots',
|
| 444 |
+
repo_id=HF_ICONS_REPO_ID,
|
| 445 |
+
)
|
| 446 |
+
if not ok:
|
| 447 |
+
raise HTTPException(503, 'Storage unavailable, please try later')
|
| 448 |
+
|
| 449 |
+
log.info(f'Screen types accepted: install={install_id[:8]} type={stype} accepted={accepted} rejected={rejected}')
|
| 450 |
+
return {'ok': True, 'accepted': accepted, 'rejected': rejected}
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
@app.post('/upload/anchors')
|
| 454 |
+
async def upload_anchors(req: AnchorsRequest, request: Request):
|
| 455 |
+
"""Accept a batch of anchor grids (one file per grid, keyed by sha8)."""
|
| 456 |
+
client_ip = _get_client_ip(request)
|
| 457 |
+
if not await _check_and_increment_rate_limit(client_ip):
|
| 458 |
+
raise HTTPException(429, 'Rate limit exceeded. Try again tomorrow.')
|
| 459 |
+
|
| 460 |
+
install_id = req.install_id.strip()
|
| 461 |
+
if not _INSTALL_ID_RE.match(install_id):
|
| 462 |
+
raise HTTPException(400, 'Invalid install_id format')
|
| 463 |
+
|
| 464 |
+
base_dir = f'staging/{install_id}'
|
| 465 |
+
files_to_upload: dict[str, bytes] = {}
|
| 466 |
+
accepted = 0
|
| 467 |
+
rejected = 0
|
| 468 |
+
reasons: list[str] = []
|
| 469 |
+
|
| 470 |
+
for grid in req.grids:
|
| 471 |
+
slots = grid.slots
|
| 472 |
+
if len(slots) < 3:
|
| 473 |
+
rejected += 1
|
| 474 |
+
reasons.append('fewer than 3 slots')
|
| 475 |
+
continue
|
| 476 |
+
# Each bbox must be [x, y, w, h] (4 floats); guard against malformed payloads.
|
| 477 |
+
if any(len(v) != 4 for v in slots.values()):
|
| 478 |
+
rejected += 1
|
| 479 |
+
reasons.append('bbox not 4-tuple')
|
| 480 |
+
continue
|
| 481 |
+
|
| 482 |
+
payload = {
|
| 483 |
+
'build_type': grid.build_type,
|
| 484 |
+
'aspect': grid.aspect,
|
| 485 |
+
'resolution': grid.resolution,
|
| 486 |
+
'slots': slots,
|
| 487 |
+
}
|
| 488 |
+
# sort_keys=True must match the client's canonical form so hashes
|
| 489 |
+
# line up and we don't duplicate the same grid as a different file.
|
| 490 |
+
payload_json = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
| 491 |
+
sha8 = hashlib.sha256(payload_json.encode()).hexdigest()[:8]
|
| 492 |
+
files_to_upload[f'{base_dir}/anchors_grid_{sha8}.json'] = payload_json.encode('utf-8')
|
| 493 |
+
accepted += 1
|
| 494 |
+
|
| 495 |
+
if not files_to_upload:
|
| 496 |
+
raise HTTPException(400, f'All {rejected} grids rejected: {reasons[:3]}')
|
| 497 |
+
|
| 498 |
+
ok = _hf_upload_files(
|
| 499 |
+
files_to_upload,
|
| 500 |
+
message=f'WARP anchors: {accepted} grid entries',
|
| 501 |
+
repo_id=HF_ICONS_REPO_ID,
|
| 502 |
+
)
|
| 503 |
+
if not ok:
|
| 504 |
+
raise HTTPException(503, 'Storage unavailable, please try later')
|
| 505 |
+
|
| 506 |
+
log.info(f'Anchors accepted: install={install_id[:8]} accepted={accepted} rejected={rejected}')
|
| 507 |
+
return {'ok': True, 'accepted': accepted, 'rejected': rejected}
|
| 508 |
+
|
| 509 |
+
|
| 510 |
@app.post('/webhooks/hf-dataset')
|
| 511 |
async def hf_dataset_webhook(request: Request):
|
| 512 |
"""
|
|
|
|
| 604 |
|
| 605 |
# ββ Validation helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 606 |
|
| 607 |
+
def _check_crop_dims(png_bytes: bytes, slot: str) -> str | None:
|
| 608 |
+
"""Validate crop image dimensions. Returns None if OK, else error message.
|
| 609 |
+
|
| 610 |
+
Text crops (ship_type_/ship_tier_) are wide horizontal bands and use
|
| 611 |
+
relaxed height/width minimums instead of the square icon minimum.
|
| 612 |
+
"""
|
| 613 |
+
try:
|
| 614 |
+
nparr = np.frombuffer(png_bytes, np.uint8)
|
| 615 |
+
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 616 |
+
if img is None:
|
| 617 |
+
return 'unreadable image'
|
| 618 |
+
h, w = img.shape[:2]
|
| 619 |
+
if any(slot.startswith(p) for p in _TEXT_CROP_PREFIXES):
|
| 620 |
+
if h < MIN_TEXT_CROP_H or w < MIN_TEXT_CROP_W:
|
| 621 |
+
return f'too small ({w}x{h})'
|
| 622 |
+
else:
|
| 623 |
+
if h < MIN_CROP_PX or w < MIN_CROP_PX:
|
| 624 |
+
return f'too small ({w}x{h})'
|
| 625 |
+
return None
|
| 626 |
+
except Exception as e:
|
| 627 |
+
return str(e)
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
def _merge_annotations_jsonl(install_id: str, new_entries: list[dict]) -> bytes:
|
| 631 |
+
"""Fetch existing staging annotations.jsonl, merge with last-wins per sha.
|
| 632 |
+
|
| 633 |
+
Mirrors warp/trainer/sync.py:_append_staging_annotations_to_ops so that
|
| 634 |
+
the central pipeline sees the same per-sha dedup behaviour regardless of
|
| 635 |
+
whether the client uploaded directly (legacy) or via this backend.
|
| 636 |
+
"""
|
| 637 |
+
existing_lines = _fetch_staging_annotations(install_id)
|
| 638 |
+
|
| 639 |
+
override_shas = {e.get('crop_sha256', '') for e in new_entries if e.get('crop_sha256')}
|
| 640 |
+
kept: list[str] = []
|
| 641 |
+
for line in existing_lines:
|
| 642 |
+
try:
|
| 643 |
+
sha = json.loads(line).get('crop_sha256', '')
|
| 644 |
+
except Exception:
|
| 645 |
+
kept.append(line)
|
| 646 |
+
continue
|
| 647 |
+
if sha and sha in override_shas:
|
| 648 |
+
continue
|
| 649 |
+
kept.append(line)
|
| 650 |
+
|
| 651 |
+
# Dedup within new_entries themselves (last wins) so a single batch
|
| 652 |
+
# with duplicate sha doesn't write two conflicting labels.
|
| 653 |
+
seen: dict[str, dict] = {}
|
| 654 |
+
for e in new_entries:
|
| 655 |
+
sha = e.get('crop_sha256', '')
|
| 656 |
+
if sha:
|
| 657 |
+
seen[sha] = e
|
| 658 |
+
|
| 659 |
+
combined = kept + [json.dumps(e, ensure_ascii=False) for e in seen.values()]
|
| 660 |
+
return '\n'.join(combined).encode('utf-8')
|
| 661 |
+
|
| 662 |
+
|
| 663 |
def is_valid_crop(png_bytes: bytes) -> bool:
|
| 664 |
"""Checks if crop is valid (not garbage/too uniform)."""
|
| 665 |
try:
|
|
|
|
| 675 |
|
| 676 |
# ββ HF Dataset helpers ββββββββββββββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββββββββββ
|
| 677 |
|
| 678 |
+
def _hf_upload_files(
|
| 679 |
+
files: dict[str, bytes],
|
| 680 |
+
message: str = 'WARP auto-upload',
|
| 681 |
+
repo_id: str | None = None,
|
| 682 |
+
) -> bool:
|
| 683 |
+
"""Upload multiple files to HF Dataset repo atomically.
|
| 684 |
+
|
| 685 |
+
`repo_id` defaults to HF_REPO_ID (knowledge dataset). Pass
|
| 686 |
+
HF_ICONS_REPO_ID for icon-dataset uploads (bulk crops, screen types,
|
| 687 |
+
anchor grids).
|
| 688 |
+
"""
|
| 689 |
+
target = repo_id or HF_REPO_ID
|
| 690 |
+
if not HF_TOKEN or not target:
|
| 691 |
+
log.error('HF_TOKEN or repo_id not set')
|
| 692 |
return False
|
| 693 |
try:
|
| 694 |
from huggingface_hub import HfApi, CommitOperationAdd
|
|
|
|
| 698 |
for path, content in files.items()
|
| 699 |
]
|
| 700 |
api.create_commit(
|
| 701 |
+
repo_id=target,
|
| 702 |
repo_type='dataset',
|
| 703 |
operations=operations,
|
| 704 |
commit_message=message,
|
| 705 |
)
|
| 706 |
return True
|
| 707 |
except Exception as e:
|
| 708 |
+
log.error(f'HF atomic upload failed (repo={target}): {e}')
|
| 709 |
return False
|
| 710 |
|
| 711 |
|
| 712 |
+
def _fetch_staging_annotations(install_id: str) -> list[str]:
|
| 713 |
+
"""Fetch existing staging/<install_id>/annotations.jsonl lines from HF.
|
| 714 |
+
|
| 715 |
+
Returns the raw JSON lines (already stripped of trailing whitespace),
|
| 716 |
+
or [] if the file doesn't exist yet or download fails.
|
| 717 |
+
"""
|
| 718 |
+
try:
|
| 719 |
+
from huggingface_hub import hf_hub_download
|
| 720 |
+
local = hf_hub_download(
|
| 721 |
+
repo_id=HF_ICONS_REPO_ID,
|
| 722 |
+
filename=f'staging/{install_id}/annotations.jsonl',
|
| 723 |
+
repo_type='dataset',
|
| 724 |
+
token=HF_TOKEN or None,
|
| 725 |
+
)
|
| 726 |
+
out: list[str] = []
|
| 727 |
+
with open(local, encoding='utf-8') as f:
|
| 728 |
+
for line in f:
|
| 729 |
+
line = line.strip()
|
| 730 |
+
if line:
|
| 731 |
+
out.append(line)
|
| 732 |
+
return out
|
| 733 |
+
except Exception:
|
| 734 |
+
return []
|
| 735 |
+
|
| 736 |
+
|
| 737 |
def _load_model_version_from_hf() -> dict:
|
| 738 |
"""Download models/model_version.json from HF."""
|
| 739 |
if not HF_REPO_ID:
|