Spaces:
Sleeping
Sleeping
| """Math captcha solver. | |
| Strategy order (fast -> slow, accurate -> less): | |
| 1. Pure-Python regex on OCR output (sub-millisecond) | |
| 2. Tiny LLM (Qwen2.5 1.5B) for cleanup if OCR returns garbled math | |
| 3. Ollama text model if enabled | |
| For the OCR step we use Florence-2 (or fall back to Tesseract if | |
| available). The math string is then evaluated safely. | |
| """ | |
| from __future__ import annotations | |
| import ast | |
| import operator | |
| import re | |
| from typing import Optional | |
| from captcha_solver.solvers.base import BaseSolver, SolveAttempt | |
| from captcha_solver.utils.image import decode_base64_image, image_to_pil | |
| _SAFE_OPS = { | |
| ast.Add: operator.add, | |
| ast.Sub: operator.sub, | |
| ast.Mult: operator.mul, | |
| ast.Div: operator.truediv, | |
| ast.Mod: operator.mod, | |
| ast.Pow: operator.pow, | |
| ast.USub: operator.neg, | |
| ast.UAdd: operator.pos, | |
| } | |
| def _safe_eval(expr: str) -> Optional[float]: | |
| """Evaluate a math expression with only + - * / % ** and parens.""" | |
| if not expr or not re.match(r"^[\d\s+\-*/%().]+$", expr): | |
| return None | |
| try: | |
| tree = ast.parse(expr, mode="eval") | |
| return _eval_node(tree.body) | |
| except Exception: | |
| return None | |
| def _eval_node(node): | |
| if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): | |
| return node.value | |
| if isinstance(node, ast.BinOp): | |
| op = _SAFE_OPS.get(type(node.op)) | |
| if op is None: | |
| raise ValueError("op not allowed") | |
| return op(_eval_node(node.left), _eval_node(node.right)) | |
| if isinstance(node, ast.UnaryOp): | |
| op = _SAFE_OPS.get(type(node.op)) | |
| if op is None: | |
| raise ValueError("op not allowed") | |
| return op(_eval_node(node.operand)) | |
| raise ValueError("unsupported") | |
| _WORD_TO_NUM = { | |
| "zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, | |
| "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, | |
| "ten": 10, "eleven": 11, "twelve": 12, "thirteen": 13, | |
| "fourteen": 14, "fifteen": 15, "sixteen": 16, "seventeen": 17, | |
| "eighteen": 18, "nineteen": 19, "twenty": 20, "thirty": 30, | |
| "forty": 40, "fifty": 50, "hundred": 100, | |
| } | |
| def _words_to_expr(text: str) -> str: | |
| """Convert 'three plus five' -> '3+5'.""" | |
| s = text.lower() | |
| s = s.replace("plus", "+").replace("add", "+").replace("+", " + ") | |
| s = s.replace("minus", "-").replace("subtract", "-") | |
| s = s.replace("times", "*").replace("multiplied by", "*").replace("x", "*") | |
| s = s.replace("divided by", "/").replace("over", "/") | |
| s = s.replace("equals", "=").replace("is", "") | |
| tokens: list[str] = [] | |
| for w in re.findall(r"[a-z]+|\d+|[+\-*/()]", s): | |
| if w in _WORD_TO_NUM: | |
| tokens.append(str(_WORD_TO_NUM[w])) | |
| else: | |
| tokens.append(w) | |
| return " ".join(tokens) | |
| def _normalize_ocr_math(s: str) -> str: | |
| s = s.strip() | |
| s = s.replace("×", "*").replace("x", "*").replace("X", "*") | |
| s = s.replace("÷", "/").replace("−", "-") | |
| # Strip trailing "=..." (handles "=?", "= 7", "=42", "= ?") | |
| s = re.sub(r"=.*$", "", s) | |
| # Strip OCR garbage that looks like a single trailing digit (often "?" read as 6/2/7) | |
| # Only strip if it's a single digit at the very end AND not part of an expression | |
| s_no_space = s.replace(" ", "") | |
| # If the result still ends with a lone digit that doesn't follow an operator, leave it | |
| # (e.g. "3+5=2" -> "3+5", but "12+8=?" -> "12+8" since ? is stripped first) | |
| s = s.replace("?", "") | |
| s = s.replace(" ", "") | |
| return s | |
| def _extract_math_expression(s: str) -> Optional[str]: | |
| s = _normalize_ocr_math(s) | |
| # Try to find a chain of at least one operator between numbers | |
| m = re.search(r"(-?\d+(?:\.\d+)?\s*[+\-*/%]\s*-?\d+(?:\.\d+)?(?:\s*[+\-*/%]\s*-?\d+(?:\.\d+)?)*)", s) | |
| if m: | |
| return m.group(1) | |
| return None | |
| class MathSolver(BaseSolver): | |
| name = "math" | |
| captcha_type = "math" | |
| def attempts(self): | |
| return [ | |
| self._solve_regex, | |
| self._solve_tesseract_fallback, | |
| self._solve_ollama_if_enabled, | |
| ] | |
| def _solve_regex(self) -> SolveAttempt: | |
| """Pure regex path: try to extract and eval the math string from the raw input. | |
| Note: when the input is an image, we need OCR first. We delegate to | |
| a small helper that uses Florence-2 and a cleanup LLM if needed. | |
| """ | |
| raw = self._raw_text | |
| if not raw: | |
| return SolveAttempt(answer="", confidence=0.0, solver_name="math.regex", error="no text input") | |
| for src in (raw, _words_to_expr(raw)): | |
| expr = _extract_math_expression(src) | |
| if not expr: | |
| continue | |
| val = _safe_eval(expr) | |
| if val is None: | |
| continue | |
| conf = 0.92 if src is raw else 0.8 | |
| return SolveAttempt(answer=_format_int(val), confidence=conf, solver_name="math.regex") | |
| return SolveAttempt(answer="", confidence=0.0, solver_name="math.regex", error="no math found") | |
| def _solve_tesseract_fallback(self) -> SolveAttempt: | |
| """Use Tesseract OCR when Florence-2 isn't available. | |
| Useful when HF models can't be downloaded (expired token, offline). | |
| """ | |
| if not self._pil_image: | |
| return SolveAttempt(answer="", confidence=0.0, solver_name="math.tesseract", error="no image") | |
| try: | |
| from captcha_solver.utils.tesseract_ocr import ocr_captcha_math | |
| text = ocr_captcha_math(self._pil_image) | |
| except Exception as exc: | |
| return SolveAttempt(answer="", confidence=0.0, solver_name="math.tesseract", error=str(exc)) | |
| if not text: | |
| return SolveAttempt(answer="", confidence=0.0, solver_name="math.tesseract", error="empty OCR") | |
| expr = _extract_math_expression(text) | |
| if not expr: | |
| return SolveAttempt(answer="", confidence=0.0, solver_name="math.tesseract", error=f"no math in: {text!r}") | |
| val = _safe_eval(expr) | |
| if val is None: | |
| return SolveAttempt(answer="", confidence=0.0, solver_name="math.tesseract", error=f"eval failed: {expr}") | |
| return SolveAttempt(answer=_format_int(val), confidence=0.6, solver_name="math.tesseract") | |
| def _solve_ollama_if_enabled(self) -> SolveAttempt: | |
| if not self.ctx.ollama.enabled: | |
| return SolveAttempt(answer="", confidence=0.0, solver_name="math.ollama", error="ollama disabled") | |
| if not self._raw_text: | |
| return SolveAttempt(answer="", confidence=0.0, solver_name="math.ollama", error="no text") | |
| out = self.ctx.ollama.generate_text( | |
| f"Extract and solve the math expression. Return ONLY the final integer answer.\nExpression: {self._raw_text}", | |
| system="You solve simple arithmetic. Reply with one integer, nothing else.", | |
| max_tokens=8, | |
| ) | |
| m = re.search(r"-?\d+", out) | |
| if not m: | |
| return SolveAttempt(answer="", confidence=0.0, solver_name="math.ollama", error="no number in output") | |
| return SolveAttempt(answer=m.group(0), confidence=0.88, solver_name="math.ollama") | |
| def prepare(self, image_b64: Optional[str], audio_b64: Optional[str], hint: Optional[str]) -> None: | |
| """OCR the image to text (or accept a pre-OCR'd hint). | |
| Stores the result in self._raw_text and keeps the PIL image in | |
| self._pil_image for the tesseract fallback. | |
| """ | |
| self._pil_image = None | |
| if hint: | |
| self._raw_text = hint | |
| return | |
| if not image_b64: | |
| self._raw_text = "" | |
| return | |
| try: | |
| data = decode_base64_image(image_b64) | |
| img = image_to_pil(data) | |
| self._pil_image = img | |
| except Exception as exc: | |
| self._raw_text = "" | |
| self._last_error = f"decode: {exc}" | |
| return | |
| try: | |
| raw = self.ctx.florence.ocr(img, task="<OCR>") | |
| self._raw_text = _clean_ocr_text(raw) | |
| except Exception as exc: | |
| self._raw_text = "" | |
| self._last_error = f"florence: {exc}" | |
| def __init__(self, ctx) -> None: | |
| super().__init__(ctx) | |
| self._raw_text: str = "" | |
| self._pil_image = None | |
| self._last_error: str = "" | |
| def _clean_ocr_text(s: str) -> str: | |
| s = s.strip() | |
| s = s.replace("\n", " ").replace("\r", " ") | |
| s = re.sub(r"\s+", " ", s) | |
| return s | |
| def _format_int(v: float) -> str: | |
| if abs(v - round(v)) < 1e-9: | |
| return str(int(round(v))) | |
| return ("%g" % v) | |