ballagb19's picture
Update captcha_solver/api/routes.py: full-image VQA + improved solver for canvas text challenges
5f610b9 verified
Raw
History Blame Contribute Delete
9.69 kB
"""FastAPI routes for the captcha solver API."""
from __future__ import annotations
import time
from threading import Lock
from fastapi import APIRouter, HTTPException
from captcha_solver.config import get_settings
from captcha_solver.models import (
HealthResponse,
ModelsResponse,
SolveRequest,
SolveResponse,
StatsResponse,
)
from captcha_solver.solvers.router import SolverRouter
from captcha_solver.utils.cache import SolveCache
from captcha_solver.utils.image import audio_size_hash, perceptual_hash
router = APIRouter()
_state: dict = {
"router": None,
"cache": None,
"stats": {
"total": 0,
"by_type": {},
"by_solver": {},
"success": 0,
"elapsed_sum_ms": 0,
"cache_hits": 0,
"started_at": time.time(),
},
"_stats_lock": Lock(),
}
def get_state() -> dict:
if _state["router"] is None:
s = get_settings()
r = SolverRouter()
r.init()
_state["router"] = r
_state["cache"] = SolveCache(
ttl_seconds=s.cache_ttl_seconds,
max_entries=s.cache_max_entries,
)
return _state
@router.post("/solve", response_model=SolveResponse)
def solve(req: SolveRequest) -> SolveResponse:
s = get_settings()
state = get_state()
t0 = time.time()
captcha_type = req.type or "auto"
if not req.image_base64 and not req.audio_base64 and not req.hint:
raise HTTPException(status_code=400, detail="must provide image_base64, audio_base64, or hint")
cache_key = None
if req.use_cache and s.cache_enabled:
if req.image_base64:
try:
from captcha_solver.utils.image import decode_base64_image
cache_key = "img:" + perceptual_hash(decode_base64_image(req.image_base64))
except Exception:
cache_key = None
elif req.audio_base64:
try:
from captcha_solver.utils.image import decode_base64_audio
cache_key = "aud:" + audio_size_hash(decode_base64_audio(req.audio_base64))
except Exception:
cache_key = None
if cache_key:
hit = state["cache"].get(cache_key)
if hit is not None:
with state["_stats_lock"]:
state["stats"]["total"] += 1
state["stats"]["by_type"][captcha_type] = state["stats"]["by_type"].get(captcha_type, 0) + 1
state["stats"]["by_solver"][hit.solver] = state["stats"]["by_solver"].get(hit.solver, 0) + 1
state["stats"]["success"] += 1
state["stats"]["cache_hits"] += 1
return SolveResponse(
success=True,
answer=hit.answer,
confidence=hit.confidence,
solver=hit.solver,
elapsed_ms=int((time.time() - t0) * 1000),
cache_hit=True,
)
router_obj: SolverRouter = state["router"]
attempt = router_obj.solve(
captcha_type=captcha_type,
image_b64=req.image_base64,
audio_b64=req.audio_base64,
hint=req.hint,
)
elapsed_ms = int((time.time() - t0) * 1000)
success = bool(attempt.answer) and attempt.confidence > 0
with state["_stats_lock"]:
st = state["stats"]
st["total"] += 1
st["by_type"][captcha_type] = st["by_type"].get(captcha_type, 0) + 1
st["by_solver"][attempt.solver_name] = st["by_solver"].get(attempt.solver_name, 0) + 1
if success:
st["success"] += 1
st["elapsed_sum_ms"] += elapsed_ms
if success and cache_key and s.cache_enabled:
state["cache"].set(cache_key, attempt.answer, attempt.solver_name, attempt.confidence)
return SolveResponse(
success=success,
answer=attempt.answer or None,
confidence=attempt.confidence,
solver=attempt.solver_name,
elapsed_ms=elapsed_ms,
cache_hit=False,
attempts=len(attempt.metadata) + 1,
error=attempt.error,
)
@router.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
state = get_state()
engines = state["router"].engine_status() if state["router"] else {}
status = "ok"
if any(v == "not_loaded" for v in engines.values()):
status = "degraded"
if all(v == "not_loaded" for v in engines.values()):
status = "down"
return HealthResponse(
status=status,
version="0.1.0",
engines=engines,
uptime_s=time.time() - state["stats"]["started_at"],
)
@router.get("/stats", response_model=StatsResponse)
def stats() -> StatsResponse:
state = get_state()
st = state["stats"]
total = st["total"]
return StatsResponse(
total_requests=total,
by_type=dict(st["by_type"]),
by_solver=dict(st["by_solver"]),
success_rate=(st["success"] / total) if total else 0.0,
avg_elapsed_ms=(st["elapsed_sum_ms"] / total) if total else 0.0,
cache_hits=st["cache_hits"],
)
@router.get("/models", response_model=ModelsResponse)
def models() -> ModelsResponse:
state = get_state()
engines = state["router"].engine_status() if state["router"] else {}
loaded = [k for k, v in engines.items() if v == "loaded"]
available = [
{"name": "faster-whisper (tiny)", "size_mb": 75, "engine": "whisper", "purpose": "audio captcha"},
{"name": "faster-whisper (base)", "size_mb": 150, "engine": "whisper", "purpose": "audio captcha (better)"},
{"name": "Florence-2-base", "size_mb": 1200, "engine": "florence2", "purpose": "OCR + detection"},
{"name": "Moondream2", "size_mb": 1700, "engine": "moondream2", "purpose": "image grid VQA"},
{"name": "Qwen2.5-1.5B-Instruct", "size_mb": 1500, "engine": "qwen", "purpose": "math / text reasoning"},
{"name": "Qwen2-VL 7B (via ollama)", "size_mb": 4500, "engine": "ollama", "purpose": "best vision (optional)"},
]
return ModelsResponse(
loaded=loaded,
available=available,
ollama_enabled=engines.get("ollama") == "loaded",
)
@router.get("/cache/clear")
def clear_cache() -> dict:
state = get_state()
if state["cache"]:
state["cache"].clear()
return {"cleared": True}
@router.post("/vqa")
def visual_qa(req: dict) -> dict:
"""Visual Question Answering with Moondream2.
Request body:
image_base64: str - base64-encoded image
question: str - question about the image
Response:
answer: str - model's answer
confidence: float
solver: str - which model answered
"""
image_b64 = req.get("image_base64", "")
question = req.get("question", "")
if not image_b64:
raise HTTPException(status_code=400, detail="image_base64 is required")
if not question:
raise HTTPException(status_code=400, detail="question is required")
state = get_state()
router_obj = state["router"]
md = router_obj._engines.get("moondream2")
if not md:
raise HTTPException(status_code=503, detail="Moondream2 not loaded")
from captcha_solver.utils.image import decode_base64_image, image_to_pil
try:
img = image_to_pil(decode_base64_image(image_b64))
except Exception as e:
raise HTTPException(status_code=400, detail=f"bad image: {e}")
try:
answer = md.query(img, question)
return {"answer": answer, "confidence": 0.6, "solver": "moondream2.vqa"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# --- hCaptcha tile classification ---
@router.post("/classify")
def classify_tile(req: dict) -> dict:
"""Classify a single hCaptcha tile against an instruction.
Request body:
image_base64: str - base64-encoded tile image
instruction: str - hCaptcha instruction (e.g. "Find all items made by people")
Response:
match: bool - whether the tile matches the instruction
confidence: float - classification confidence
caption: str - Florence-2 caption of the tile
solver: str - which solver was used
"""
from captcha_solver.solvers.hcaptcha_solver import classify_tile as _classify
image_b64 = req.get("image_base64", "")
instruction = req.get("instruction", "")
if not image_b64:
raise HTTPException(status_code=400, detail="image_base64 is required")
if not instruction:
raise HTTPException(status_code=400, detail="instruction is required")
state = get_state()
router_obj = state["router"]
# Build SolveContext from router's engines
from captcha_solver.solvers.base import SolveContext
ctx = SolveContext(
whisper=router_obj._engines.get("whisper"),
florence=router_obj._engines.get("florence2"),
moondream=router_obj._engines.get("moondream2"),
qwen=router_obj._engines.get("qwen"),
ollama=router_obj._engines.get("ollama"),
)
return _classify(image_b64, instruction, ctx)
# --- Learning stats ---
try:
from captcha_solver.learning.db import LearningDB
_learning_db = LearningDB()
except ImportError:
_learning_db = None
@router.get("/stats")
def get_learning_stats() -> dict:
if not _learning_db:
return {"enabled": False, "error": "learning module not available"}
summary = _learning_db.summary()
rankings = _learning_db.get_solver_ranking()
return {
"enabled": True,
"summary": summary,
"rankings": rankings,
"recent_failures": _learning_db.get_recent_failures(5),
}