captchakraken 3.2.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/AGENTS.md +5 -9
  2. package/LICENSE +165 -145
  3. package/NOTICE +4 -4
  4. package/README.md +10 -11
  5. package/dist/cli-invocation.d.ts +6 -3
  6. package/dist/cli-invocation.js +26 -10
  7. package/dist/humanize.d.ts +37 -1
  8. package/dist/humanize.js +143 -9
  9. package/dist/index.d.ts +13 -12
  10. package/dist/index.js +25 -33
  11. package/dist/kinds.d.ts +15 -2
  12. package/dist/kinds.js +12 -4
  13. package/dist/playwright-types.d.ts +3 -0
  14. package/dist/selectors.d.ts +93 -11
  15. package/dist/selectors.js +226 -116
  16. package/dist/solver.d.ts +92 -12
  17. package/dist/solver.js +562 -203
  18. package/dist/types.d.ts +53 -8
  19. package/dist/verdicts.d.ts +3 -0
  20. package/dist/verdicts.js +6 -0
  21. package/package.json +4 -4
  22. package/python/AGENTS.md +7 -11
  23. package/python/LICENSE +165 -145
  24. package/python/NOTICE +4 -4
  25. package/python/README.md +10 -9
  26. package/python/pyproject.toml +27 -13
  27. package/python/src/captchakraken/__init__.py +43 -46
  28. package/python/src/captchakraken/action_types.py +32 -1
  29. package/python/src/captchakraken/captchakraken.config.json +416 -0
  30. package/python/src/captchakraken/cli.py +66 -30
  31. package/python/src/captchakraken/clipvideo.py +258 -0
  32. package/python/src/captchakraken/humanize.py +141 -10
  33. package/python/src/captchakraken/image_processor.py +3 -7
  34. package/python/src/captchakraken/keyframes.py +96 -14
  35. package/python/src/captchakraken/kinds.py +14 -2
  36. package/python/src/captchakraken/models.json +28 -3
  37. package/python/src/captchakraken/page_solver.py +595 -251
  38. package/python/src/captchakraken/planner.py +298 -47
  39. package/python/src/captchakraken/pointer/__init__.py +20 -0
  40. package/python/src/captchakraken/pointer/detector.py +333 -0
  41. package/python/src/captchakraken/pointer/elements.py +273 -0
  42. package/python/src/captchakraken/pointer/heads.py +163 -0
  43. package/python/src/captchakraken/pointer/layout.py +319 -0
  44. package/python/src/captchakraken/pointer/marks.py +184 -0
  45. package/python/src/captchakraken/pointer/server.py +146 -0
  46. package/python/src/captchakraken/pointer/service.py +553 -0
  47. package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
  48. package/python/src/captchakraken/prompts.py +111 -34
  49. package/python/src/captchakraken/selectors.py +221 -135
  50. package/python/src/captchakraken/server_manager.py +18 -0
  51. package/python/src/captchakraken/solver.py +169 -88
  52. package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
  53. package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
  54. package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
  55. package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
  56. package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
  57. package/python/src/captchakraken/verdicts.py +16 -0
  58. package/dist/slide-geometry.d.ts +0 -6
  59. package/dist/slide-geometry.js +0 -38
  60. package/python/src/captchakraken/tool_calls/track_piece.py +0 -120
@@ -0,0 +1,146 @@
1
+ """The /pointer HTTP endpoint: one board per request, generation 3's answer back.
2
+
3
+ python -m captchakraken.pointer.server --model /models/pointer-0.8b \\
4
+ --detector /models/detector_r18.pt --port 8001
5
+
6
+ POST /v1/pointer
7
+ {"image": "<base64 PNG/JPEG>"} a still, as captured
8
+ {"frames": ["<base64 JPEG>", ...], "fps": 5.0} a video, `clipvideo`-cut
9
+ {"marked": false} optional: skip the detector
10
+ {"avoid": [answer, ...]} optional: answers already refused on this
11
+ board; the likeliest other ones come back
12
+ -> {"actions": [{...}, {...}, {...}], the three likeliest distinct answers, best first
13
+ "answer": {...}, the first action, kept for older callers
14
+ "elements": [{"id", "class", "box", "score"}], "size": [W, H]}
15
+
16
+ `elements` are in the pixels of the board as the model saw it (`size`), numbered
17
+ as the answer's ids are. The client sends no prompt: the server owns it.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import base64
23
+ import io
24
+ import json
25
+ import os
26
+ import threading
27
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
28
+ from typing import Dict
29
+
30
+ import numpy as np
31
+ from PIL import Image
32
+
33
+ from . import layout as L
34
+
35
+ PATH = "/v1/pointer"
36
+ #: Refused answers one request may name: more than a solve's loops ever perform on one board.
37
+ MAX_AVOID = 32
38
+
39
+
40
+ def _image(b64: str) -> Image.Image:
41
+ return Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGB")
42
+
43
+
44
+ def parse(body: Dict) -> Dict:
45
+ """A request body -> PointerService.solve's arguments. Raises ValueError on a bad one."""
46
+ if ("image" in body) == ("frames" in body):
47
+ raise ValueError("send exactly one of image or frames")
48
+ marked = bool(body.get("marked", True))
49
+ avoid = body.get("avoid") or []
50
+ if not isinstance(avoid, list) or not all(isinstance(a, dict) for a in avoid):
51
+ raise ValueError("avoid is a list of earlier answers")
52
+ if len(avoid) > MAX_AVOID:
53
+ raise ValueError(f"{len(avoid)} answers to avoid; at most {MAX_AVOID}")
54
+ history = body.get("history") or []
55
+ if "image" in body:
56
+ if len(history) > L.MAX_HISTORY:
57
+ raise ValueError(f"{len(history)} earlier screens; at most {L.MAX_HISTORY}")
58
+ return {"image": _image(body["image"]), "marked": marked, "avoid": avoid,
59
+ "history": [_image(h) for h in history]}
60
+ if history:
61
+ raise ValueError("a video board carries no earlier screens")
62
+ from .. import clipvideo as CV
63
+ if abs(float(body.get("fps", CV.FPS)) - CV.FPS) > 1e-6:
64
+ raise ValueError(f"frames must be cut at {CV.FPS} fps by clipvideo, got {body.get('fps')}")
65
+ frames = [np.asarray(Image.open(io.BytesIO(base64.b64decode(f))).convert("RGB")) for f in body["frames"]]
66
+ if len(frames) < 2:
67
+ raise ValueError("a video needs at least two frames")
68
+ return {"frames": frames, "marked": False, "avoid": avoid}
69
+
70
+
71
+ def reply(result: Dict) -> Dict:
72
+ return {"actions": result.get("answers") or [result["answer"]],
73
+ "answer": result["answer"], "size": list(result["size"]),
74
+ "elements": [{"id": n, "class": e["class"], "box": [round(v, 1) for v in e["box"]],
75
+ "score": e["score"]} for n, e in enumerate(result["elements"], 1)]}
76
+
77
+
78
+ def make_handler(service):
79
+ lock = threading.Lock() # one engine; vLLM batches inside a call, not across threads
80
+
81
+ class Handler(BaseHTTPRequestHandler):
82
+ protocol_version = "HTTP/1.1"
83
+
84
+ def _send(self, code: int, obj: Dict) -> None:
85
+ raw = json.dumps(obj).encode()
86
+ self.send_response(code)
87
+ self.send_header("Content-Type", "application/json")
88
+ self.send_header("Content-Length", str(len(raw)))
89
+ self.end_headers()
90
+ self.wfile.write(raw)
91
+
92
+ def _authorized(self) -> bool:
93
+ key = os.getenv("VLLM_API_KEY", "")
94
+ return not key or self.headers.get("Authorization") == f"Bearer {key}"
95
+
96
+ def do_GET(self): # noqa: N802 — http.server's name
97
+ path = self.path.rstrip("/")
98
+ if path == "/health":
99
+ return self._send(200, {})
100
+ if not self._authorized():
101
+ return self._send(401, {"error": "Unauthorized"})
102
+ if path == "/v1/models":
103
+ model = os.getenv("CAPTCHA_LORA_NAME", "captcha-pointer")
104
+ return self._send(200, {"object": "list", "data": [
105
+ {"id": model, "object": "model", "created": 0, "owned_by": "captchakraken"},
106
+ ]})
107
+ return self._send(404, {"error": {"message": f"no route {self.path}"}})
108
+
109
+ def do_POST(self): # noqa: N802 — http.server's name
110
+ if self.path.rstrip("/") != PATH:
111
+ return self._send(404, {"error": {"message": f"no route {self.path}"}})
112
+ if not self._authorized():
113
+ return self._send(401, {"error": "Unauthorized"})
114
+ try:
115
+ body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)))
116
+ args = parse(body)
117
+ except (ValueError, KeyError, OSError) as e:
118
+ return self._send(400, {"error": {"message": str(e), "type": "invalid_request"}})
119
+ with lock:
120
+ result = service.solve(**args)
121
+ return self._send(200, reply(result))
122
+
123
+ def log_message(self, fmt, *args): # quiet: one line per request is the caller's to log
124
+ pass
125
+
126
+ return Handler
127
+
128
+
129
+ def main():
130
+ ap = argparse.ArgumentParser()
131
+ ap.add_argument("--model", required=True, help="an exported pointer checkpoint")
132
+ ap.add_argument("--detector", default=None, help="the element detector; without it every board goes plain")
133
+ ap.add_argument("--letters", default=None,
134
+ help="the letter detector: a typed code is the one it and the model read together")
135
+ ap.add_argument("--host", default="127.0.0.1")
136
+ ap.add_argument("--port", type=int, default=8001)
137
+ ap.add_argument("--gpu-memory-utilization", type=float, default=0.45)
138
+ a = ap.parse_args()
139
+ from .service import PointerService
140
+ svc = PointerService(a.model, detector=a.detector, letters=a.letters,
141
+ gpu_memory_utilization=a.gpu_memory_utilization)
142
+ ThreadingHTTPServer((a.host, a.port), make_handler(svc)).serve_forever()
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()