mwauranjorogekelvin commited on
Commit
1010cae
Β·
verified Β·
1 Parent(s): b9b24ea

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +112 -15
app.py CHANGED
@@ -5,6 +5,9 @@ import base64
5
  import secrets
6
  import logging
7
  import numpy as np
 
 
 
8
 
9
  from fastapi import FastAPI, HTTPException, Security, Depends
10
  from fastapi.security.api_key import APIKeyHeader
@@ -27,6 +30,47 @@ MODEL_PATH = "./model/model.onnx"
27
 
28
  ORT_INTRA_THREADS = int(os.getenv("ORT_INTRA_THREADS", "1"))
29
  ORT_INTER_THREADS = int(os.getenv("ORT_INTER_THREADS", "1"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  # ── API Key Auth ──────────────────────────────────────────────────────────────
32
  api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
@@ -46,28 +90,66 @@ opts.inter_op_num_threads = ORT_INTER_THREADS
46
  opts.execution_mode = rt.ExecutionMode.ORT_SEQUENTIAL
47
  opts.graph_optimization_level = rt.GraphOptimizationLevel.ORT_ENABLE_ALL
48
  opts.optimized_model_filepath = MODEL_PATH + ".opt"
 
 
49
 
50
  session = rt.InferenceSession(
51
  MODEL_PATH,
52
  sess_options=opts,
53
  providers=["CPUExecutionProvider"]
54
  )
55
- input_name = session.get_inputs()[0].name
56
  logger.info("βœ… ONNX model ready")
57
 
58
  # ── Inference ─────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
59
  def solve_image(image: Image.Image) -> str:
60
- image = image.convert("RGB").resize(IMG_SIZE, Image.BICUBIC)
61
- x = np.array(image, dtype=np.float32) / 255.0
62
- x = (x - 0.5) / 0.5
63
- x = x.transpose(2, 0, 1)[np.newaxis, :] # [1, 3, H, W]
64
- logits = session.run(None, {input_name: x})[0] # numpy output from ONNX
65
- # tokenizer uses torch Tensor ops internally (max, slicing, tolist)
66
- # so convert numpy β†’ torch only at this boundary, nowhere else
67
- probs = torch.tensor(logits).softmax(-1)
68
  preds, _ = tokenizer.decode(probs)
69
  return preds[0]
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  # ── Schemas ───────────────────────────────────────────────────────────────────
72
  class SolveRequest(BaseModel):
73
  image_base64: str
@@ -76,10 +158,12 @@ class SolveResponse(BaseModel):
76
  success: bool
77
  text: str = ""
78
  processing_time: float = 0.0
 
79
  error: str = ""
80
 
 
81
  # ── FastAPI ───────────────────────────────────────────────────────────────────
82
- app = FastAPI(title="CAPTCHA Solver API")
83
 
84
  app.add_middleware(
85
  CORSMiddleware,
@@ -88,6 +172,7 @@ app.add_middleware(
88
  allow_headers=["*"],
89
  )
90
 
 
91
  # ── Endpoints ─────────────────────────────────────────────────────────────────
92
  @app.get("/health")
93
  def health():
@@ -98,8 +183,16 @@ def health():
98
  "quantized": True,
99
  "workers": os.getenv("WEB_CONCURRENCY", "1"),
100
  "intra_threads": ORT_INTRA_THREADS,
 
 
 
 
 
 
 
101
  }
102
 
 
103
  @app.post("/solve-captcha-base64", response_model=SolveResponse)
104
  def solve(req: SolveRequest, _: str = Depends(verify_key)):
105
  start = time.time()
@@ -108,14 +201,18 @@ def solve(req: SolveRequest, _: str = Depends(verify_key)):
108
  if "," in raw:
109
  raw = raw.split(",", 1)[1]
110
 
111
- image = Image.open(io.BytesIO(base64.b64decode(raw))).convert("RGB")
112
- text = solve_image(image)
113
- text = text.strip()[:5]
114
 
115
  elapsed = time.time() - start
116
- logger.info(f"βœ… Solved: '{text}' in {elapsed:.3f}s")
117
 
118
- return SolveResponse(success=True, text=text, processing_time=elapsed)
 
 
 
 
 
119
 
120
  except Exception as e:
121
  logger.error(f"Error: {e}")
 
5
  import secrets
6
  import logging
7
  import numpy as np
8
+ from hashlib import md5
9
+ from collections import OrderedDict
10
+ from contextlib import asynccontextmanager
11
 
12
  from fastapi import FastAPI, HTTPException, Security, Depends
13
  from fastapi.security.api_key import APIKeyHeader
 
30
 
31
  ORT_INTRA_THREADS = int(os.getenv("ORT_INTRA_THREADS", "1"))
32
  ORT_INTER_THREADS = int(os.getenv("ORT_INTER_THREADS", "1"))
33
+ CACHE_MAX_SIZE = int(os.getenv("CACHE_MAX_SIZE", "500"))
34
+
35
+ # ── Torch global optimizations ────────────────────────────────────────────────
36
+ torch.set_grad_enabled(False) # no autograd overhead on tensor ops
37
+ torch.set_num_threads(1) # don't compete with ORT threads
38
+ torch.set_num_interop_threads(1) # no inter-op parallelism from torch
39
+
40
+ # ── LRU Cache ─────────────────────────────────────────────────────────────────
41
+ class LRUCache:
42
+ def __init__(self, max_size: int):
43
+ self.max_size = max_size
44
+ self._cache: OrderedDict = OrderedDict()
45
+ self.hits = 0
46
+ self.misses = 0
47
+
48
+ def get(self, key: str):
49
+ if key not in self._cache:
50
+ self.misses += 1
51
+ return None
52
+ self._cache.move_to_end(key)
53
+ self.hits += 1
54
+ return self._cache[key]
55
+
56
+ def set(self, key: str, value: str):
57
+ if key in self._cache:
58
+ self._cache.move_to_end(key)
59
+ else:
60
+ if len(self._cache) >= self.max_size:
61
+ self._cache.popitem(last=False)
62
+ self._cache[key] = value
63
+
64
+ @property
65
+ def size(self):
66
+ return len(self._cache)
67
+
68
+ @property
69
+ def hit_rate(self):
70
+ total = self.hits + self.misses
71
+ return round(self.hits / total * 100, 1) if total else 0.0
72
+
73
+ cache = LRUCache(max_size=CACHE_MAX_SIZE)
74
 
75
  # ── API Key Auth ──────────────────────────────────────────────────────────────
76
  api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
 
90
  opts.execution_mode = rt.ExecutionMode.ORT_SEQUENTIAL
91
  opts.graph_optimization_level = rt.GraphOptimizationLevel.ORT_ENABLE_ALL
92
  opts.optimized_model_filepath = MODEL_PATH + ".opt"
93
+ opts.enable_mem_pattern = True # reuse memory allocations across runs
94
+ opts.enable_cpu_mem_arena = True # pre-allocate memory pool for ORT
95
 
96
  session = rt.InferenceSession(
97
  MODEL_PATH,
98
  sess_options=opts,
99
  providers=["CPUExecutionProvider"]
100
  )
101
+ input_name = session.get_inputs()[0].name # cache once at startup
102
  logger.info("βœ… ONNX model ready")
103
 
104
  # ── Inference ─────────────────────────────────────────────────────────────────
105
+ def preprocess(image: Image.Image) -> np.ndarray:
106
+ """Normalize and transpose in one contiguous block β€” no extra memory copies."""
107
+ image = image.convert("RGB").resize(IMG_SIZE, Image.BILINEAR) # faster than BICUBIC
108
+ x = np.ascontiguousarray(image, dtype=np.float32)
109
+ x = (x / 255.0 - 0.5) / 0.5
110
+ return x.transpose(2, 0, 1)[np.newaxis, :] # [1, 3, H, W]
111
+
112
+
113
  def solve_image(image: Image.Image) -> str:
114
+ x = preprocess(image)
115
+ logits = session.run(None, {input_name: x})[0]
116
+ # tokenizer uses torch Tensor ops internally β€” convert only at this boundary
117
+ probs = torch.tensor(logits).softmax(-1)
 
 
 
 
118
  preds, _ = tokenizer.decode(probs)
119
  return preds[0]
120
 
121
+
122
+ def solve_with_cache(raw_b64: str, image: Image.Image) -> tuple[str, bool]:
123
+ key = md5(raw_b64.encode()).hexdigest()
124
+ cached = cache.get(key)
125
+ if cached is not None:
126
+ logger.info(f"⚑ Cache hit β€” '{cached}'")
127
+ return cached, True
128
+ text = solve_image(image).strip()[:5]
129
+ cache.set(key, text)
130
+ return text, False
131
+
132
+
133
+ # ── Warmup ────────────────────────────────────────────────────────────────────
134
+ def warmup():
135
+ """
136
+ Run 3 dummy inferences at startup so ORT JIT-compiles the graph
137
+ before any real request arrives. Every worker runs this independently.
138
+ """
139
+ logger.info("Warming up model...")
140
+ dummy = Image.new("RGB", IMG_SIZE, color=(128, 128, 128))
141
+ for _ in range(3):
142
+ solve_image(dummy)
143
+ logger.info("βœ… Warmup complete β€” model is hot")
144
+
145
+
146
+ # ── Lifespan ──────────────────────────────────────────────────────────────────
147
+ @asynccontextmanager
148
+ async def lifespan(app: FastAPI):
149
+ warmup()
150
+ yield
151
+
152
+
153
  # ── Schemas ───────────────────────────────────────────────────────────────────
154
  class SolveRequest(BaseModel):
155
  image_base64: str
 
158
  success: bool
159
  text: str = ""
160
  processing_time: float = 0.0
161
+ cached: bool = False
162
  error: str = ""
163
 
164
+
165
  # ── FastAPI ───────────────────────────────────────────────────────────────────
166
+ app = FastAPI(title="CAPTCHA Solver API", lifespan=lifespan)
167
 
168
  app.add_middleware(
169
  CORSMiddleware,
 
172
  allow_headers=["*"],
173
  )
174
 
175
+
176
  # ── Endpoints ─────────────────────────────────────────────────────────────────
177
  @app.get("/health")
178
  def health():
 
183
  "quantized": True,
184
  "workers": os.getenv("WEB_CONCURRENCY", "1"),
185
  "intra_threads": ORT_INTRA_THREADS,
186
+ "cache": {
187
+ "size": cache.size,
188
+ "max_size": cache.max_size,
189
+ "hits": cache.hits,
190
+ "misses": cache.misses,
191
+ "hit_rate": f"{cache.hit_rate}%",
192
+ },
193
  }
194
 
195
+
196
  @app.post("/solve-captcha-base64", response_model=SolveResponse)
197
  def solve(req: SolveRequest, _: str = Depends(verify_key)):
198
  start = time.time()
 
201
  if "," in raw:
202
  raw = raw.split(",", 1)[1]
203
 
204
+ image = Image.open(io.BytesIO(base64.b64decode(raw)))
205
+ text, hit = solve_with_cache(raw, image)
 
206
 
207
  elapsed = time.time() - start
208
+ logger.info(f"βœ… Solved: '{text}' in {elapsed:.3f}s (cached={hit})")
209
 
210
+ return SolveResponse(
211
+ success=True,
212
+ text=text,
213
+ processing_time=elapsed,
214
+ cached=hit,
215
+ )
216
 
217
  except Exception as e:
218
  logger.error(f"Error: {e}")