| |
| """用 pie-xian command-a-vision 批量识别验证码:3x放大 + 每张2次读取(不一致补第3次) + 多数投票。 |
| 用法: label_captchas.py <图片目录> <输出.json> [workers] |
| 输出: {filename: {label, votes:[...], count, agreed}} |
| """ |
| import base64 |
| import concurrent.futures |
| import glob |
| import json |
| import os |
| import re |
| import ssl |
| import sys |
| import tempfile |
| import urllib.error |
| import urllib.request |
| from collections import Counter |
|
|
| from PIL import Image |
|
|
| API = "https://api.pie-xian.com/v1/chat/completions" |
| KEY = os.environ.get("PIE_XIAN_API_KEY", "sk-Rel8iERHrmu9NDbvQhnhwTowEUnGqVvCFXMatASH8q5O4gGP") |
| MODEL = "command-a-vision-07-2025" |
| UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36" |
| PROMPT = "识别这张验证码图片中的字符。只输出字符本身,不要任何解释、标点或额外文字。这是登录验证码,可能包含1-4个数字。" |
|
|
| _CTX = ssl.create_default_context() |
| _CTX.check_hostname = False |
| _CTX.verify_mode = ssl.CERT_NONE |
|
|
|
|
| def call_vision(big_path): |
| b64 = base64.b64encode(open(big_path, "rb").read()).decode() |
| body = json.dumps({ |
| "model": MODEL, |
| "messages": [{"role": "user", "content": [ |
| {"type": "text", "text": PROMPT}, |
| {"type": "image_url", "image_url": {"url": "data:image/png;base64," + b64}}, |
| ]}], |
| "max_tokens": 20, "temperature": 0, |
| }).encode() |
| req = urllib.request.Request(API, data=body, headers={ |
| "Authorization": "Bearer " + KEY, "Content-Type": "application/json", "User-Agent": UA}) |
| d = json.loads(urllib.request.urlopen(req, timeout=90, context=_CTX).read()) |
| code = re.sub(r"[^0-9A-Za-z]", "", d["choices"][0]["message"]["content"]) |
| if not code: |
| raise RuntimeError("empty output") |
| return code |
|
|
|
|
| def make_big(src, bigdir): |
| name = os.path.basename(src) |
| dst = os.path.join(bigdir, name) |
| im = Image.open(src).convert("RGB") |
| im = im.resize((im.width * 3, im.height * 3), Image.LANCZOS) |
| im.save(dst) |
| return dst |
|
|
|
|
| def label_one(big): |
| votes = [] |
| for _ in range(2): |
| for attempt in range(3): |
| try: |
| votes.append(call_vision(big)) |
| break |
| except Exception: |
| continue |
| c = Counter(votes) |
| if len(votes) >= 2 and c.most_common(1)[0][1] >= 2: |
| label, n = c.most_common(1)[0] |
| return {"label": label, "votes": votes, "count": n, "agreed": True} |
| for attempt in range(3): |
| try: |
| votes.append(call_vision(big)) |
| break |
| except Exception: |
| continue |
| if not votes: |
| return {"label": None, "votes": [], "count": 0, "agreed": False, "error": "all reads failed"} |
| c = Counter(votes) |
| label, n = c.most_common(1)[0] |
| return {"label": label, "votes": votes, "count": n, "agreed": n == len(votes)} |
|
|
|
|
| def main(): |
| d = sys.argv[1] |
| out = sys.argv[2] |
| workers = int(sys.argv[3]) if len(sys.argv) > 3 else 8 |
| with tempfile.TemporaryDirectory(prefix="sp_big_") as bigdir: |
| files = sorted(glob.glob(os.path.join(d, "*.png"))) |
| print(f"images={len(files)} workers={workers}", flush=True) |
| bigs = {f: make_big(f, bigdir) for f in files} |
| results = {} |
| with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex: |
| futs = {ex.submit(label_one, bigs[f]): f for f in files} |
| done = 0 |
| for fut in concurrent.futures.as_completed(futs): |
| f = futs[fut] |
| try: |
| results[os.path.basename(f)] = fut.result() |
| except Exception as e: |
| results[os.path.basename(f)] = {"label": None, "votes": [], "count": 0, "error": str(e)} |
| done += 1 |
| if done % 10 == 0 or done == len(files): |
| print(f"progress {done}/{len(files)}", flush=True) |
| with open(out, "w") as fh: |
| json.dump(results, fh, ensure_ascii=False, indent=1) |
| ok = sum(1 for v in results.values() if v.get("label")) |
| agree = sum(1 for v in results.values() if v.get("agreed")) |
| print(f"DONE labeled={ok}/{len(files)} full-agree={agree} -> {out}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|