ChoruYt commited on
Commit
59afa9c
Β·
verified Β·
1 Parent(s): f147305

Update server.py

Browse files
Files changed (1) hide show
  1. server.py +106 -220
server.py CHANGED
@@ -3,6 +3,8 @@ import io
3
  import base64
4
  import ctypes
5
  import threading
 
 
6
  from flask import Flask, request, jsonify, Response
7
  from flask_cors import CORS
8
 
@@ -13,7 +15,6 @@ _SERVER_DIR = os.path.dirname(os.path.abspath(__file__))
13
  _DEFAULT_PATH = os.path.join(_SERVER_DIR, "models", "gemma", HF_FILE)
14
 
15
  # litert_lm links against libvulkan.so.1 even on CPU-only runs.
16
- # Pre-load a stub so the dynamic linker is satisfied without a real GPU.
17
  _vk_stub = os.path.join(_SERVER_DIR, "libvulkan.so.1")
18
  if os.path.exists(_vk_stub):
19
  try:
@@ -68,153 +69,78 @@ def load_model():
68
  model_status = "error"
69
 
70
 
71
- # ─── Image analysis via Pillow (no model) ──────────────────────────────────────
72
 
73
  def _analyze_image(image_bytes: bytes) -> dict:
74
  from PIL import Image
75
  img = Image.open(io.BytesIO(image_bytes))
76
  w, h = img.size
77
  fmt = (img.format or "image").lower()
78
-
79
- thumb = img.convert("RGB").resize((64, 64))
80
- pixels = list(thumb.getdata())
81
- n = len(pixels)
82
- r = sum(p[0] for p in pixels) // n
83
- g = sum(p[1] for p in pixels) // n
84
- b = sum(p[2] for p in pixels) // n
85
-
86
- lum = (r * 299 + g * 587 + b * 114) // 1000
87
- tone = "bright" if lum > 200 else "medium" if lum > 130 else "dark" if lum > 60 else "very dark"
88
-
89
- diff = max(r, g, b) - min(r, g, b)
90
- if diff < 25:
91
- hue = "neutral / grayscale"
92
- elif max(r, g, b) == r and r - g > 30:
93
- hue = "red / warm"
94
- elif max(r, g, b) == g and g - b > 20:
95
- hue = "green"
96
- elif max(r, g, b) == b:
97
- hue = "blue / cool"
98
- elif r > 180 and g > 150 and b < 100:
99
- hue = "yellow / orange"
100
- elif r > 150 and b > 150 and g < 100:
101
- hue = "purple / violet"
102
- else:
103
- hue = "mixed"
104
-
105
- ratio = w / h if h else 1
106
- orientation = "landscape" if ratio > 1.4 else "portrait" if ratio < 0.72 else "square"
107
-
108
- return dict(w=w, h=h, fmt=fmt, r=r, g=g, b=b, tone=tone, hue=hue, orientation=orientation)
109
-
110
 
111
  def _describe_image(ask: str, image_bytes: bytes) -> str:
112
  try:
113
- i = _analyze_image(image_bytes)
114
- q = ask.lower()
115
- out = [
116
- f"This is a {i['orientation']} {i['fmt']} image ({i['w']}Γ—{i['h']} px).",
117
- f"Overall tone: {i['tone']}. Dominant color: {i['hue']}. Average RGB: ({i['r']}, {i['g']}, {i['b']}).",
118
- ]
119
- if any(w in q for w in ["color", "colour", "kulay"]):
120
- out.append(f"Main color: {i['hue']} β€” RGB({i['r']}, {i['g']}, {i['b']}).")
121
- elif any(w in q for w in ["size", "dimension", "laki", "sukat"]):
122
- out.append(f"Dimensions: {i['w']}Γ—{i['h']} pixels.")
123
- elif any(w in q for w in ["bright", "dark", "liwanag"]):
124
- out.append(f"Brightness: {i['tone']}.")
125
-
126
- out.append(
127
- f"For full scene/object understanding, load the real model: "
128
- f"GET /gemma/download or set GEMMA_MODEL_PATH."
129
- )
130
- return "\n".join(out)
131
  except Exception as e:
132
  return f"Could not analyze image: {e}"
133
 
 
 
134
 
135
- # ─── Fallback text replies (no model) ─────────────────────────────────────────
136
-
137
- def _hint() -> str:
138
- if model_status == "no_litert_lm":
139
- return "litert_lm is not installed. Run: pip install litert-lm"
140
- if model_status in ("no_model_path", "model_file_missing"):
141
- return (f"Model not found at '{MODEL_PATH}'. "
142
- f"Run: GET /gemma/download to fetch it automatically.")
143
- return "Connect a Gemma 4 model to enable full responses."
144
 
145
- _REPLIES = [
146
- lambda h: f"Hello! I'm the Gemma 4 API. {h}",
147
- lambda h: f"Gemma 4 is Google's open multimodal model β€” text + image input, runs on-device via LiteRT. {h}",
148
- lambda h: f"Send images as base64 JSON (field 'image') or multipart/form-data file upload. {h}",
149
- lambda h: f"API: GET /gemma?ask=... or POST /gemma {{\"ask\":\"...\",\"image\":\"<base64>\"}}. {h}",
150
- ]
151
- _idx = 0
152
 
153
- def _text_reply(ask: str) -> str:
154
- global _idx
155
- h, q = _hint(), ask.lower()
156
- if any(w in q for w in ["hello", "hi", "hey", "kumusta"]):
157
- return _REPLIES[0](h)
158
- if any(w in q for w in ["gemma", "model", "what are you"]):
159
- return _REPLIES[1](h)
160
- if any(w in q for w in ["image", "photo", "picture", "larawan"]):
161
- return _REPLIES[2](h)
162
- if any(w in q for w in ["how", "api", "endpoint", "use", "query"]):
163
- return _REPLIES[3](h)
164
- r = _REPLIES[_idx % len(_REPLIES)](h)
165
- _idx += 1
166
- return r
167
-
168
-
169
- # ─── Real model inference (litert_lm) ─────────────────────────────────────────
170
-
171
- def _run_real_model(ask: str, image_bytes: bytes | None) -> str:
172
  import litert_lm
 
173
  with engine_lock:
174
- try:
175
- with engine.create_conversation() as conv:
176
- if image_bytes:
177
- msg = litert_lm.Contents.of(
178
- litert_lm.Content.ImageBytes(image_bytes),
179
- litert_lm.Content.Text(ask),
180
- )
181
- else:
182
- msg = ask
183
-
184
- out = []
185
- for chunk in conv.send_message_async(msg):
186
- for part in chunk.get("content", []):
187
- if part.get("type") == "text":
188
- out.append(part.get("text", ""))
189
- return "".join(out) or "(empty response)"
190
- except Exception as e:
191
- return f"Model error: {e}"
192
-
193
-
194
- def run_model(ask: str, image_bytes: bytes | None) -> str:
195
- if engine is not None and model_status == "ready":
196
- return _run_real_model(ask, image_bytes)
197
- if image_bytes:
198
- return _describe_image(ask, image_bytes)
199
- return _text_reply(ask)
200
 
201
 
202
  # ─── Request extraction ────────────────────────────────────────────────────────
203
 
204
- def extract_request() -> tuple[str, bytes | None]:
 
 
 
205
  if request.method == "GET":
206
- return request.args.get("ask", "").strip(), None
207
 
208
  ct = request.content_type or ""
 
 
209
  if "multipart/form-data" in ct:
210
  ask = request.form.get("ask", "").strip()
211
  f = request.files.get("image")
212
- return ask, (f.read() if f else None)
 
 
213
 
 
214
  data = request.get_json(silent=True) or {}
215
  ask = data.get("ask", "").strip()
216
  image_bytes = None
217
  raw = data.get("image", "")
 
 
 
 
218
  if raw:
219
  try:
220
  if "," in raw:
@@ -222,128 +148,89 @@ def extract_request() -> tuple[str, bytes | None]:
222
  image_bytes = base64.b64decode(raw)
223
  except Exception:
224
  pass
225
- return ask, image_bytes
 
226
 
227
 
228
  # ─── Routes ────────────────────────────────────────────────────────────────────
229
 
230
- @app.route("/favicon.ico")
231
- def favicon():
232
- return "", 204
233
-
234
-
235
  @app.route("/")
236
  def index():
237
  return jsonify({
238
- "service": "Gemma 4 API",
239
  "model_status": model_status,
240
- "model_path": MODEL_PATH or None,
241
- "endpoints": {
242
- "GET /gemma?ask=hello": "Text query",
243
- "POST /gemma {ask, image?}": "JSON body β€” text + optional base64 image",
244
- "POST /gemma multipart/form-data": "File upload β€” text + optional image file",
245
- "GET /gemma/download": "Download Gemma 4 model from HuggingFace",
246
- "GET /health": "Health check",
247
- },
 
 
 
 
 
 
 
 
 
 
 
248
  })
249
 
250
 
251
  @app.route("/health")
252
- @app.route("/api/health")
253
  def health():
254
- info = {"status": "ok", "model_status": model_status}
255
- if MODEL_PATH:
256
- info["model_path"] = MODEL_PATH
257
- return jsonify(info)
258
 
259
 
260
  @app.route("/gemma", methods=["GET", "POST"])
261
  def gemma():
262
- ask, image_bytes = extract_request()
 
263
  if not ask:
264
  return jsonify({"error": "Missing 'ask' parameter"}), 400
265
- response = run_model(ask, image_bytes)
266
- return jsonify({
267
- "ask": ask,
268
- "response": response,
269
- "has_image": image_bytes is not None,
270
- "model_status": model_status,
271
- })
272
-
273
-
274
- # ─── Download state ────────────────────────────────────────────────────────────
275
-
276
- _dl: dict = {"status": "idle", "path": None, "error": None, "bytes_done": 0}
277
- _dl_lock = threading.Lock()
278
-
279
 
280
- def _do_download(save_to: str):
281
- global _dl
282
- with _dl_lock:
283
- _dl.update(status="downloading", path=save_to, error=None, bytes_done=0)
284
- try:
285
- from huggingface_hub import hf_hub_download
286
- save_dir = os.path.dirname(os.path.abspath(save_to))
287
- os.makedirs(save_dir, exist_ok=True)
288
- result_path = hf_hub_download(
289
- repo_id = HF_REPO,
290
- filename = HF_FILE,
291
- local_dir = save_dir,
292
- )
293
- # hf_hub_download saves to local_dir/<filename>
294
- canonical = os.path.join(save_dir, HF_FILE)
295
- if result_path != save_to and os.path.exists(result_path):
296
- os.replace(result_path, save_to)
297
- with _dl_lock:
298
- _dl.update(status="done", path=save_to, bytes_done=os.path.getsize(save_to))
299
- print(f"[INFO] Model downloaded β†’ {save_to}", flush=True)
300
- except Exception as e:
301
- with _dl_lock:
302
- _dl.update(status="error", error=str(e))
303
- print(f"[ERROR] Download failed: {e}", flush=True)
304
-
305
-
306
- @app.route("/gemma/download", methods=["GET", "POST"])
307
- def download_model():
308
- """Start (or check) background download of the Gemma 4 model from HuggingFace."""
309
- save_to = request.args.get("path", "").strip() or _DEFAULT_PATH
310
-
311
- # Status check only (no action)
312
- if request.args.get("status"):
313
- with _dl_lock:
314
- info = dict(_dl)
315
- info["model_path"] = save_to
316
- return jsonify(info)
317
-
318
- # Already on disk
319
- if os.path.exists(save_to):
320
- size_mb = os.path.getsize(save_to) // (1024 * 1024)
321
- return jsonify({
322
- "status": "already_exists",
323
- "model_path": save_to,
324
- "size_mb": size_mb,
325
- "next_step": f"Set env var GEMMA_MODEL_PATH={save_to} and restart the server.",
326
- })
327
-
328
- with _dl_lock:
329
- current = _dl["status"]
330
-
331
- if current == "downloading":
332
- return jsonify({
333
- "status": "downloading",
334
- "message": "Download already in progress. Poll GET /gemma/download?status=1",
335
- })
336
-
337
- # Kick off background download
338
- threading.Thread(target=_do_download, args=(save_to,), daemon=True).start()
339
- return jsonify({
340
- "status": "started",
341
- "model": f"{HF_REPO}/{HF_FILE}",
342
- "saving_to": save_to,
343
- "size": "~2.5 GB β€” will take a few minutes",
344
- "poll": "GET /gemma/download?status=1",
345
- "next_step": f"When done, set GEMMA_MODEL_PATH={save_to} and restart the server.",
346
- })
347
 
348
 
349
  # ─── Entry ─────────────────────────────────────────────────────────────────────
@@ -352,5 +239,4 @@ if __name__ == "__main__":
352
  port = int(os.environ.get("PORT", 5173))
353
  threading.Thread(target=load_model, daemon=True).start()
354
  print(f"[INFO] Gemma API on :{port}", flush=True)
355
- print(f"[INFO] Model: {MODEL_PATH or '(not set β€” GET /gemma/download to fetch)'}", flush=True)
356
- app.run(host="0.0.0.0", port=port, debug=False)
 
3
  import base64
4
  import ctypes
5
  import threading
6
+ import json
7
+ import time
8
  from flask import Flask, request, jsonify, Response
9
  from flask_cors import CORS
10
 
 
15
  _DEFAULT_PATH = os.path.join(_SERVER_DIR, "models", "gemma", HF_FILE)
16
 
17
  # litert_lm links against libvulkan.so.1 even on CPU-only runs.
 
18
  _vk_stub = os.path.join(_SERVER_DIR, "libvulkan.so.1")
19
  if os.path.exists(_vk_stub):
20
  try:
 
69
  model_status = "error"
70
 
71
 
72
+ # ─── Fallback functions (No Model) ──────────────────────────────────────────────
73
 
74
  def _analyze_image(image_bytes: bytes) -> dict:
75
  from PIL import Image
76
  img = Image.open(io.BytesIO(image_bytes))
77
  w, h = img.size
78
  fmt = (img.format or "image").lower()
79
+ return dict(w=w, h=h, fmt=fmt)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
  def _describe_image(ask: str, image_bytes: bytes) -> str:
82
  try:
83
+ i = _analyze_image(image_bytes)
84
+ return f"[MOCK] This is a {i['fmt']} image ({i['w']}Γ—{i['h']} px). Connect the real model for full vision analysis."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  except Exception as e:
86
  return f"Could not analyze image: {e}"
87
 
88
+ def _text_reply(ask: str) -> str:
89
+ return f"[MOCK] Hello! I received: '{ask}'. Connect the Gemma model to see real answers."
90
 
 
 
 
 
 
 
 
 
 
91
 
92
+ # ─── Real model inference (litert_lm Generator) ────────────────────────────────
 
 
 
 
 
 
93
 
94
+ def _run_real_model_generator(ask: str, image_bytes: bytes | None):
95
+ """Yields text chunks as they are generated by the model."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  import litert_lm
97
+ # engine_lock ensures only 1 request processes at a time to prevent RAM crashes
98
  with engine_lock:
99
+ with engine.create_conversation() as conv:
100
+ if image_bytes:
101
+ msg = litert_lm.Contents.of(
102
+ litert_lm.Content.ImageBytes(image_bytes),
103
+ litert_lm.Content.Text(ask),
104
+ )
105
+ else:
106
+ msg = ask
107
+
108
+ for chunk in conv.send_message_async(msg):
109
+ for part in chunk.get("content", []):
110
+ if part.get("type") == "text":
111
+ text = part.get("text", "")
112
+ if text:
113
+ yield text
 
 
 
 
 
 
 
 
 
 
 
114
 
115
 
116
  # ─── Request extraction ────────────────────────────────────────────────────────
117
 
118
+ def extract_request() -> tuple[str, bytes | None, bool]:
119
+ # Check query params first (e.g. ?stream=true)
120
+ stream = str(request.args.get("stream", "")).lower() in ["true", "1", "yes"]
121
+
122
  if request.method == "GET":
123
+ return request.args.get("ask", "").strip(), None, stream
124
 
125
  ct = request.content_type or ""
126
+
127
+ # Handle Form Data (File Uploads)
128
  if "multipart/form-data" in ct:
129
  ask = request.form.get("ask", "").strip()
130
  f = request.files.get("image")
131
+ if "stream" in request.form:
132
+ stream = str(request.form.get("stream")).lower() in ["true", "1", "yes"]
133
+ return ask, (f.read() if f else None), stream
134
 
135
+ # Handle JSON
136
  data = request.get_json(silent=True) or {}
137
  ask = data.get("ask", "").strip()
138
  image_bytes = None
139
  raw = data.get("image", "")
140
+
141
+ if "stream" in data:
142
+ stream = str(data.get("stream")).lower() in ["true", "1", "yes"]
143
+
144
  if raw:
145
  try:
146
  if "," in raw:
 
148
  image_bytes = base64.b64decode(raw)
149
  except Exception:
150
  pass
151
+
152
+ return ask, image_bytes, stream
153
 
154
 
155
  # ─── Routes ────────────────────────────────────────────────────────────────────
156
 
 
 
 
 
 
157
  @app.route("/")
158
  def index():
159
  return jsonify({
160
+ "service": "Gemma 4 API",
161
  "model_status": model_status,
162
+ "guide": {
163
+ "1. Text Only Chat": {
164
+ "GET_Example": "/gemma?ask=Hello&stream=false",
165
+ "POST_JSON": {"ask": "What is AI?", "stream": True}
166
+ },
167
+ "2. Image with Text": {
168
+ "POST_JSON": {
169
+ "ask": "Describe this image",
170
+ "image": "<base64_string_here>",
171
+ "stream": False
172
+ },
173
+ "POST_FormData": {
174
+ "ask": "What color is this?",
175
+ "image": "@file.jpg (File Upload)",
176
+ "stream": "true"
177
+ }
178
+ },
179
+ "Streaming Info": "Set 'stream': true to receive Server-Sent Events (SSE) by token. Set false to wait for 1 full JSON response."
180
+ }
181
  })
182
 
183
 
184
  @app.route("/health")
 
185
  def health():
186
+ return jsonify({"status": "ok", "model_status": model_status})
 
 
 
187
 
188
 
189
  @app.route("/gemma", methods=["GET", "POST"])
190
  def gemma():
191
+ ask, image_bytes, stream = extract_request()
192
+
193
  if not ask:
194
  return jsonify({"error": "Missing 'ask' parameter"}), 400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
 
196
+ # Fallback if model isn't ready
197
+ if engine is None or model_status != "ready":
198
+ fallback_msg = _describe_image(ask, image_bytes) if image_bytes else _text_reply(ask)
199
+
200
+ if stream:
201
+ def mock_stream():
202
+ for word in fallback_msg.split():
203
+ yield f"data: {json.dumps({'text': word + ' '})}\n\n"
204
+ time.sleep(0.05)
205
+ yield "data: [DONE]\n\n"
206
+ return Response(mock_stream(), mimetype="text/event-stream")
207
+ else:
208
+ return jsonify({"ask": ask, "response": fallback_msg, "has_image": bool(image_bytes)})
209
+
210
+ # Real Model Logic
211
+ if stream:
212
+ def generate_stream():
213
+ try:
214
+ for text_chunk in _run_real_model_generator(ask, image_bytes):
215
+ # Standard SSE format for frontend parsing
216
+ yield f"data: {json.dumps({'text': text_chunk})}\n\n"
217
+ yield "data: [DONE]\n\n"
218
+ except Exception as e:
219
+ yield f"data: {json.dumps({'error': str(e)})}\n\n"
220
+
221
+ return Response(generate_stream(), mimetype="text/event-stream")
222
+ else:
223
+ # Wait for all chunks and join them into one string
224
+ try:
225
+ full_text = "".join(list(_run_real_model_generator(ask, image_bytes)))
226
+ return jsonify({
227
+ "ask": ask,
228
+ "response": full_text,
229
+ "has_image": image_bytes is not None,
230
+ "model_status": model_status
231
+ })
232
+ except Exception as e:
233
+ return jsonify({"error": f"Model error: {e}"}), 500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
 
235
 
236
  # ─── Entry ─────────────────────────────────────────────────────────────────────
 
239
  port = int(os.environ.get("PORT", 5173))
240
  threading.Thread(target=load_model, daemon=True).start()
241
  print(f"[INFO] Gemma API on :{port}", flush=True)
242
+ app.run(host="0.0.0.0", port=port, debug=False)