Seth0330 commited on
Commit
933228b
·
verified ·
1 Parent(s): ae5855a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +245 -532
app.py CHANGED
@@ -1,549 +1,262 @@
1
- import streamlit as st
2
- import io
3
- import base64
4
- import pandas as pd
5
- from PIL import Image
6
  from datetime import datetime
7
- import csv
8
- import json
9
- import os
10
- import requests
11
-
12
- # Optional PDF support via PyMuPDF
13
- try:
14
- import fitz # PyMuPDF
15
- PDF_SUPPORT = True
16
- except ImportError:
17
- PDF_SUPPORT = False
18
-
19
- # Optional HF Inference API client (for LLaVA serverless)
20
- try:
21
- from huggingface_hub import InferenceClient
22
- HF_CLIENT_AVAILABLE = True
23
- except ImportError:
24
- HF_CLIENT_AVAILABLE = False
25
-
26
- # ---------------------------
27
- # Page config (must be first Streamlit call)
28
- # ---------------------------
29
- st.set_page_config(
30
- page_title="EZOFIS AI OCR",
31
- page_icon="🔍",
32
- layout="wide",
33
- initial_sidebar_state="expanded"
34
- )
35
-
36
- # ---------------------------
37
- # Global UI / Render constants (NOT args to set_page_config)
38
- # ---------------------------
39
- IMAGE_PREVIEW_WIDTH = 1000
40
- PDF_RENDER_SCALE = 3.0
41
-
42
- # ---------------------------
43
- # Secrets / Tokens
44
- # ---------------------------
45
- # OpenRouter + HF API
46
- OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") # For OpenRouter models
47
- HF_TOKEN = os.getenv("HF_TOKEN") # For HF Inference API (LLaVA)
48
-
49
- # RunPod (secured, OpenAI-compatible)
50
- RUNPOD_SECURE_BASE_URL = os.getenv("RUNPOD_SECURE_BASE_URL", "").rstrip("/") # e.g. http://194.68.245.201:22156/v1
51
- RUNPOD_SECURE_API_KEY = os.getenv("RUNPOD_SECURE_API_KEY") # optional
52
- RUNPOD_SECURE_MODEL = os.getenv("RUNPOD_SECURE_MODEL", "qwen2.5:32b-instruct") # set to your model id
53
-
54
- # ---------------------------
55
- # Helpers
56
- # ---------------------------
57
- def resize_image(image, max_size=1920):
58
- w, h = image.size
59
- if w > max_size or h > max_size:
60
- if w > h:
61
- nw = max_size
62
- nh = int(h * (max_size / w))
63
- else:
64
- nh = max_size
65
- nw = int(w * (max_size / h))
66
- return image.resize((nw, nh), Image.LANCZOS)
67
- return image
68
 
69
- def image_to_base64(image):
70
- buf = io.BytesIO()
71
- image.save(buf, format='JPEG')
72
- return base64.b64encode(buf.getvalue()).decode('utf-8')
73
-
74
- def extract_structured_data(content, fields):
75
- """Attempt to parse JSON object from model text."""
76
- structured_data = {}
77
- try:
78
- if "```json" in content and "```" in content.split("```json")[1]:
79
- json_str = content.split("```json")[1].split("```")[0].strip()
80
- structured_data.update(json.loads(json_str))
81
- else:
82
- try:
83
- maybe = json.loads(content)
84
- if isinstance(maybe, dict):
85
- structured_data.update(maybe)
86
- except Exception:
87
- pass
88
- except Exception:
89
- pass
90
- return structured_data
91
-
92
- def is_vision_model_name(name: str) -> bool:
93
- """Heuristic: treat models containing 'vl', 'vision', 'mm', or 'multimodal' as vision-capable."""
94
- n = (name or "").lower()
95
- return any(k in n for k in ["vl", "vision", "mm", "multimodal"])
96
-
97
- # ---------------------------
98
- # OpenRouter client (multimodal chat)
99
- # ---------------------------
100
- def query_openrouter(prompt: str, image_base64: str, model_id: str) -> str:
101
- if not OPENROUTER_API_KEY:
102
- raise RuntimeError("Missing OPENROUTER_API_KEY. Add it in your Space → Settings → Variables & secrets.")
103
-
104
- data_url = f"data:image/jpeg;base64,{image_base64}"
105
- payload = {
106
- "model": model_id,
107
- "messages": [
108
- {
109
- "role": "user",
110
- "content": [
111
- {"type": "text", "text": prompt},
112
- {"type": "image_url", "image_url": {"url": data_url}}
113
- ]
114
- }
115
- ],
116
- "max_tokens": 800
117
- }
118
- headers = {
119
- "Authorization": f"Bearer {OPENROUTER_API_KEY}",
120
- "Content-Type": "application/json",
121
- "HTTP-Referer": st.secrets.get("SPACE_URL", "https://hf.space"),
122
- "X-Title": "EZOFIS AI OCR"
123
- }
124
- r = requests.post("https://openrouter.ai/api/v1/chat/completions",
125
- headers=headers, json=payload, timeout=120)
126
- r.raise_for_status()
127
- data = r.json()
128
- return data["choices"][0]["message"]["content"]
129
-
130
- # ---------------------------
131
- # HF Inference API client for LLaVA (serverless VQA-style)
132
- # ---------------------------
133
- @st.cache_resource
134
- def _hf_client(model_id: str):
135
- if not HF_CLIENT_AVAILABLE:
136
- raise RuntimeError("huggingface_hub not installed. Add it to requirements.txt.")
137
- if not HF_TOKEN:
138
- raise RuntimeError("Missing HF_TOKEN. Add it in your Space → Settings → Variables & secrets.")
139
- return InferenceClient(model=model_id, token=HF_TOKEN)
140
 
141
- def query_hf_llava_vqa(prompt: str, image_base64: str, model_id: str) -> str:
142
- client = _hf_client(model_id)
143
- image_bytes = base64.b64decode(image_base64)
144
- try:
145
- result = client.visual_question_answering(image=image_bytes, question=prompt)
146
- except TypeError:
147
- result = client.request(
148
- task="visual_question_answering",
149
- data={"inputs": {"question": prompt}},
150
- files={"image": image_bytes}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  )
152
-
153
- if isinstance(result, str):
154
- return result
155
- if isinstance(result, dict):
156
- return result.get("answer") or result.get("generated_text") or json.dumps(result, ensure_ascii=False)
157
- if isinstance(result, list) and result:
158
- first = result[0]
159
- if isinstance(first, dict):
160
- return first.get("answer") or first.get("generated_text") or json.dumps(first, ensure_ascii=False)
161
- return str(first)
162
- return str(result)
163
-
164
- # ---------------------------
165
- # RunPod (secured, OpenAI-compatible)
166
- # ---------------------------
167
- def _secured_openai_compatible(prompt: str, image_base64: str) -> str:
168
- """
169
- Call your OpenAI-compatible server on RunPod/OpenWebUI/Ollama.
170
- Works with base URLs that already include /v1 or not.
171
- API key header is added only if provided.
172
- """
173
- if not RUNPOD_SECURE_BASE_URL:
174
- raise RuntimeError("RUNPOD_SECURE_BASE_URL is missing.")
175
-
176
- base = RUNPOD_SECURE_BASE_URL.rstrip("/")
177
- if base.endswith("/v1"):
178
- url = f"{base}/chat/completions"
179
- else:
180
- url = f"{base}/v1/chat/completions"
181
-
182
- headers = {"Content-Type": "application/json"}
183
- if RUNPOD_SECURE_API_KEY:
184
- headers["Authorization"] = f"Bearer {RUNPOD_SECURE_API_KEY}"
185
-
186
- # If the configured model isn't vision-capable, send text-only content.
187
- model_name = RUNPOD_SECURE_MODEL
188
- vision_ok = is_vision_model_name(model_name)
189
-
190
- if vision_ok:
191
- data_url = f"data:image/jpeg;base64,{image_base64}"
192
- content = [
193
- {"type": "text", "text": prompt},
194
- {"type": "image_url", "image_url": {"url": data_url}}
195
- ]
196
- else:
197
- # Text-only fallback: no image is sent.
198
- content = [
199
- {"type": "text", "text": f"{prompt}\n\n(Note: model configured as text-only; image not sent.)"}
200
- ]
201
-
202
- payload = {
203
- "model": model_name,
204
- "messages": [{"role": "user", "content": content}],
205
- "max_tokens": 800
206
- }
207
-
208
- r = requests.post(url, headers=headers, json=payload, timeout=600)
209
- r.raise_for_status()
210
- js = r.json()
211
- return js["choices"][0]["message"]["content"]
212
-
213
- def query_runpod_secured(prompt: str, image_base64: str) -> str:
214
- return _secured_openai_compatible(prompt, image_base64)
215
-
216
- # ---------------------------
217
- # Router to pick the right backend by model selection
218
- # ---------------------------
219
- HF_LLaVA_LABEL = "llava-hf/llava-v1.6-mistral-7b-hf (HF API)"
220
- HF_LLaVA_ID = "llava-hf/llava-v1.6-mistral-7b-hf"
221
- RUNPOD_SECURE_LABEL = "RunPod (secured)"
222
-
223
- def run_vision_inference(prompt: str, img_b64: str, model_id: str) -> str:
224
- if model_id == HF_LLaVA_LABEL:
225
- return query_hf_llava_vqa(prompt, img_b64, HF_LLaVA_ID)
226
- if model_id == RUNPOD_SECURE_LABEL:
227
- return query_runpod_secured(prompt, img_b64)
228
- # All others go via OpenRouter
229
- return query_openrouter(prompt, img_b64, model_id)
230
-
231
- # ---------------------------
232
- # Core processing
233
- # ---------------------------
234
- def process_image(image, filename, fields=None, model=None):
235
- img_base64 = image_to_base64(resize_image(image))
236
-
237
- if fields is None:
238
- prompt = "Describe this image in detail."
239
- content = run_vision_inference(prompt, img_base64, model)
240
- return {'filename': filename, 'description': content}, content, None
241
- else:
242
- fields_str = ", ".join(fields)
243
- prompt = (
244
- "Extract the following fields from this image and return JSON only "
245
- f"with these exact keys: {fields_str}. If a field is missing, use an empty string."
246
  )
247
- content = run_vision_inference(prompt, img_base64, model)
248
- structured_data = {'filename': filename}
249
- parsed = extract_structured_data(content, fields)
250
- if parsed:
251
- structured_data.update(parsed)
252
- return {'filename': filename, 'extraction': content}, content, structured_data
253
-
254
- def process_pdf(file_bytes, filename, fields=None, process_pages_separately=True, model=None):
255
- if not PDF_SUPPORT:
256
- yield None, None, None, filename, "PDF support requires PyMuPDF. Install pymupdf.", None
257
- return
258
-
259
- try:
260
- pdf_document = fitz.open(stream=file_bytes, filetype="pdf")
261
- page_count = len(pdf_document)
262
-
263
- def _render_page(page):
264
- # Higher-res, no alpha to keep RGB consistent
265
- pix = page.get_pixmap(matrix=fitz.Matrix(PDF_RENDER_SCALE, PDF_RENDER_SCALE), alpha=False)
266
- img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
267
- return img
268
-
269
- if process_pages_separately:
270
- for page_num in range(page_count):
271
- page = pdf_document[page_num]
272
- img = _render_page(page)
273
- page_filename = f"{filename} (Page {page_num+1})"
274
- result, content, structured_data = process_image(img, page_filename, fields, model)
275
- yield page_num, page_count, img, page_filename, content, structured_data
276
- else:
277
- page = pdf_document[0]
278
- img = _render_page(page)
279
- result, content, structured_data = process_image(img, filename, fields, model)
280
- yield 0, page_count, img, filename, content, structured_data
281
-
282
- except Exception as e:
283
- yield None, None, None, filename, f"Error processing PDF: {str(e)}", None
284
-
285
- def create_download_buttons(results, structured_results, extraction_mode):
286
- st.header("Download Results")
287
- base_csv = io.StringIO()
288
- base_writer = csv.writer(base_csv)
289
- base_writer.writerow(['Filename', 'Description/Extraction'])
290
- for r in results:
291
- base_writer.writerow([r['filename'], r.get('description', r.get('extraction', ''))])
292
- ts = datetime.now().strftime("%Y%m%d_%H%M%S")
293
- base_name = f"image_analysis_{ts}.csv"
294
-
295
- st.success("All files processed.")
296
- st.download_button(
297
- label="Download Results (CSV)",
298
- data=base_csv.getvalue(),
299
- file_name=base_name,
300
- mime="text/csv",
301
- use_container_width=True
302
- )
303
-
304
- if extraction_mode == "Custom field extraction" and structured_results:
305
- all_fields = set(['filename'])
306
- for row in structured_results:
307
- all_fields.update(row.keys())
308
- headers = sorted(list(all_fields))
309
- buff = io.StringIO()
310
- w = csv.writer(buff)
311
- w.writerow(headers)
312
- for row in structured_results:
313
- w.writerow([row.get(h, '') for h in headers])
314
- st.download_button(
315
- label="Download Structured Data (CSV)",
316
- data=buff.getvalue(),
317
- file_name=f"structured_data_{ts}.csv",
318
- mime="text/csv",
319
- use_container_width=True
320
  )
 
 
321
 
322
- # ---------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  # UI
324
- # ---------------------------
325
- st.title("EZOFIS AI OCR")
326
-
327
- if 'results' not in st.session_state:
328
- st.session_state.results = []
329
- if 'structured_results' not in st.session_state:
330
- st.session_state.structured_results = []
331
-
332
- with st.sidebar:
333
- st.header("Upload Files")
334
- uploaded_files = st.file_uploader(
335
- "Choose images or PDFs",
336
- accept_multiple_files=True,
337
- type=['png', 'jpg', 'jpeg', 'pdf']
338
  )
339
 
340
- st.header("Model Settings")
341
- selected_model = st.selectbox(
342
- "Choose vision model:",
343
- [
344
- "google/gemma-3-4b-it",
345
- "google/gemma-3-12b-it",
346
- "openai/gpt-4.1",
347
- "openai/gpt-4.1-mini",
348
- "qwen/qwen2.5-vl-32b-instruct", # OpenRouter vision option
349
- HF_LLaVA_LABEL, # LLaVA via HF API
350
- RUNPOD_SECURE_LABEL # Your RunPod OpenAI-compatible server
351
- ],
352
- help=("OpenRouter uses OPENROUTER_API_KEY. "
353
- "LLaVA (HF API) uses HF_TOKEN. "
354
- "RunPod (secured) uses RUNPOD_SECURE_* env vars. "
355
- f"Current RunPod model: {RUNPOD_SECURE_MODEL}")
356
- )
357
-
358
- # If RunPod model looks text-only, warn user
359
- if selected_model == RUNPOD_SECURE_LABEL and not is_vision_model_name(RUNPOD_SECURE_MODEL):
360
- st.warning(
361
- f"RunPod model '{RUNPOD_SECURE_MODEL}' appears text-only. "
362
- "Requests to this endpoint will NOT include images. "
363
- "Use a VL model (e.g. 'qwen2.5-vl:32b-instruct') for vision."
364
- )
365
-
366
- extraction_mode = "General description"
367
- pdf_process_mode = "Process each page separately"
368
- fields = None
369
-
370
- if uploaded_files:
371
- st.write(f"Uploaded {len(uploaded_files)} file(s)")
372
-
373
- st.header("Data Extraction Options")
374
- extraction_mode = st.radio(
375
- "Choose extraction mode:",
376
- ["General description", "Custom field extraction"]
377
- )
378
-
379
- if extraction_mode == "Custom field extraction":
380
- custom_fields = st.text_area(
381
- "Enter fields to extract (comma separated or your prompt here):",
382
- value="Invoice number, Date, Company name, Total amount"
 
 
 
 
 
 
 
 
 
 
 
 
383
  )
384
- fields = [f.strip() for f in custom_fields.split(",") if f.strip()]
385
-
386
- if any(file.name.lower().endswith('.pdf') for file in uploaded_files):
387
- pdf_process_mode = st.radio(
388
- "How to process PDF files:",
389
- ["Process each page separately", "Process entire PDF as one document"]
390
- )
391
-
392
- process_button = st.button("Process Files", use_container_width=True)
393
- else:
394
- process_button = False
395
- st.info("Upload images or PDFs to begin.")
396
-
397
- # Processing loop
398
- if uploaded_files and process_button:
399
- # Token checks by route
400
- can_run = False
401
- if selected_model == HF_LLaVA_LABEL:
402
- if not HF_CLIENT_AVAILABLE:
403
- st.error("huggingface_hub not installed. Add 'huggingface_hub' to requirements.txt.")
404
- elif not HF_TOKEN:
405
- st.error("HF_TOKEN is not set.")
406
- else:
407
- can_run = True
408
- elif selected_model == RUNPOD_SECURE_LABEL:
409
- if not RUNPOD_SECURE_BASE_URL:
410
- st.error("RUNPOD_SECURE_BASE_URL is not set.")
411
- else:
412
- can_run = True
413
- else:
414
- if not OPENROUTER_API_KEY:
415
- st.error("OPENROUTER_API_KEY is not set.")
416
- else:
417
- can_run = True
418
-
419
- if can_run:
420
- st.header("Processing Results")
421
- progress_bar = st.progress(0)
422
- status_text = st.empty()
423
-
424
- st.session_state.results = []
425
- st.session_state.structured_results = []
426
-
427
- total_items = 0
428
- for f in uploaded_files:
429
- file_bytes = f.read()
430
- f.seek(0)
431
- if f.name.lower().endswith('.pdf') and PDF_SUPPORT:
432
- if pdf_process_mode == "Process each page separately":
433
- try:
434
- pdf_document = fitz.open(stream=file_bytes, filetype="pdf")
435
- total_items += len(pdf_document)
436
- except Exception:
437
- total_items += 1
438
- else:
439
- total_items += 1
440
- else:
441
- total_items += 1
442
-
443
- processed_count = 0
444
-
445
- for f in uploaded_files:
446
- file_bytes = f.read()
447
- f.seek(0)
448
-
449
- if f.name.lower().endswith('.pdf'):
450
- if not PDF_SUPPORT:
451
- st.error("PDF support requires PyMuPDF. Add 'pymupdf' to requirements.txt.")
452
- processed_count += 1
453
- progress_bar.progress(processed_count / max(total_items, 1))
454
- continue
455
-
456
- try:
457
- process_separately = pdf_process_mode == "Process each page separately"
458
- for page_info in process_pdf(file_bytes, f.name, fields, process_separately, selected_model):
459
- page_num, page_count, image, page_filename, content, structured_data = page_info
460
- if page_num is None:
461
- st.error(content)
462
- continue
463
-
464
- status_text.text(f"Processing {page_filename} ({page_num+1}/{page_count})")
465
- result = {'filename': page_filename, 'description': content}
466
- st.session_state.results.append(result)
467
- if structured_data and len(structured_data) > 1:
468
- st.session_state.structured_results.append(structured_data)
469
-
470
- st.subheader(page_filename)
471
- c1, c2 = st.columns([3, 2]) # give image more room
472
- with c1:
473
- st.image(image, width=IMAGE_PREVIEW_WIDTH)
474
- if page_count > 1 and not process_separately:
475
- st.info(f"PDF has {page_count} pages. Showing first page only.")
476
- with c2:
477
- st.write(content)
478
- if structured_data and len(structured_data) > 1:
479
- st.success("Extracted structured data")
480
- st.json(structured_data)
481
-
482
- st.divider()
483
- processed_count += 1
484
- progress_bar.progress(min(processed_count / max(total_items, 1), 1.0))
485
-
486
- except Exception as e:
487
- st.error(f"Error processing PDF {f.name}: {e}")
488
- processed_count += 1
489
- progress_bar.progress(min(processed_count / max(total_items, 1), 1.0))
490
-
491
- else:
492
  try:
493
- status_text.text(f"Processing image {f.name}")
494
- image = Image.open(f).convert("RGB")
495
- result, content, structured_data = process_image(image, f.name, fields, selected_model)
496
- st.session_state.results.append(result)
497
- if structured_data and len(structured_data) > 1:
498
- st.session_state.structured_results.append(structured_data)
499
-
500
- st.subheader(f"Image: {f.name}")
501
- c1, c2 = st.columns([3, 2])
502
- with c1:
503
- st.image(image, width=IMAGE_PREVIEW_WIDTH)
504
- with c2:
505
- st.write(content)
506
- if structured_data and len(structured_data) > 1:
507
- st.success("Extracted structured data")
508
- st.json(structured_data)
509
-
510
- st.divider()
511
-
512
  except Exception as e:
513
- st.error(f"Error processing image {f.name}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
 
515
- processed_count += 1
516
- progress_bar.progress(min(processed_count / max(total_items, 1), 1.0))
 
 
 
 
 
517
 
518
- status_text.text("Processing complete.")
519
- if st.session_state.results:
520
- create_download_buttons(
521
- st.session_state.results,
522
- st.session_state.structured_results,
523
- extraction_mode
524
- )
 
 
 
 
525
 
526
- if not uploaded_files:
527
- st.info("Upload files using the sidebar to get started.")
528
- st.write("""
529
- How to use:
530
- 1) Upload one or more images or PDFs
531
- 2) Choose a model:
532
- - OpenRouter: Gemma-3 4B/12B, GPT-4.1/4.1-mini, Qwen2.5-VL-32B
533
- - HF API: LLaVA v1.6 Mistral-7B
534
- - RunPod (secured): OpenAI-compatible base URL (supports images only if the model is VL)
535
- 3) Pick description or custom field extraction
536
- 4) For PDFs, choose page-by-page or first page
537
- 5) Click Process Files
538
- 6) Review outputs and download CSVs
539
- """)
540
 
541
- st.markdown("---")
542
- st.markdown(
543
- """
544
- <div style="text-align: center; margin-top: 12px; opacity: 0.7;">
545
- EZOFIS AI OCR
546
- </div>
547
- """,
548
- unsafe_allow_html=True
549
- )
 
 
 
 
 
1
+ import sqlite3
2
+ import threading
3
+ import time
4
+ import re
 
5
  from datetime import datetime
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
+ import pandas as pd
8
+ import streamlit as st
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
+ # =========================
11
+ # App Config
12
+ # =========================
13
+ st.set_page_config(page_title="Expo Game Timer", page_icon="⏱️", layout="centered")
14
+
15
+ DB_PATH = "game.db"
16
+ DB_LOCK = threading.Lock()
17
+
18
+ # =========================
19
+ # DB Utilities
20
+ # =========================
21
+ def init_db():
22
+ with DB_LOCK:
23
+ conn = sqlite3.connect(DB_PATH, check_same_thread=False)
24
+ cur = conn.cursor()
25
+ cur.execute(
26
+ """
27
+ CREATE TABLE IF NOT EXISTS results (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ name TEXT NOT NULL,
30
+ email TEXT NOT NULL,
31
+ seconds REAL NOT NULL,
32
+ created_at TEXT NOT NULL
33
+ )
34
+ """
35
  )
36
+ conn.commit()
37
+ conn.close()
38
+
39
+ def insert_result(name: str, email: str, seconds: float):
40
+ now = datetime.utcnow().isoformat()
41
+ with DB_LOCK:
42
+ conn = sqlite3.connect(DB_PATH, check_same_thread=False)
43
+ cur = conn.cursor()
44
+ cur.execute(
45
+ "INSERT INTO results (name, email, seconds, created_at) VALUES (?, ?, ?, ?)",
46
+ (name.strip(), email.strip().lower(), float(seconds), now),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  )
48
+ conn.commit()
49
+ conn.close()
50
+ # bust cached reads
51
+ load_all_results.clear()
52
+
53
+ @st.cache_data(show_spinner=False)
54
+ def load_all_results() -> pd.DataFrame:
55
+ with DB_LOCK:
56
+ conn = sqlite3.connect(DB_PATH, check_same_thread=False)
57
+ df = pd.read_sql_query(
58
+ "SELECT id, name, email, seconds, created_at FROM results ORDER BY id DESC",
59
+ conn,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  )
61
+ conn.close()
62
+ return df
63
 
64
+ # =========================
65
+ # Helpers
66
+ # =========================
67
+ EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$")
68
+
69
+ def valid_email(email: str) -> bool:
70
+ return bool(EMAIL_RE.match(email or ""))
71
+
72
+ def format_seconds(s: float) -> str:
73
+ # mm:ss.mmm
74
+ m, sec = divmod(max(s, 0.0), 60)
75
+ return f"{int(m):02d}:{sec:06.3f}"
76
+
77
+ def ensure_session_state():
78
+ ss = st.session_state
79
+ if "running" not in ss:
80
+ ss.running = False
81
+ if "start_perf" not in ss:
82
+ ss.start_perf = 0.0
83
+ if "accumulated" not in ss:
84
+ ss.accumulated = 0.0
85
+ if "last_display" not in ss:
86
+ ss.last_display = 0.0
87
+ if "name" not in ss:
88
+ ss.name = ""
89
+ if "email" not in ss:
90
+ ss.email = ""
91
+
92
+ def current_elapsed() -> float:
93
+ ss = st.session_state
94
+ if ss.running:
95
+ return ss.accumulated + (time.perf_counter() - ss.start_perf)
96
+ return ss.accumulated
97
+
98
+ def start_timer():
99
+ ss = st.session_state
100
+ if not ss.running:
101
+ ss.start_perf = time.perf_counter()
102
+ ss.running = True
103
+
104
+ def stop_timer():
105
+ ss = st.session_state
106
+ if ss.running:
107
+ ss.accumulated += (time.perf_counter() - ss.start_perf)
108
+ ss.running = False
109
+
110
+ def reset_timer():
111
+ ss = st.session_state
112
+ ss.running = False
113
+ ss.start_perf = 0.0
114
+ ss.accumulated = 0.0
115
+
116
+ # =========================
117
  # UI
118
+ # =========================
119
+ def header():
120
+ st.markdown(
121
+ """
122
+ <div style="text-align:center; margin-bottom: 0.5rem;">
123
+ <h1 style="margin-bottom:0">⏱️ Expo Game Timer</h1>
124
+ <p style="color:#666; margin-top:0.25rem">Record participants, time their run, track a live leaderboard, and export results.</p>
125
+ </div>
126
+ """,
127
+ unsafe_allow_html=True,
 
 
 
 
128
  )
129
 
130
+ def participant_form():
131
+ c1, c2 = st.columns(2)
132
+ with c1:
133
+ st.text_input("Participant Name", key="name", placeholder="Jane Doe")
134
+ with c2:
135
+ st.text_input("Email", key="email", placeholder="jane@example.com")
136
+
137
+ def stopwatch_card():
138
+ ensure_session_state()
139
+
140
+ # auto-refresh display while running
141
+ if st.session_state.running:
142
+ st.autorefresh = st.experimental_rerun # backward compat no-op
143
+ st.experimental_rerun # ensures responsive live update on Spaces
144
+ # NOTE: If the above rerun feels too aggressive on your Space,
145
+ # comment it out and use `st.autorefresh(interval=200, key="tick")`.
146
+
147
+ st.markdown("### Stopwatch")
148
+ with st.container(border=True):
149
+ elapsed = current_elapsed()
150
+ st.markdown(f"<div style='font-size:3rem; text-align:center; font-variant-numeric: tabular-nums;'>{format_seconds(elapsed)}</div>",
151
+ unsafe_allow_html=True)
152
+
153
+ b1, b2, b3 = st.columns(3)
154
+ with b1:
155
+ if st.button("▶️ Start", use_container_width=True, disabled=st.session_state.running):
156
+ start_timer()
157
+ st.rerun()
158
+ with b2:
159
+ if st.button("⏸️ Stop", use_container_width=True, disabled=not st.session_state.running):
160
+ stop_timer()
161
+ st.rerun()
162
+ with b3:
163
+ if st.button(" Reset", use_container_width=True, disabled=(current_elapsed() == 0.0 and not st.session_state.running)):
164
+ reset_timer()
165
+ st.rerun()
166
+
167
+ st.caption("Tip: Start the timer when the game begins and press Stop as soon as they finish. Then Save Result.")
168
+
169
+ # Save section
170
+ st.divider()
171
+ save_col1, save_col2 = st.columns([2, 1])
172
+ with save_col1:
173
+ st.write("**Save this run**")
174
+ if not st.session_state.name.strip():
175
+ st.info("Enter a participant name.")
176
+ if not st.session_state.email.strip():
177
+ st.info("Enter a valid email.")
178
+ if st.session_state.email and not valid_email(st.session_state.email):
179
+ st.error("Please enter a valid email address.")
180
+ with save_col2:
181
+ disabled_save = (
182
+ not st.session_state.name.strip()
183
+ or not valid_email(st.session_state.email)
184
+ or current_elapsed() <= 0.0
185
  )
186
+ if st.button("💾 Save Result", type="primary", use_container_width=True, disabled=disabled_save):
187
+ secs = round(current_elapsed(), 3)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  try:
189
+ insert_result(st.session_state.name, st.session_state.email, secs)
190
+ st.success(f"Saved: {st.session_state.name} — {format_seconds(secs)}")
191
+ reset_timer()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  except Exception as e:
193
+ st.error(f"Failed to save result: {e}")
194
+ st.rerun()
195
+
196
+ def dashboard():
197
+ st.markdown("### Dashboard")
198
+ with st.container(border=True):
199
+ df = load_all_results()
200
+ if df.empty:
201
+ st.info("No results yet. Save the first run to see stats and leaderboard.")
202
+ return
203
+
204
+ # Quick stats
205
+ total = len(df)
206
+ best = df["seconds"].min()
207
+ avg = df["seconds"].mean()
208
+
209
+ s1, s2, s3 = st.columns(3)
210
+ s1.metric("Total Participants (runs)", total)
211
+ s2.metric("Best Time", format_seconds(best))
212
+ s3.metric("Average Time", format_seconds(avg))
213
+
214
+ st.markdown("#### 🏆 Top 3 Fastest")
215
+ top3 = df.sort_values("seconds", ascending=True).head(3).copy()
216
+ top3["Time"] = top3["seconds"].apply(format_seconds)
217
+ st.dataframe(
218
+ top3[["name", "email", "Time", "created_at"]]
219
+ .rename(columns={"name": "Name", "email": "Email", "created_at": "Recorded (UTC)"}),
220
+ hide_index=True,
221
+ use_container_width=True,
222
+ )
223
 
224
+ st.markdown("#### All Results")
225
+ show = df.copy()
226
+ show["Time"] = show["seconds"].apply(format_seconds)
227
+ show = show[["id", "name", "email", "Time", "created_at"]].rename(
228
+ columns={"id": "ID", "name": "Name", "email": "Email", "created_at": "Recorded (UTC)"}
229
+ )
230
+ st.dataframe(show, use_container_width=True, hide_index=True)
231
 
232
+ # CSV download
233
+ csv_df = df.copy()
234
+ csv_df["time_formatted"] = csv_df["seconds"].apply(format_seconds)
235
+ csv_bytes = csv_df.to_csv(index=False).encode("utf-8")
236
+ st.download_button(
237
+ label="⬇️ Download CSV",
238
+ data=csv_bytes,
239
+ file_name="game_results.csv",
240
+ mime="text/csv",
241
+ use_container_width=True,
242
+ )
243
 
244
+ def footer_note():
245
+ st.caption(
246
+ "Data is stored in a local SQLite database (`game.db`). "
247
+ "Multiple attempts per email are allowed; use the CSV to post-process if you want best-per-email."
248
+ )
 
 
 
 
 
 
 
 
 
249
 
250
+ # =========================
251
+ # Main
252
+ # =========================
253
+ def main():
254
+ init_db()
255
+ header()
256
+ participant_form()
257
+ stopwatch_card()
258
+ dashboard()
259
+ footer_note()
260
+
261
+ if __name__ == "__main__":
262
+ main()