diff --git a/.gitignore b/.gitignore index 52eecfc..f3183b4 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ cases/_session_archives/ __pycache__/ *.py[cod] *$py.class + +# 验证码原始与中间数据集(防止大文件污染 git) +tcaptcha/data/raw/ +tcaptcha/data/crops/ +data/ diff --git a/pyproject.toml b/pyproject.toml index 70e86ca..6aa71ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,5 +4,6 @@ version = "0.1.0" requires-python = ">=3.12" dependencies = [ "httpx>=0.28.1", + "numpy>=2.5.3", "pillow>=12.3.0", ] diff --git a/tcaptcha/collector.py b/tcaptcha/collector.py new file mode 100644 index 0000000..0122b30 --- /dev/null +++ b/tcaptcha/collector.py @@ -0,0 +1,242 @@ +""" +腾讯天御点选验证码 (Tencent Turing Captcha) 样本纯协议采集器 +通过模拟前端两步 HTTP 接口直接拉取高清原图: +1. GET /cap_union_prehandle 获取初始会话 sess +2. POST /cap_union_new_getsig 获取题目配置与背景/提示图 URL +3. 并行下载 672x480 背景大图与 170x50 提示图标图 +""" + +import argparse +import asyncio +import json +import logging +import os +import random +import re +import time +from pathlib import Path +from typing import Optional, Tuple + +import httpx + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger("tcaptcha_collector") + + +class TencentCaptchaCollector: + BASE_URL = "https://turing.captcha.qcloud.com" + DEFAULT_AID = "2039519451" + + def __init__(self, aid: str = DEFAULT_AID, output_dir: str = "tcaptcha/data/raw", timeout: float = 10.0): + self.aid = aid + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + self.index_file = self.output_dir / "index.jsonl" + self.timeout = timeout + + self.client = httpx.Client( + base_url=self.BASE_URL, + trust_env=False, + headers={ + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36" + ), + "Referer": "https://turing.captcha.qcloud.com/", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + }, + timeout=self.timeout, + follow_redirects=True, + ) + + def _fetch_session(self) -> Tuple[Optional[str], Optional[str]]: + """第一步:预检请求获取 sess""" + cb_name = f"_aq_{random.randint(100000, 999999)}" + tkid = str(random.randint(100000000, 999999999)) + ua_b64 = "TW96aWxsYS81LjAgKE1hY2ludG9zaDsgSW50ZWwgTWFjIE9TIFggMTBfMTVfNykgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzE1Mi4wLjAuMCBTYWZhcmkvNTM3LjM2" + + params = { + "aid": self.aid, + "protocol": "https", + "accver": "1", + "showtype": "popup", + "ua": ua_b64, + "noheader": "1", + "fb": "1", + "grayscale": "1", + "clientype": "2", + "lang": "zh-cn", + "js": "/tcaptcha-frame.ba387dd1.js", + "wb": "2", + "tkid": tkid, + "subsid": "1", + "callback": cb_name, + } + + resp = self.client.get("/cap_union_prehandle", params=params) + if resp.status_code != 200: + logger.error("prehandle failed with status %d", resp.status_code) + return None, None + + match = re.search(rf"{cb_name}\((.*)\)", resp.text) + if not match: + logger.error("Failed to parse prehandle response: %s", resp.text[:100]) + return None, None + + try: + data = json.loads(match.group(1)) + sess = data.get("sess") + return sess, tkid + except Exception as e: + logger.error("Failed to decode JSON from prehandle: %s", e) + return None, None + + def _fetch_sig_and_images(self, sess: str, tkid: str) -> Optional[dict]: + """第二步:请求题目信息并下载图片对""" + resp = self.client.post( + "/cap_union_new_getsig", + data={"sess": sess, "tkid": tkid}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + if resp.status_code != 200: + logger.error("getsig failed with status %d", resp.status_code) + return None + + try: + res_json = resp.json() + except Exception as e: + logger.error("Failed to decode getsig JSON: %s", e) + return None + + if str(res_json.get("ret")) != "0": + logger.warning("getsig returned non-zero ret: %s", res_json) + return None + + cfg = res_json.get("data", {}) + instruction = cfg.get("instruction", "请依次点击:") + bg_cfg = cfg.get("bg_elem_cfg", {}) + img_url_bg = bg_cfg.get("img_url", "") + + if not img_url_bg: + logger.warning("No img_url found in getsig response") + return None + + img_url_prompt = img_url_bg.replace("img_index=1", "img_index=0") + + # 下载背景大图 (672x480) 与 提示图 (170x50) + bg_resp = self.client.get(img_url_bg) + prompt_resp = self.client.get(img_url_prompt) + + if bg_resp.status_code != 200 or prompt_resp.status_code != 200: + logger.error("Failed to download image pairs") + return None + + return { + "instruction": instruction, + "bg_bytes": bg_resp.content, + "prompt_bytes": prompt_resp.content, + "img_url_bg": img_url_bg, + "img_url_prompt": img_url_prompt, + "sess": sess, + "tkid": tkid, + } + + def fetch_one(self) -> Optional[str]: + """采集单组样本并保存到本地""" + sess, tkid = self._fetch_session() + if not sess or not tkid: + return None + + data = self._fetch_sig_and_images(sess, tkid) + if not data: + return None + + sample_id = f"{int(time.time() * 1000)}_{random.randint(1000, 9999)}" + bg_filename = f"{sample_id}_bg.png" + prompt_filename = f"{sample_id}_prompt.png" + meta_filename = f"{sample_id}_meta.json" + + bg_path = self.output_dir / bg_filename + prompt_path = self.output_dir / prompt_filename + meta_path = self.output_dir / meta_filename + + bg_path.write_bytes(data["bg_bytes"]) + prompt_path.write_bytes(data["prompt_bytes"]) + + meta_info = { + "sample_id": sample_id, + "timestamp": time.time(), + "aid": self.aid, + "instruction": data["instruction"], + "bg_file": bg_filename, + "prompt_file": prompt_filename, + "bg_size": len(data["bg_bytes"]), + "prompt_size": len(data["prompt_bytes"]), + "sess": data["sess"], + "tkid": data["tkid"], + } + + meta_path.write_text(json.dumps(meta_info, ensure_ascii=False, indent=2), encoding="utf-8") + + # 写入 index.jsonl + with open(self.index_file, "a", encoding="utf-8") as f: + f.write(json.dumps(meta_info, ensure_ascii=False) + "\n") + + logger.info( + "✓ 采集成功 [%s]: 大图 %d KB, 提示图 %d KB | 提示: %s", + sample_id, + len(data["bg_bytes"]) // 1024, + len(data["prompt_bytes"]) // 1024, + data["instruction"], + ) + return sample_id + + def collect_batch(self, count: int = 10, delay: float = 0.8) -> int: + """批量采集样本""" + logger.info("开始批量采集点选验证码样本,目标数量: %d, 间隔: %.2fs", count, delay) + success_count = 0 + + for i in range(count): + try: + sid = self.fetch_one() + if sid: + success_count += 1 + else: + logger.warning("第 %d/%d 个样本采集失败,准备重试", i + 1, count) + except Exception as e: + logger.error("采集异常: %s", e) + + if i < count - 1 and delay > 0: + # 随机微弱浮动,模拟正常采集 + actual_delay = delay + random.uniform(-0.2, 0.3) + time.sleep(max(0.2, actual_delay)) + + logger.info("批量采集完成: 成功 %d/%d (存储目录: %s)", success_count, count, self.output_dir) + return success_count + + def close(self): + self.client.close() + + +def main(): + parser = argparse.ArgumentParser(description="腾讯天御点选验证码纯协议批量采集器") + parser.add_argument("--count", type=int, default=10, help="采集样本数量 (默认: 10)") + parser.add_argument("--delay", type=float, default=0.8, help="每次请求间隔秒数 (默认: 0.8)") + parser.add_argument("--aid", type=str, default=TencentCaptchaCollector.DEFAULT_AID, help="业务 AppID") + parser.add_argument("--output-dir", type=str, default="tcaptcha/data/raw", help="保存路径") + + args = parser.parse_args() + collector = TencentCaptchaCollector(aid=args.aid, output_dir=args.output_dir) + try: + collector.collect_batch(count=args.count, delay=args.delay) + finally: + collector.close() + + +if __name__ == "__main__": + main() diff --git a/tcaptcha/slicer.py b/tcaptcha/slicer.py new file mode 100644 index 0000000..f410e0c --- /dev/null +++ b/tcaptcha/slicer.py @@ -0,0 +1,86 @@ +""" +提示词图像自动切割模块 +将 170x50 的提示图自动切分为 3 个独立的图标小图 +""" + +from pathlib import Path +from typing import List, Tuple +import numpy as np +from PIL import Image + + +def slice_prompt_image(prompt_img_path: str | Path, padding: int = 2) -> List[Tuple[Image.Image, Tuple[int, int, int, int]]]: + """ + 自动识别并切割提示图中的 3 个字符/图标 + 返回: [(PIL_Image, (x1, y1, x2, y2)), ...] + """ + img = Image.open(prompt_img_path).convert("L") + arr = np.array(img) + h, w = arr.shape + + # 背景灰度基准 (通常取左上角像素) + bg_val = int(arr[0, 0]) + + # 提取暗色笔画像素(排除灰色背景) + mask = arr < (bg_val - 25) + + # 水平方向投影,找出连续包含笔画的列区间 + cols_has_dark = mask.any(axis=0) + + segments: List[Tuple[int, int]] = [] + in_seg = False + start = 0 + + for i, val in enumerate(cols_has_dark): + if val and not in_seg: + in_seg = True + start = i + elif not val and in_seg: + in_seg = False + if i - start >= 5: # 过滤过窄杂点 + segments.append((start, i)) + if in_seg and (w - start >= 5): + segments.append((start, w)) + + # 若投影分割不足 3 段(极罕见),按固定 3 等分兜底 + if len(segments) != 3: + step = w // 3 + segments = [(i * step + 5, (i + 1) * step - 5) for i in range(3)] + + # 原始彩色图用于切片 + orig_img = Image.open(prompt_img_path).convert("RGBA") + crops = [] + + for seg_start, seg_end in segments[:3]: + # 纵向精确裁剪 + sub_mask = mask[:, seg_start:seg_end] + rows_has_dark = sub_mask.any(axis=1) + row_indices = np.where(rows_has_dark)[0] + + if len(row_indices) > 0: + top = max(0, int(row_indices[0]) - padding) + bottom = min(h, int(row_indices[-1]) + 1 + padding) + else: + top, bottom = 0, h + + left = max(0, seg_start - padding) + right = min(w, seg_end + padding) + + box = (left, top, right, bottom) + crop = orig_img.crop(box) + crops.append((crop, box)) + + return crops + + +if __name__ == "__main__": + import sys + + sample = sys.argv[1] if len(sys.argv) > 1 else "tcaptcha/data/raw/1789135559041_4170_prompt.png" + if Path(sample).exists(): + results = slice_prompt_image(sample) + print(f"切片完成,切出 {len(results)} 个目标:") + for idx, (c, b) in enumerate(results): + print(f" [{idx+1}] 尺寸: {c.size}, 边界框: {b}") + else: + print(f"样本文件不存在: {sample}") diff --git a/tcaptcha/web_labeler.py b/tcaptcha/web_labeler.py new file mode 100644 index 0000000..c6684f2 --- /dev/null +++ b/tcaptcha/web_labeler.py @@ -0,0 +1,493 @@ +""" +极简本地 Web 点选验证码打标工具 +启动方式: uv run python tcaptcha/web_labeler.py --port 8765 +访问地址: http://127.0.0.1:8765 +""" + +import argparse +import base64 +import json +import logging +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from io import BytesIO +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +from PIL import Image + +try: + from tcaptcha.slicer import slice_prompt_image +except ImportError: + from slicer import slice_prompt_image + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger("web_labeler") + +RAW_DIR = Path("tcaptcha/data/raw") +LABEL_FILE = Path("tcaptcha/data/labels.jsonl") +CROPS_DIR = Path("tcaptcha/data/crops") + + +def get_labeled_sample_ids() -> set: + if not LABEL_FILE.exists(): + return set() + labeled = set() + with open(LABEL_FILE, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + try: + record = json.loads(line) + labeled.add(record.get("sample_id")) + except Exception: + pass + return labeled + + +def get_all_samples() -> list: + if not RAW_DIR.exists(): + return [] + meta_files = sorted(RAW_DIR.glob("*_meta.json")) + samples = [] + for mf in meta_files: + try: + meta = json.loads(mf.read_text(encoding="utf-8")) + samples.append(meta) + except Exception: + pass + return samples + + +def get_sample_data(sample_id: str): + bg_path = RAW_DIR / f"{sample_id}_bg.png" + prompt_path = RAW_DIR / f"{sample_id}_prompt.png" + meta_path = RAW_DIR / f"{sample_id}_meta.json" + + if not bg_path.exists() or not prompt_path.exists() or not meta_path.exists(): + return None + + meta = json.loads(meta_path.read_text(encoding="utf-8")) + + # 切割提示图标 + crops = slice_prompt_image(prompt_path) + sliced_b64 = [] + for idx, (crop_img, _) in enumerate(crops): + buf = BytesIO() + crop_img.save(buf, format="PNG") + b64 = base64.b64encode(buf.getvalue()).decode("utf-8") + sliced_b64.append(f"data:image/png;base64,{b64}") + + bg_b64 = base64.b64encode(bg_path.read_bytes()).decode("utf-8") + prompt_b64 = base64.b64encode(prompt_path.read_bytes()).decode("utf-8") + + return { + "sample_id": sample_id, + "instruction": meta.get("instruction", "请依次点击:"), + "bg_b64": f"data:image/png;base64,{bg_b64}", + "prompt_b64": f"data:image/png;base64,{prompt_b64}", + "sliced_prompts": sliced_b64, + } + + +HTML_TEMPLATE = """ + + + + +腾讯天御点选验证码 - 标注工作台 + + + + +
+
🎯 腾讯天御点选验证码 · 标注工具
+
加载中...
+
+ +
+
+
请依次点击:
+
+
+ +
+ +
+ +
+
已标记: 0 / 3 点
+
+ + + + +
+
+
快捷键: 点击大图标记坐标 | [Z] 撤销 | [R] 重置 | [S] 跳过 | [Enter/Space] 保存
+
+ + + + +""" + + +class LabelHandler(BaseHTTPRequestHandler): + def do_GET(self): + url = urlparse(self.path) + if url.path in ["/", "/index.html"]: + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(HTML_TEMPLATE.encode("utf-8")) + return + + if url.path == "/api/next": + all_samples = get_all_samples() + labeled_ids = get_labeled_sample_ids() + + unlabeled = [s for s in all_samples if s.get("sample_id") not in labeled_ids] + if not unlabeled: + resp_data = {"finished": True, "stats": {"total": len(all_samples), "labeled": len(labeled_ids)}} + else: + next_sample = unlabeled[0] + sample_data = get_sample_data(next_sample["sample_id"]) + resp_data = { + "finished": False, + "sample": sample_data, + "stats": {"total": len(all_samples), "labeled": len(labeled_ids)}, + } + + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.end_headers() + self.wfile.write(json.dumps(resp_data).encode("utf-8")) + return + + self.send_response(404) + self.end_headers() + + def do_POST(self): + url = urlparse(self.path) + if url.path == "/api/save": + content_len = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_len).decode("utf-8") + data = json.loads(body) + sample_id = data.get("sample_id") + points = data.get("points", []) + + if not sample_id or len(points) != 3: + self.send_response(400) + self.end_headers() + return + + LABEL_FILE.parent.mkdir(parents=True, exist_ok=True) + CROPS_DIR.mkdir(parents=True, exist_ok=True) + + # 保存切片作为正样本 + bg_path = RAW_DIR / f"{sample_id}_bg.png" + if bg_path.exists(): + bg_img = Image.open(bg_path).convert("RGBA") + for i, pt in enumerate(points): + cx, cy = pt["x"], pt["y"] + # 裁剪 54x54 小图块 + crop_box = (max(0, cx - 27), max(0, cy - 27), min(672, cx + 27), min(480, cy + 27)) + crop_patch = bg_img.crop(crop_box) + crop_patch.save(CROPS_DIR / f"{sample_id}_target_{i+1}.png") + + # 写入 labels.jsonl + record = { + "sample_id": sample_id, + "points": points, + "ans_format": [{"elem_id": i + 1, "type": "DynAnswerType_POS", "data": f"{p['x']},{p['y']}"} for i, p in enumerate(points)], + } + with open(LABEL_FILE, "a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + logger.info("✓ 成功保存样本标注: %s -> %s", sample_id, points) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"status":"ok"}') + return + + self.send_response(404) + self.end_headers() + + +def main(): + parser = argparse.ArgumentParser(description="腾讯点选验证码轻量 Web 打标工具") + parser.add_argument("--port", type=int, default=8765, help="本地服务端口 (默认: 8765)") + args = parser.parse_args() + + server = ThreadingHTTPServer(("127.0.0.1", args.port), LabelHandler) + logger.info("🚀 打标工作台已启动: http://127.0.0.1:%d", args.port) + logger.info("在浏览器打开上述地址即可开始打标,按 Ctrl+C 停止") + try: + server.serve_forever() + except KeyboardInterrupt: + logger.info("打标服务已停止") + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 72e201d..8b921bc 100644 --- a/uv.lock +++ b/uv.lock @@ -39,12 +39,14 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "httpx" }, + { name = "numpy" }, { name = "pillow" }, ] [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, + { name = "numpy", specifier = ">=2.5.3" }, { name = "pillow", specifier = ">=12.3.0" }, ] @@ -85,6 +87,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + [[package]] name = "pillow" version = "12.3.0"