roop0726 commited on
Commit
1e02b48
·
verified ·
1 Parent(s): ae678eb

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +628 -215
app.py CHANGED
@@ -1,6 +1,7 @@
1
  import os
2
  import sys
3
  import time
 
4
  import base64
5
  import mimetypes
6
  import subprocess
@@ -12,63 +13,328 @@ import gradio as gr
12
 
13
 
14
  # ============================================================
15
- # CONFIG
16
  # ============================================================
17
 
18
- MODEL_PATH = "/app/models/Qwen2.5-VL-3B-Instruct.Q4_K_M.gguf"
19
- MMPROJ_PATH = "/app/models/mmproj-Qwen2.5-VL-3B-Instruct-Q8_0.gguf"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  SERVER_HOST = "127.0.0.1"
22
  SERVER_PORT = 8080
23
- SERVER_URL = f"http://{SERVER_HOST}:{SERVER_PORT}"
24
 
25
- MODEL_NAME = "Qwen2.5-VL-3B-Instruct"
 
 
26
 
27
- # Number of concurrent llama.cpp slots
28
- N_SLOTS = 4
29
-
30
- # Context per slot
31
  N_CTX = 4096
 
32
 
33
- # CPU threads
34
- CPU_THREADS = max(1, os.cpu_count() or 4)
 
 
35
 
36
- # Change to a smaller number if GPU memory is limited.
37
  N_GPU_LAYERS = 99
38
 
39
 
 
 
 
40
  # ============================================================
41
- # SERVER PROCESS
42
  # ============================================================
43
 
44
- server_process = None
45
 
 
 
 
 
46
 
47
- def start_llama_server():
48
- global server_process
49
 
50
- if not os.path.exists(MODEL_PATH):
51
- raise FileNotFoundError(
52
- f"Model not found:\n{MODEL_PATH}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  )
54
 
55
- if not os.path.exists(MMPROJ_PATH):
56
- raise FileNotFoundError(
57
- f"Multimodal projector not found:\n{MMPROJ_PATH}"
 
58
  )
59
 
60
- print("=" * 60)
61
- print("STARTING LLAMA.CPP MULTIMODAL SERVER")
62
- print("=" * 60)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  command = [
65
- "llama-server",
 
66
 
67
  "-m",
68
- MODEL_PATH,
69
 
70
  "--mmproj",
71
- MMPROJ_PATH,
72
 
73
  "--host",
74
  SERVER_HOST,
@@ -88,63 +354,108 @@ def start_llama_server():
88
  "-ngl",
89
  str(N_GPU_LAYERS),
90
 
91
- # Enable prompt caching
92
  "--cache-type-k",
93
  "q8_0",
94
 
95
  "--cache-type-v",
96
  "q8_0",
97
 
98
- # Faster startup / processing
99
  "--no-webui",
100
-
101
- # Disable thinking when supported by template.
102
- "--reasoning-format",
103
- "none",
104
  ]
105
 
106
- print("Command:")
107
- print(" ".join(command))
108
  print()
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
  server_process = subprocess.Popen(
 
111
  command,
 
112
  stdout=subprocess.PIPE,
 
113
  stderr=subprocess.STDOUT,
 
114
  text=True,
 
115
  bufsize=1,
116
  )
117
 
118
- def read_output():
119
- for line in iter(server_process.stdout.readline, ""):
120
- print("[llama.cpp]", line.rstrip())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
  threading.Thread(
123
- target=read_output,
124
- daemon=True
125
  ).start()
126
 
127
  wait_for_server()
128
 
129
 
130
- def wait_for_server(timeout=180):
131
- print("Waiting for llama.cpp server...")
 
 
 
 
 
 
 
 
 
 
132
 
133
  start = time.time()
134
 
135
- while time.time() - start < timeout:
 
 
 
 
136
  try:
137
- r = requests.get(
 
138
  f"{SERVER_URL}/health",
139
  timeout=2,
140
  )
141
 
142
- if r.status_code == 200:
 
143
  print()
144
- print("=" * 60)
145
  print("LLAMA.CPP SERVER READY")
146
- print("=" * 60)
147
  print()
 
148
  return
149
 
150
  except Exception:
@@ -153,77 +464,139 @@ def wait_for_server(timeout=180):
153
  time.sleep(0.5)
154
 
155
  raise RuntimeError(
156
- "llama.cpp server did not become ready."
 
157
  )
158
 
159
 
160
  # ============================================================
161
- # IMAGE → BASE64
162
  # ============================================================
163
 
164
- def image_to_data_url(image_path):
 
 
 
165
  if not image_path:
166
- return None
 
 
167
 
168
- path = Path(image_path)
 
 
169
 
170
  if not path.exists():
 
171
  raise FileNotFoundError(
172
  f"Image not found: {image_path}"
173
  )
174
 
175
- mime_type, _ = mimetypes.guess_type(str(path))
 
 
 
 
 
 
176
 
177
- if not mime_type:
178
  mime_type = "image/jpeg"
179
 
180
- with open(path, "rb") as f:
 
 
 
 
181
  encoded = base64.b64encode(
182
  f.read()
183
- ).decode("utf-8")
 
 
184
 
185
- return f"data:{mime_type};base64,{encoded}"
 
 
186
 
187
 
188
  # ============================================================
189
- # QWEN VISION REQUEST
190
  # ============================================================
191
 
192
- def ask_qwen(image_path, instruction, max_tokens):
 
 
 
 
 
193
  if not image_path:
194
- raise gr.Error("Please upload an image.")
195
 
196
- if not instruction or not instruction.strip():
 
 
 
 
 
 
 
 
197
  instruction = (
198
  "Read all visible text in this image. "
199
- "Read it in natural reading order, "
200
- "from top to bottom and left to right. "
201
  "Return only the text."
202
  )
203
 
204
- image_data_url = image_to_data_url(image_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
 
206
  messages = [
 
207
  {
208
  "role": "system",
209
- "content": (
210
- "You are an image reading and OCR assistant. "
211
- "Follow the user's instruction exactly. "
212
- "When extracting text, preserve the natural "
213
- "reading order from top to bottom and left to right."
214
- ),
 
 
 
 
215
  },
 
216
  {
217
  "role": "user",
 
218
  "content": [
 
219
  {
220
  "type": "image_url",
 
221
  "image_url": {
222
- "url": image_data_url
 
223
  },
224
  },
 
225
  {
226
  "type": "text",
 
227
  "text": instruction,
228
  },
229
  ],
@@ -231,115 +604,181 @@ def ask_qwen(image_path, instruction, max_tokens):
231
  ]
232
 
233
  payload = {
234
- "model": MODEL_NAME,
235
- "messages": messages,
236
 
237
- "temperature": 0.0,
 
 
 
 
238
 
239
- "max_tokens": int(max_tokens),
 
240
 
241
- "stream": True,
 
242
 
243
- # llama.cpp options
244
- "cache_prompt": True,
245
 
246
- "reasoning_format": "none",
 
 
 
 
247
  }
248
 
249
  print()
250
- print("=" * 60)
251
- print("VISION REQUEST")
252
- print("=" * 60)
253
- print("Instruction:", instruction)
254
- print()
 
 
 
 
 
255
 
256
  try:
 
257
  response = requests.post(
 
258
  f"{SERVER_URL}/v1/chat/completions",
 
259
  json=payload,
 
260
  stream=True,
 
261
  timeout=600,
262
  )
263
 
264
- if response.status_code != 200:
265
- error_text = response.text
 
 
 
 
 
266
 
267
- raise RuntimeError(
268
- f"llama.cpp returned HTTP "
269
- f"{response.status_code}:\n{error_text}"
270
  )
 
 
271
 
272
- output = ""
 
 
 
 
 
 
 
 
273
 
274
- for line in response.iter_lines(
275
  decode_unicode=True
276
  ):
 
 
 
 
 
 
277
  if not line:
278
  continue
279
 
280
- if line.startswith("data:"):
281
- data = line[5:].strip()
 
282
 
283
- if data == "[DONE]":
284
- break
 
285
 
286
- try:
287
- import json
288
 
289
- obj = json.loads(data)
290
 
291
- choices = obj.get(
292
- "choices",
293
- []
294
- )
295
 
296
- if not choices:
297
- continue
298
 
299
- delta = choices[0].get(
300
- "delta",
301
- {}
302
- )
303
 
304
- token = delta.get(
305
- "content",
306
- ""
307
- )
 
 
 
 
308
 
309
- if token:
310
- output += token
311
- yield output
 
312
 
313
- except Exception:
314
- continue
 
 
 
 
 
 
 
315
 
316
- if not output:
317
- yield "No response was generated."
 
 
 
 
 
318
 
319
- except requests.exceptions.RequestException as e:
320
  raise gr.Error(
321
- f"Connection to llama.cpp failed:\n{e}"
322
  )
323
 
324
- except Exception as e:
325
- raise gr.Error(str(e))
 
 
 
 
326
 
327
 
328
  # ============================================================
329
- # SIMPLE IMAGE OCR
330
  # ============================================================
331
 
332
  DEFAULT_INSTRUCTION = """Read all text visible in this image.
333
 
334
- Reading order:
335
- 1. Top to bottom.
336
- 2. Within the same line, left to right.
337
- 3. Preserve paragraphs and line breaks where possible.
338
- 4. Do not describe the image.
339
- 5. Return only the text you can read."""
 
 
 
 
 
 
340
 
 
 
 
 
 
341
 
342
- def run_vision(image, instruction, max_tokens):
343
  yield from ask_qwen(
344
  image,
345
  instruction,
@@ -352,11 +791,16 @@ def run_vision(image, instruction, max_tokens):
352
  # ============================================================
353
 
354
  def clear_all():
355
- return None, DEFAULT_INSTRUCTION, ""
 
 
 
 
 
356
 
357
 
358
  # ============================================================
359
- # UI
360
  # ============================================================
361
 
362
  def create_ui():
@@ -369,22 +813,29 @@ def create_ui():
369
  """
370
  # Qwen2.5-VL 3B Image Reader
371
 
372
- Upload an image and enter an instruction.
373
 
374
- The image and text instruction are sent directly to the
375
- local **llama.cpp Qwen2.5-VL** server.
376
  """
377
  )
378
 
379
  with gr.Row():
380
 
 
 
 
 
381
  with gr.Column(
382
  scale=1
383
  ):
384
 
385
  image = gr.Image(
386
- label="Upload Image",
 
 
387
  type="filepath",
 
388
  sources=[
389
  "upload",
390
  "clipboard",
@@ -392,27 +843,43 @@ local **llama.cpp Qwen2.5-VL** server.
392
  )
393
 
394
  instruction = gr.Textbox(
 
395
  label="Instruction",
396
- value=DEFAULT_INSTRUCTION,
397
- lines=8,
 
 
 
 
 
398
  placeholder=(
399
- "Example: Read all text from "
 
400
  "top to bottom."
401
  ),
402
  )
403
 
404
  max_tokens = gr.Slider(
 
405
  minimum=64,
 
406
  maximum=4096,
 
407
  value=1024,
 
408
  step=64,
409
- label="Maximum Output Tokens",
 
 
 
410
  )
411
 
412
  with gr.Row():
413
 
414
  run_button = gr.Button(
 
415
  "Read Image",
 
416
  variant="primary",
417
  )
418
 
@@ -420,30 +887,48 @@ local **llama.cpp Qwen2.5-VL** server.
420
  "Clear"
421
  )
422
 
 
 
 
 
423
  with gr.Column(
424
  scale=1
425
  ):
426
 
427
  output = gr.Textbox(
 
428
  label="Result",
 
429
  lines=25,
 
430
  show_copy_button=True,
 
431
  autoscroll=True,
432
  )
433
 
 
 
 
 
434
  run_button.click(
 
435
  fn=run_vision,
 
436
  inputs=[
437
  image,
438
  instruction,
439
  max_tokens,
440
  ],
 
441
  outputs=output,
442
  )
443
 
444
  clear_button.click(
 
445
  fn=clear_all,
 
446
  inputs=[],
 
447
  outputs=[
448
  image,
449
  instruction,
@@ -451,76 +936,4 @@ local **llama.cpp Qwen2.5-VL** server.
451
  ],
452
  )
453
 
454
- gr.Markdown(
455
- """
456
- ### Example instructions
457
-
458
- **OCR**
459
- > Read all text in this image from top to bottom and left to right. Return only the text.
460
-
461
- **Specific region**
462
- > Read the text in the upper half of the image.
463
-
464
- **Form**
465
- > Extract the form fields and their values in reading order.
466
-
467
- **Question**
468
- > What is the name shown in the image?
469
- """
470
- )
471
-
472
- return demo
473
-
474
-
475
- # ============================================================
476
- # MAIN
477
- # ============================================================
478
-
479
- if __name__ == "__main__":
480
-
481
- print()
482
- print("=" * 60)
483
- print("QWEN2.5-VL 3B + LLAMA.CPP")
484
- print("=" * 60)
485
- print("Model:", MODEL_PATH)
486
- print("MMProj:", MMPROJ_PATH)
487
- print("Context:", N_CTX)
488
- print("Slots:", N_SLOTS)
489
- print("CPU threads:", CPU_THREADS)
490
- print("=" * 60)
491
- print()
492
-
493
- try:
494
- start_llama_server()
495
-
496
- demo = create_ui()
497
-
498
- demo.queue(
499
- max_size=20,
500
- default_concurrency_limit=N_SLOTS,
501
- )
502
-
503
- demo.launch(
504
- server_name="0.0.0.0",
505
- server_port=7860,
506
- show_error=True,
507
- )
508
-
509
- except KeyboardInterrupt:
510
- print("Stopping...")
511
-
512
- except Exception as e:
513
- print()
514
- print("=" * 60)
515
- print("STARTUP FAILED")
516
- print("=" * 60)
517
- print(repr(e))
518
- print()
519
-
520
- if server_process is not None:
521
- try:
522
- server_process.terminate()
523
- except Exception:
524
- pass
525
-
526
- raise
 
1
  import os
2
  import sys
3
  import time
4
+ import json
5
  import base64
6
  import mimetypes
7
  import subprocess
 
13
 
14
 
15
  # ============================================================
16
+ # CONFIGURATION
17
  # ============================================================
18
 
19
+ MODEL_DIR = "/app/models"
20
+
21
+ HF_REPO = "ggml-org/Qwen2.5-VL-3B-Instruct-GGUF"
22
+
23
+ # Main language model
24
+ MODEL_FILE = "Qwen2.5-VL-3B-Instruct-Q4_K_M.gguf"
25
+
26
+ # Multimodal projector
27
+ MMPROJ_FILE = "mmproj-Qwen2.5-VL-3B-Instruct-Q8_0.gguf"
28
+
29
+ MODEL_PATH = os.path.join(
30
+ MODEL_DIR,
31
+ MODEL_FILE
32
+ )
33
+
34
+ MMPROJ_PATH = os.path.join(
35
+ MODEL_DIR,
36
+ MMPROJ_FILE
37
+ )
38
 
39
  SERVER_HOST = "127.0.0.1"
40
  SERVER_PORT = 8080
 
41
 
42
+ SERVER_URL = (
43
+ f"http://{SERVER_HOST}:{SERVER_PORT}"
44
+ )
45
 
 
 
 
 
46
  N_CTX = 4096
47
+ N_SLOTS = 2
48
 
49
+ CPU_THREADS = max(
50
+ 1,
51
+ os.cpu_count() or 4
52
+ )
53
 
54
+ # GPU offload
55
  N_GPU_LAYERS = 99
56
 
57
 
58
+ server_process = None
59
+
60
+
61
  # ============================================================
62
+ # PRINT CONFIG
63
  # ============================================================
64
 
65
+ def print_config():
66
 
67
+ print()
68
+ print("=" * 70)
69
+ print("QWEN2.5-VL-3B IMAGE READER")
70
+ print("=" * 70)
71
 
72
+ print("HF repository:")
73
+ print(HF_REPO)
74
 
75
+ print()
76
+ print("Model:")
77
+ print(MODEL_PATH)
78
+
79
+ print()
80
+ print("MMProj:")
81
+ print(MMPROJ_PATH)
82
+
83
+ print()
84
+ print("Context:", N_CTX)
85
+ print("Slots:", N_SLOTS)
86
+ print("CPU threads:", CPU_THREADS)
87
+ print("GPU layers:", N_GPU_LAYERS)
88
+
89
+ print("=" * 70)
90
+ print()
91
+
92
+
93
+ # ============================================================
94
+ # CHECK LLAMA SERVER
95
+ # ============================================================
96
+
97
+ def find_llama_server():
98
+
99
+ possible = [
100
+ "llama-server",
101
+ "/usr/local/bin/llama-server",
102
+ "/usr/bin/llama-server",
103
+ "/app/llama-server",
104
+ ]
105
+
106
+ for path in possible:
107
+
108
+ if path == "llama-server":
109
+ import shutil
110
+
111
+ found = shutil.which(
112
+ "llama-server"
113
+ )
114
+
115
+ if found:
116
+ return found
117
+
118
+ elif os.path.isfile(path):
119
+ return path
120
+
121
+ raise FileNotFoundError(
122
+ "llama-server executable was not found."
123
+ )
124
+
125
+
126
+ # ============================================================
127
+ # DOWNLOAD MODEL
128
+ # ============================================================
129
+
130
+ def download_file_from_hf(filename):
131
+
132
+ os.makedirs(
133
+ MODEL_DIR,
134
+ exist_ok=True
135
+ )
136
+
137
+ destination = os.path.join(
138
+ MODEL_DIR,
139
+ filename
140
+ )
141
+
142
+ if os.path.exists(destination):
143
+
144
+ size = os.path.getsize(
145
+ destination
146
  )
147
 
148
+ print(
149
+ f"[MODEL] Already exists: "
150
+ f"{destination} "
151
+ f"({size / 1024 / 1024:.1f} MB)"
152
  )
153
 
154
+ return destination
155
+
156
+ url = (
157
+ f"https://huggingface.co/"
158
+ f"{HF_REPO}/resolve/main/"
159
+ f"{filename}?download=true"
160
+ )
161
+
162
+ print()
163
+ print("=" * 70)
164
+ print("DOWNLOADING MODEL")
165
+ print("=" * 70)
166
+
167
+ print("URL:")
168
+ print(url)
169
+
170
+ print()
171
+ print("Destination:")
172
+ print(destination)
173
+
174
+ print()
175
+ print("This may take several minutes.")
176
+ print("=" * 70)
177
+
178
+ temporary = destination + ".part"
179
+
180
+ try:
181
+
182
+ with requests.get(
183
+ url,
184
+ stream=True,
185
+ timeout=60,
186
+ ) as response:
187
+
188
+ response.raise_for_status()
189
+
190
+ total = int(
191
+ response.headers.get(
192
+ "content-length",
193
+ 0
194
+ )
195
+ )
196
+
197
+ downloaded = 0
198
+ last_print = time.time()
199
+
200
+ with open(
201
+ temporary,
202
+ "wb"
203
+ ) as f:
204
+
205
+ for chunk in response.iter_content(
206
+ chunk_size=1024 * 1024
207
+ ):
208
+
209
+ if not chunk:
210
+ continue
211
+
212
+ f.write(chunk)
213
+
214
+ downloaded += len(chunk)
215
+
216
+ now = time.time()
217
+
218
+ if (
219
+ now - last_print >= 2
220
+ or (
221
+ total
222
+ and downloaded >= total
223
+ )
224
+ ):
225
+
226
+ if total:
227
+
228
+ percent = (
229
+ downloaded
230
+ / total
231
+ * 100
232
+ )
233
+
234
+ print(
235
+ f"[DOWNLOAD] "
236
+ f"{percent:.1f}% "
237
+ f""
238
+ f"{downloaded / 1024 / 1024:.0f} MB"
239
+ f" / "
240
+ f"{total / 1024 / 1024:.0f} MB"
241
+ )
242
+
243
+ else:
244
+
245
+ print(
246
+ f"[DOWNLOAD] "
247
+ f"{downloaded / 1024 / 1024:.0f} MB"
248
+ )
249
+
250
+ last_print = now
251
+
252
+ os.replace(
253
+ temporary,
254
+ destination
255
+ )
256
+
257
+ print()
258
+ print(
259
+ "[MODEL] Download complete:"
260
+ )
261
+ print(destination)
262
+
263
+ return destination
264
+
265
+ except Exception:
266
+
267
+ if os.path.exists(
268
+ temporary
269
+ ):
270
+
271
+ try:
272
+ os.remove(
273
+ temporary
274
+ )
275
+ except Exception:
276
+ pass
277
+
278
+ raise
279
+
280
+
281
+ # ============================================================
282
+ # PREPARE MODELS
283
+ # ============================================================
284
+
285
+ def prepare_models():
286
+
287
+ print()
288
+ print("=" * 70)
289
+ print("PREPARING QWEN2.5-VL MODELS")
290
+ print("=" * 70)
291
+
292
+ model = download_file_from_hf(
293
+ MODEL_FILE
294
+ )
295
+
296
+ mmproj = download_file_from_hf(
297
+ MMPROJ_FILE
298
+ )
299
+
300
+ print()
301
+ print("MODEL READY")
302
+ print()
303
+ print("LLM:")
304
+ print(model)
305
+
306
+ print()
307
+ print("MMPROJ:")
308
+ print(mmproj)
309
+
310
+ print("=" * 70)
311
+
312
+ return model, mmproj
313
+
314
+
315
+ # ============================================================
316
+ # START LLAMA.CPP
317
+ # ============================================================
318
+
319
+ def start_llama_server():
320
+
321
+ global server_process
322
+
323
+ model_path, mmproj_path = (
324
+ prepare_models()
325
+ )
326
+
327
+ llama_server = find_llama_server()
328
 
329
  command = [
330
+
331
+ llama_server,
332
 
333
  "-m",
334
+ model_path,
335
 
336
  "--mmproj",
337
+ mmproj_path,
338
 
339
  "--host",
340
  SERVER_HOST,
 
354
  "-ngl",
355
  str(N_GPU_LAYERS),
356
 
357
+ # KV cache quantization
358
  "--cache-type-k",
359
  "q8_0",
360
 
361
  "--cache-type-v",
362
  "q8_0",
363
 
364
+ # Do not start llama.cpp's own web UI
365
  "--no-webui",
 
 
 
 
366
  ]
367
 
 
 
368
  print()
369
+ print("=" * 70)
370
+ print("STARTING LLAMA.CPP")
371
+ print("=" * 70)
372
+
373
+ print()
374
+
375
+ print(
376
+ " ".join(command)
377
+ )
378
+
379
+ print()
380
+ print("=" * 70)
381
 
382
  server_process = subprocess.Popen(
383
+
384
  command,
385
+
386
  stdout=subprocess.PIPE,
387
+
388
  stderr=subprocess.STDOUT,
389
+
390
  text=True,
391
+
392
  bufsize=1,
393
  )
394
 
395
+ def read_server_output():
396
+
397
+ try:
398
+
399
+ for line in iter(
400
+ server_process.stdout.readline,
401
+ ""
402
+ ):
403
+
404
+ print(
405
+ "[llama.cpp]",
406
+ line.rstrip()
407
+ )
408
+
409
+ except Exception as e:
410
+
411
+ print(
412
+ "[llama.cpp output error]",
413
+ e
414
+ )
415
 
416
  threading.Thread(
417
+ target=read_server_output,
418
+ daemon=True,
419
  ).start()
420
 
421
  wait_for_server()
422
 
423
 
424
+ # ============================================================
425
+ # WAIT FOR SERVER
426
+ # ============================================================
427
+
428
+ def wait_for_server(
429
+ timeout=180
430
+ ):
431
+
432
+ print()
433
+ print(
434
+ "[SYSTEM] Waiting for llama.cpp..."
435
+ )
436
 
437
  start = time.time()
438
 
439
+ while (
440
+ time.time() - start
441
+ < timeout
442
+ ):
443
+
444
  try:
445
+
446
+ response = requests.get(
447
  f"{SERVER_URL}/health",
448
  timeout=2,
449
  )
450
 
451
+ if response.status_code == 200:
452
+
453
  print()
454
+ print("=" * 70)
455
  print("LLAMA.CPP SERVER READY")
456
+ print("=" * 70)
457
  print()
458
+
459
  return
460
 
461
  except Exception:
 
464
  time.sleep(0.5)
465
 
466
  raise RuntimeError(
467
+ "llama.cpp did not become ready "
468
+ f"within {timeout} seconds."
469
  )
470
 
471
 
472
  # ============================================================
473
+ # IMAGE → DATA URL
474
  # ============================================================
475
 
476
+ def image_to_data_url(
477
+ image_path
478
+ ):
479
+
480
  if not image_path:
481
+ raise ValueError(
482
+ "No image was provided."
483
+ )
484
 
485
+ path = Path(
486
+ image_path
487
+ )
488
 
489
  if not path.exists():
490
+
491
  raise FileNotFoundError(
492
  f"Image not found: {image_path}"
493
  )
494
 
495
+ mime_type, _ = (
496
+ mimetypes.guess_type(
497
+ str(path)
498
+ )
499
+ )
500
+
501
+ if mime_type is None:
502
 
 
503
  mime_type = "image/jpeg"
504
 
505
+ with open(
506
+ path,
507
+ "rb"
508
+ ) as f:
509
+
510
  encoded = base64.b64encode(
511
  f.read()
512
+ ).decode(
513
+ "utf-8"
514
+ )
515
 
516
+ return (
517
+ f"data:{mime_type};base64,{encoded}"
518
+ )
519
 
520
 
521
  # ============================================================
522
+ # SEND IMAGE + TEXT TO LLAMA.CPP
523
  # ============================================================
524
 
525
+ def ask_qwen(
526
+ image_path,
527
+ instruction,
528
+ max_tokens,
529
+ ):
530
+
531
  if not image_path:
 
532
 
533
+ raise gr.Error(
534
+ "Please upload an image."
535
+ )
536
+
537
+ if (
538
+ instruction is None
539
+ or not instruction.strip()
540
+ ):
541
+
542
  instruction = (
543
  "Read all visible text in this image. "
544
+ "Read it from top to bottom and left to right. "
545
+ "Preserve the natural reading order. "
546
  "Return only the text."
547
  )
548
 
549
+ try:
550
+
551
+ image_url = (
552
+ image_to_data_url(
553
+ image_path
554
+ )
555
+ )
556
+
557
+ except Exception as e:
558
+
559
+ raise gr.Error(
560
+ str(e)
561
+ )
562
+
563
+ # ========================================================
564
+ # MULTIMODAL MESSAGE
565
+ # ========================================================
566
 
567
  messages = [
568
+
569
  {
570
  "role": "system",
571
+
572
+ "content":
573
+ (
574
+ "You are a fast image reading "
575
+ "and OCR assistant. "
576
+ "Follow the user's instruction exactly. "
577
+ "When reading text, use natural "
578
+ "reading order from top to bottom "
579
+ "and left to right."
580
+ ),
581
  },
582
+
583
  {
584
  "role": "user",
585
+
586
  "content": [
587
+
588
  {
589
  "type": "image_url",
590
+
591
  "image_url": {
592
+
593
+ "url": image_url
594
  },
595
  },
596
+
597
  {
598
  "type": "text",
599
+
600
  "text": instruction,
601
  },
602
  ],
 
604
  ]
605
 
606
  payload = {
 
 
607
 
608
+ "model":
609
+ "Qwen2.5-VL-3B-Instruct",
610
+
611
+ "messages":
612
+ messages,
613
 
614
+ "temperature":
615
+ 0.0,
616
 
617
+ "top_p":
618
+ 1.0,
619
 
620
+ "max_tokens":
621
+ int(max_tokens),
622
 
623
+ "stream":
624
+ True,
625
+
626
+ "cache_prompt":
627
+ True,
628
  }
629
 
630
  print()
631
+ print("=" * 70)
632
+ print("NEW VISION REQUEST")
633
+ print("=" * 70)
634
+
635
+ print(
636
+ "Instruction:",
637
+ instruction
638
+ )
639
+
640
+ print("=" * 70)
641
 
642
  try:
643
+
644
  response = requests.post(
645
+
646
  f"{SERVER_URL}/v1/chat/completions",
647
+
648
  json=payload,
649
+
650
  stream=True,
651
+
652
  timeout=600,
653
  )
654
 
655
+ except Exception as e:
656
+
657
+ raise gr.Error(
658
+ f"Could not connect to llama.cpp:\n{e}"
659
+ )
660
+
661
+ if response.status_code != 200:
662
 
663
+ try:
664
+ error_body = (
665
+ response.text
666
  )
667
+ except Exception:
668
+ error_body = "Unknown error"
669
 
670
+ raise gr.Error(
671
+ "llama.cpp error "
672
+ f"HTTP {response.status_code}:\n"
673
+ f"{error_body}"
674
+ )
675
+
676
+ output = ""
677
+
678
+ try:
679
 
680
+ for raw_line in response.iter_lines(
681
  decode_unicode=True
682
  ):
683
+
684
+ if not raw_line:
685
+ continue
686
+
687
+ line = raw_line.strip()
688
+
689
  if not line:
690
  continue
691
 
692
+ if line.startswith(
693
+ "data:"
694
+ ):
695
 
696
+ data = line[
697
+ len("data:"):
698
+ ].strip()
699
 
700
+ else:
 
701
 
702
+ data = line
703
 
704
+ if data == "[DONE]":
705
+ break
 
 
706
 
707
+ try:
 
708
 
709
+ obj = json.loads(
710
+ data
711
+ )
 
712
 
713
+ except json.JSONDecodeError:
714
+
715
+ continue
716
+
717
+ choices = obj.get(
718
+ "choices",
719
+ []
720
+ )
721
 
722
+ if not choices:
723
+ continue
724
+
725
+ choice = choices[0]
726
 
727
+ delta = choice.get(
728
+ "delta",
729
+ {}
730
+ )
731
+
732
+ token = delta.get(
733
+ "content",
734
+ ""
735
+ )
736
 
737
+ if token:
738
+
739
+ output += token
740
+
741
+ yield output
742
+
743
+ except Exception as e:
744
 
 
745
  raise gr.Error(
746
+ f"Streaming error:\n{e}"
747
  )
748
 
749
+ if not output:
750
+
751
+ yield (
752
+ "No text was returned by "
753
+ "the vision model."
754
+ )
755
 
756
 
757
  # ============================================================
758
+ # DEFAULT OCR PROMPT
759
  # ============================================================
760
 
761
  DEFAULT_INSTRUCTION = """Read all text visible in this image.
762
 
763
+ Read the text in natural reading sequence:
764
+ 1. From top to bottom.
765
+ 2. Within each line, from left to right.
766
+ 3. Follow columns in their natural order.
767
+ 4. Preserve paragraphs and line breaks where possible.
768
+ 5. Do not describe the image.
769
+ 6. Return only the text you can read."""
770
+
771
+
772
+ # ============================================================
773
+ # RUN
774
+ # ============================================================
775
 
776
+ def run_vision(
777
+ image,
778
+ instruction,
779
+ max_tokens,
780
+ ):
781
 
 
782
  yield from ask_qwen(
783
  image,
784
  instruction,
 
791
  # ============================================================
792
 
793
  def clear_all():
794
+
795
+ return (
796
+ None,
797
+ DEFAULT_INSTRUCTION,
798
+ "",
799
+ )
800
 
801
 
802
  # ============================================================
803
+ # GRADIO UI
804
  # ============================================================
805
 
806
  def create_ui():
 
813
  """
814
  # Qwen2.5-VL 3B Image Reader
815
 
816
+ Upload an image and give the model an instruction.
817
 
818
+ The image and instruction are sent to the local
819
+ **llama.cpp + Qwen2.5-VL-3B** server.
820
  """
821
  )
822
 
823
  with gr.Row():
824
 
825
+ # =================================================
826
+ # LEFT
827
+ # =================================================
828
+
829
  with gr.Column(
830
  scale=1
831
  ):
832
 
833
  image = gr.Image(
834
+
835
+ label="Image",
836
+
837
  type="filepath",
838
+
839
  sources=[
840
  "upload",
841
  "clipboard",
 
843
  )
844
 
845
  instruction = gr.Textbox(
846
+
847
  label="Instruction",
848
+
849
+ value=(
850
+ DEFAULT_INSTRUCTION
851
+ ),
852
+
853
+ lines=9,
854
+
855
  placeholder=(
856
+ "Example: "
857
+ "Read all text from "
858
  "top to bottom."
859
  ),
860
  )
861
 
862
  max_tokens = gr.Slider(
863
+
864
  minimum=64,
865
+
866
  maximum=4096,
867
+
868
  value=1024,
869
+
870
  step=64,
871
+
872
+ label=(
873
+ "Maximum output tokens"
874
+ ),
875
  )
876
 
877
  with gr.Row():
878
 
879
  run_button = gr.Button(
880
+
881
  "Read Image",
882
+
883
  variant="primary",
884
  )
885
 
 
887
  "Clear"
888
  )
889
 
890
+ # =================================================
891
+ # RIGHT
892
+ # =================================================
893
+
894
  with gr.Column(
895
  scale=1
896
  ):
897
 
898
  output = gr.Textbox(
899
+
900
  label="Result",
901
+
902
  lines=25,
903
+
904
  show_copy_button=True,
905
+
906
  autoscroll=True,
907
  )
908
 
909
+ # =====================================================
910
+ # EVENTS
911
+ # =====================================================
912
+
913
  run_button.click(
914
+
915
  fn=run_vision,
916
+
917
  inputs=[
918
  image,
919
  instruction,
920
  max_tokens,
921
  ],
922
+
923
  outputs=output,
924
  )
925
 
926
  clear_button.click(
927
+
928
  fn=clear_all,
929
+
930
  inputs=[],
931
+
932
  outputs=[
933
  image,
934
  instruction,
 
936
  ],
937
  )
938
 
939
+