"""Text-OCR captcha solver. For distorted-letter CAPTCHAs (e.g. 'g4K9P'). Strategy: 1. Florence-2 with 2. Qwen2.5 LLM cleanup (remove spaces, fix common OCR confusions) 3. Ollama upgrade if enabled """ from __future__ import annotations 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 class TextOcrSolver(BaseSolver): name = "text_ocr" captcha_type = "text_ocr" def __init__(self, ctx) -> None: super().__init__(ctx) self._img = None self._raw_ocr: str = "" def prepare(self, image_b64: Optional[str], audio_b64: Optional[str], hint: Optional[str]) -> None: if not image_b64: self._img = None return try: data = decode_base64_image(image_b64) self._img = image_to_pil(data) except Exception as exc: self._img = None self._last_error = f"decode: {exc}" def attempts(self): return [ self._florence_only, self._florence_plus_llm, self._ollama_if_enabled, ] def _florence_only(self) -> SolveAttempt: if self._img is None: return SolveAttempt(answer="", confidence=0.0, solver_name="text_ocr.florence", error="no image") try: text = self.ctx.florence.ocr(self._img, task="") except Exception as exc: return SolveAttempt(answer="", confidence=0.0, solver_name="text_ocr.florence", error=str(exc)) cleaned = _clean_captcha_text(text) self._raw_ocr = cleaned if not cleaned: return SolveAttempt(answer="", confidence=0.0, solver_name="text_ocr.florence", error="empty OCR") if _looks_like_captcha(cleaned): return SolveAttempt(answer=cleaned, confidence=0.7, solver_name="text_ocr.florence") return SolveAttempt(answer="", confidence=0.2, solver_name="text_ocr.florence", error="not captcha-like") def _florence_plus_llm(self) -> SolveAttempt: if self._img is None: return SolveAttempt(answer="", confidence=0.0, solver_name="text_ocr.florence_llm", error="no image") try: raw = self.ctx.florence.ocr(self._img, task="") except Exception as exc: return SolveAttempt(answer="", confidence=0.0, solver_name="text_ocr.florence_llm", error=str(exc)) try: cleaned = self.ctx.qwen.generate( f"You see this OCR output from a CAPTCHA image: '{raw}'\n" f"Return ONLY the corrected CAPTCHA text. Remove spaces. " f"Fix common OCR errors (0/O, 1/l/I, 5/S, 2/Z). " f"No commentary, no quotes.", max_new_tokens=20, ) except Exception as exc: return SolveAttempt(answer="", confidence=0.0, solver_name="text_ocr.florence_llm", error=str(exc)) cleaned = _clean_captcha_text(cleaned) if not cleaned: return SolveAttempt(answer="", confidence=0.0, solver_name="text_ocr.florence_llm", error="llm empty") return SolveAttempt(answer=cleaned, confidence=0.78, solver_name="text_ocr.florence_llm") def _ollama_if_enabled(self) -> SolveAttempt: if not self.ctx.ollama.enabled or self._img is None: return SolveAttempt(answer="", confidence=0.0, solver_name="text_ocr.ollama", error="ollama disabled") try: text = self.ctx.ollama.describe_image( self._img, "Read the CAPTCHA text exactly as shown. Reply with ONLY the text, no spaces." ) except Exception as exc: return SolveAttempt(answer="", confidence=0.0, solver_name="text_ocr.ollama", error=str(exc)) cleaned = _clean_captcha_text(text) if not cleaned: return SolveAttempt(answer="", confidence=0.0, solver_name="text_ocr.ollama", error="ollama empty") return SolveAttempt(answer=cleaned, confidence=0.85, solver_name="text_ocr.ollama") def _clean_captcha_text(s: str) -> str: s = s.strip() s = s.replace("\n", "").replace("\r", "").replace(" ", "").replace("\t", "") return s def _looks_like_captcha(s: str) -> bool: if not s or len(s) > 12: return False return bool(re.match(r"^[A-Za-z0-9]{3,8}$", s))