File size: 8,527 Bytes
7b84802
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
"""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)