File size: 6,313 Bytes
53f7d61
36f68cc
53f7d61
36f68cc
53f7d61
e40d130
 
 
 
53f7d61
 
 
36f68cc
e40d130
53f7d61
e40d130
 
36f68cc
53f7d61
a6f63e9
53f7d61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e40d130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53f7d61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36f68cc
 
53f7d61
 
 
 
 
 
a6f63e9
 
 
 
 
53f7d61
 
 
e40d130
a6f63e9
 
 
 
53f7d61
 
 
 
 
 
 
a6f63e9
 
 
 
 
53f7d61
 
 
 
 
e40d130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53f7d61
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
"""Hugging Face Space entry point for the trusted HTML shell."""

from __future__ import annotations

import base64
import binascii
import threading
import time
import warnings
from io import BytesIO
from pathlib import Path
from typing import Any, Callable

from fastapi import HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import JSONResponse
from PIL import Image, UnidentifiedImageError

from snap2sim.backend import InferenceClient, Settings
from snap2sim.schema import normalize_confidence_threshold

try:
    from gradio import Server
except ImportError:
    from fastapi import FastAPI
    import uvicorn

    class Server(FastAPI):  # type: ignore[no-redef]
        """Local compatibility shim for environments older than Gradio Server."""

        def api(self, name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
            def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
                return func

            return decorator

        def launch(self, **kwargs: Any) -> None:
            uvicorn.run(
                self,
                host=kwargs.get("server_name", "0.0.0.0"),
                port=kwargs.get("server_port", 7860),
            )


app = Server()
INDEX_PATH = Path(__file__).with_name("index.html")
MAX_IMAGE_BASE64_CHARS = 12 * 1024 * 1024
MAX_IMAGE_BYTES = 9 * 1024 * 1024
MAX_IMAGE_PIXELS = 12_000_000
RATE_LIMIT_WINDOW_SECONDS = 60
RATE_LIMIT_PER_CLIENT = 12
RATE_LIMIT_GLOBAL = 72
RATE_LIMIT_PATHS = {"/analyze_image", "/generate_scene"}

Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS

_rate_lock = threading.Lock()
_client_hits: dict[str, list[float]] = {}
_global_hits: list[float] = []


@app.middleware("http")
async def rate_limit_api(request: Request, call_next: Callable[..., Any]) -> Any:
    if request.method == "POST" and request.url.path in RATE_LIMIT_PATHS:
        allowed, retry_after = _record_request(_client_id(request))
        if not allowed:
            return JSONResponse(
                {"detail": f"Rate limit exceeded. Retry after {retry_after} seconds."},
                status_code=429,
                headers={"Retry-After": str(retry_after)},
            )
    return await call_next(request)


@app.get("/", response_class=HTMLResponse)
async def homepage() -> str:
    return INDEX_PATH.read_text(encoding="utf-8")


@app.get("/manifest.json")
async def manifest() -> dict[str, Any]:
    return {
        "name": "Snap2Sim Inside the Machine",
        "short_name": "Snap2Sim",
        "start_url": "/",
        "display": "standalone",
        "background_color": "#0F1318",
        "theme_color": "#E8A33D",
    }


@app.api(name="analyze_image")
def analyze_image_api(image_base64: str) -> dict[str, Any]:
    return _analyze_image(image_base64)


@app.post("/analyze_image")
def analyze_image_http(payload: dict[str, Any]) -> dict[str, Any]:
    return _analyze_image(str(payload.get("image_base64", "")))


@app.api(name="generate_scene")
def generate_scene_api(
    analysis: dict[str, Any],
    confidence_threshold: float | None = None,
) -> dict[str, Any]:
    return _generate_scene(analysis, confidence_threshold)


@app.post("/generate_scene")
def generate_scene_http(payload: dict[str, Any]) -> dict[str, Any]:
    return _generate_scene(
        payload.get("analysis") or {},
        payload.get("confidence_threshold"),
    )


def _analyze_image(image_base64: str) -> dict[str, Any]:
    image = _decode_image(image_base64) if image_base64 else None
    return InferenceClient(Settings()).analyze_image(image)


def _generate_scene(analysis: dict[str, Any], threshold: Any = None) -> dict[str, Any]:
    return InferenceClient(Settings()).generate_scene(
        analysis,
        normalize_confidence_threshold(threshold),
    )


def _decode_image(image_base64: str) -> Image.Image:
    if "," in image_base64 and image_base64.lstrip().startswith("data:"):
        image_base64 = image_base64.split(",", 1)[1]
    if len(image_base64) > MAX_IMAGE_BASE64_CHARS:
        raise HTTPException(status_code=413, detail="Image upload is too large.")
    try:
        raw = base64.b64decode(image_base64, validate=True)
    except (binascii.Error, ValueError) as exc:
        raise HTTPException(status_code=400, detail="Image payload is not valid base64.") from exc
    if len(raw) > MAX_IMAGE_BYTES:
        raise HTTPException(status_code=413, detail="Image upload is too large.")

    try:
        with warnings.catch_warnings():
            warnings.simplefilter("error", Image.DecompressionBombWarning)
            image = Image.open(BytesIO(raw))
            image.load()
    except Image.DecompressionBombWarning as exc:
        raise HTTPException(status_code=413, detail="Image dimensions are too large.") from exc
    except Image.DecompressionBombError as exc:
        raise HTTPException(status_code=413, detail="Image dimensions are too large.") from exc
    except (UnidentifiedImageError, OSError, ValueError) as exc:
        raise HTTPException(status_code=400, detail="Upload a valid image file.") from exc

    if image.width * image.height > MAX_IMAGE_PIXELS:
        raise HTTPException(status_code=413, detail="Image dimensions are too large.")
    return image.convert("RGB")


def _client_id(request: Request) -> str:
    forwarded_for = request.headers.get("x-forwarded-for", "")
    if forwarded_for:
        return forwarded_for.split(",", 1)[0].strip()
    return request.client.host if request.client else "unknown"


def _record_request(client_id: str) -> tuple[bool, int]:
    now = time.monotonic()
    cutoff = now - RATE_LIMIT_WINDOW_SECONDS
    with _rate_lock:
        _global_hits[:] = [hit for hit in _global_hits if hit >= cutoff]
        hits = [hit for hit in _client_hits.get(client_id, []) if hit >= cutoff]
        if len(hits) >= RATE_LIMIT_PER_CLIENT or len(_global_hits) >= RATE_LIMIT_GLOBAL:
            oldest = min(hits[0] if hits else now, _global_hits[0] if _global_hits else now)
            retry_after = max(1, int(RATE_LIMIT_WINDOW_SECONDS - (now - oldest)))
            _client_hits[client_id] = hits
            return False, retry_after
        hits.append(now)
        _global_hits.append(now)
        _client_hits[client_id] = hits
    return True, 0


if __name__ == "__main__":
    app.launch()