| """ |
| 滑块验证码识别 API 服务 |
| ======================== |
| 基于 FastAPI + ONNX 模型,提供 HTTP 接口识别滑块验证码的滑块和缺口位置。 |
| 接口说明: |
| GET / - 健康检查 |
| GET /health - 健康检查 |
| POST /slide - 上传图片文件,只返回滑块位置 |
| POST /slide-base64 - 上传 base64 图片,只返回滑块位置 |
| POST /hole - 上传图片文件,只返回缺口位置 |
| POST /hole-base64 - 上传 base64 图片,只返回缺口位置 |
| POST /puzzle - 上传图片文件,返回滑块+缺口+滑缺距 |
| POST /puzzle-base64 - 上传 base64 图片,返回滑块+缺口+滑缺距 |
| POST /visualize - 上传图片文件,返回坐标+带框图片URL |
| POST /visualize-base64 - 上传base64图片,返回坐标+带框图片URL |
| 返回格式: |
| /slide、/slide-base64: |
| { |
| "滑块": [x1, y1, x2, y2], // 滑块坐标(左边的那个) |
| "相似度": 0.92, // 滑块检测置信度 |
| "消息": null // 错误信息,正常为 null |
| } |
| /hole、/hole-base64: |
| { |
| "缺口": [x1, y1, x2, y2], // 缺口坐标(左边的那个) |
| "相似度": 0.88, // 缺口检测置信度 |
| "消息": null // 错误信息,正常为 null |
| } |
| /puzzle、/puzzle-base64: |
| { |
| "滑块": [x1, y1, x2, y2], // 滑块坐标 |
| "滑块相似度": 0.92, // 滑块检测置信度 |
| "缺口": [x1, y1, x2, y2], // 缺口坐标 |
| "缺口相似度": 0.88, // 缺口检测置信度 |
| "滑缺距": 162, // 滑动距离 = 缺口x1 - 滑块x1 |
| "消息": null // 错误信息,正常为 null |
| } |
| /visualize、/visualize-base64: |
| { |
| "滑块": [x1, y1, x2, y2], |
| "滑块相似度": 0.92, |
| "缺口": [x1, y1, x2, y2], |
| "缺口相似度": 0.88, |
| "滑缺距": 162, |
| "image_url": "https://xxx.r2.dev/xxx.png", // 带框图片URL |
| "消息": null |
| } |
| """ |
|
|
| import gc |
| import ipaddress |
| import json |
| import os |
| import time |
| import urllib.parse |
| import uuid |
| from pathlib import Path |
| from typing import Optional |
|
|
| import boto3 |
| import cv2 |
| import numpy as np |
|
|
| from captcha_recognizer.slider import Slider |
| from fastapi import FastAPI, File, Query, Request, UploadFile |
| from fastapi.middleware.cors import CORSMiddleware |
| import ip2region.searcher as ip2region_xdb |
| import ip2region.util as ip2region_util |
|
|
|
|
| |
| |
| |
|
|
| app = FastAPI( |
| docs_url=None, |
| redoc_url=None, |
| ) |
|
|
| |
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| slider_model = Slider() |
|
|
|
|
| |
| |
| |
| |
| BASE_DIR = Path(__file__).resolve().parent |
| IPV4_XDB_PATH = str(BASE_DIR / "data" / "ip2region_v4.xdb") |
| IPV6_XDB_PATH = str(BASE_DIR / "data" / "ip2region_v6.xdb") |
|
|
| ip2region_error = None |
| ipv4_vector_index = None |
| ipv6_vector_index = None |
|
|
| try: |
| |
| ip2region_util.verify_from_file(IPV4_XDB_PATH) |
| ip2region_util.verify_from_file(IPV6_XDB_PATH) |
|
|
| |
| ipv4_vector_index = ip2region_util.load_vector_index_from_file(IPV4_XDB_PATH) |
| ipv6_vector_index = ip2region_util.load_vector_index_from_file(IPV6_XDB_PATH) |
| except Exception as e: |
| ip2region_error = str(e) |
|
|
|
|
| |
| |
| |
|
|
| R2_CONFIG = { |
| "endpoint": os.environ.get("R2_ENDPOINT"), |
| "access_key": os.environ.get("R2_ACCESS_KEY_ID"), |
| "secret_key": os.environ.get("R2_SECRET_ACCESS_KEY"), |
| "bucket_name": os.environ.get("R2_BUCKET_NAME", "lze"), |
| "public_url": os.environ.get("R2_PUBLIC_URL", "https://pub-7812368cee8345339c5c95b9768ad994.r2.dev"), |
| } |
|
|
| |
| r2_client = None |
| if R2_CONFIG["access_key"] and R2_CONFIG["secret_key"]: |
| r2_client = boto3.client( |
| "s3", |
| endpoint_url=R2_CONFIG["endpoint"], |
| aws_access_key_id=R2_CONFIG["access_key"], |
| aws_secret_access_key=R2_CONFIG["secret_key"], |
| ) |
|
|
|
|
| def upload_to_r2(image_bytes: bytes, filename: str) -> Optional[str]: |
| """ |
| 上传图片到 Cloudflare R2,返回公开访问 URL |
| |
| 参数: |
| image_bytes: 图片字节数据 |
| filename: 文件名 |
| |
| 返回: |
| 成功 → 公开访问 URL |
| 失败 → None |
| """ |
| if r2_client is None: |
| return None |
| |
| try: |
| r2_client.put_object( |
| Bucket=R2_CONFIG["bucket_name"], |
| Key=filename, |
| Body=image_bytes, |
| ContentType="image/png", |
| ) |
| |
| |
| if R2_CONFIG["public_url"]: |
| |
| return f"{R2_CONFIG['public_url'].rstrip('/')}/{filename}" |
| else: |
| |
| return f"https://{R2_CONFIG['bucket_name']}.{R2_CONFIG['endpoint'].replace('https://', '')}/{filename}" |
| except Exception as e: |
| print(f"R2 上传失败: {e}") |
| return None |
|
|
|
|
| def generate_image_filename() -> str: |
| """生成唯一的图片文件名""" |
| timestamp = int(time.time() * 1000) |
| unique_id = uuid.uuid4().hex[:8] |
| return f"captcha_{timestamp}_{unique_id}.png" |
|
|
|
|
| |
| |
| |
|
|
| @app.get("/") |
| def hello_captcha(): |
| """根路径,简单返回服务状态,可用于判断服务是否存活""" |
| return {"Hello": "Captcha", "status": "running"} |
|
|
|
|
| @app.get("/health") |
| def health(): |
| """健康检查接口,部署平台(如 HF Spaces)会定期调用""" |
| return {"status": "ok"} |
|
|
|
|
| |
| |
| |
|
|
| def _parse_region(region: str) -> dict: |
| """ |
| ip2region 原始结果格式一般为: |
| 国家|省份/州|城市|运营商|国家代码 |
| |
| 这里保留原始字符串,同时拆成更方便脚本读取的字段。 |
| """ |
| parts = (region or "").split("|") |
| parts = (parts + [""] * 5)[:5] |
|
|
| def clean(value: str): |
| return None if value in ("", "0") else value |
|
|
| return { |
| "国家": clean(parts[0]), |
| "省份/州": clean(parts[1]), |
| "城市": clean(parts[2]), |
| "运营商": clean(parts[3]), |
| "国家代码": clean(parts[4]), |
| } |
|
|
|
|
| def _lookup_ip(ip: str) -> dict: |
| """识别 IPv4/IPv6,并使用对应 xdb 离线库查询归属地""" |
| ip_text = (ip or "").strip() |
| if not ip_text: |
| return {"ip": ip, "版本": None, "归属地": "", "数据": {}, "消息": "缺少ip参数"} |
|
|
| try: |
| ip_obj = ipaddress.ip_address(ip_text) |
| except ValueError: |
| return {"ip": ip_text, "版本": None, "归属地": "", "数据": {}, "消息": "无效IP地址"} |
|
|
| if ip2region_error: |
| return { |
| "ip": ip_text, |
| "版本": f"IPv{ip_obj.version}", |
| "归属地": "", |
| "数据": {}, |
| "消息": f"ip2region初始化失败:{ip2region_error}", |
| } |
|
|
| searcher = None |
| try: |
| if ip_obj.version == 4: |
| searcher = ip2region_xdb.new_with_vector_index( |
| ip2region_util.IPv4, |
| IPV4_XDB_PATH, |
| ipv4_vector_index, |
| ) |
| else: |
| searcher = ip2region_xdb.new_with_vector_index( |
| ip2region_util.IPv6, |
| IPV6_XDB_PATH, |
| ipv6_vector_index, |
| ) |
|
|
| region = searcher.search(ip_text) or "" |
| return { |
| "ip": ip_text, |
| "版本": f"IPv{ip_obj.version}", |
| "归属地": region, |
| "数据": _parse_region(region), |
| "消息": None, |
| } |
| except Exception as e: |
| return { |
| "ip": ip_text, |
| "版本": f"IPv{ip_obj.version}", |
| "归属地": "", |
| "数据": {}, |
| "消息": f"查询失败:{str(e)}", |
| } |
| finally: |
| if searcher is not None: |
| searcher.close() |
|
|
|
|
| @app.get("/ip") |
| def ip_lookup(ip: str = Query(..., description="要查询的 IPv4 或 IPv6 地址")): |
| """ |
| GET 查询 IP 归属地 |
| |
| 用法: |
| GET /ip?ip=8.8.8.8 |
| GET /ip?ip=240e:3b7:3272:d8d0:db09:c067:8d59:539e |
| """ |
| return _lookup_ip(ip) |
|
|
|
|
| @app.post("/ip") |
| async def ip_lookup_post(request: Request): |
| """ |
| POST 查询 IP 归属地 |
| |
| 支持多种请求格式,方便不同客户端调用: |
| {"ip": "8.8.8.8"} |
| ip=8.8.8.8 |
| 8.8.8.8 |
| """ |
| body = (await request.body()).decode("utf-8", errors="ignore").strip() |
| ip = "" |
|
|
| if body: |
| |
| try: |
| data = json.loads(body) |
| if isinstance(data, dict): |
| ip = data.get("ip", "") |
| except Exception: |
| pass |
|
|
| |
| if not ip and "=" in body: |
| form_data = urllib.parse.parse_qs(body) |
| ip = (form_data.get("ip") or [""])[0] |
|
|
| |
| if not ip: |
| ip = body |
|
|
| return _lookup_ip(ip) |
|
|
|
|
| |
| |
| |
|
|
| def _recognize(image_bytes: bytes) -> dict: |
| """ |
| 图片字节 → 识别结果(原始 dict) |
| 流程:字节 → OpenCV图片 → 模型推理 → 返回 identify_both 的原始结果 |
| 返回 dict 结构(与 Slider.identify_both 一致): |
| slider: [x1, y1, x2, y2] 滑块坐标,空列表=未检测到 |
| slider_confidence: float 滑块置信度 |
| gap: [x1, y1, x2, y2] 缺口坐标,空列表=未检测到 |
| gap_confidence: float 缺口置信度 |
| offset: int 滑动距离 = 缺口x1 - 滑块x1 |
| 所有接口共用这个方法,避免重复代码 |
| """ |
| |
| nparr = np.frombuffer(image_bytes, np.uint8) |
| image = cv2.imdecode(nparr, cv2.IMREAD_COLOR) |
|
|
| if image is None: |
| return None |
|
|
| |
| result = slider_model.identify_both(source=image) |
|
|
| |
| del image, nparr |
| gc.collect() |
|
|
| return result |
|
|
|
|
| |
| |
| |
|
|
| def _decode_base64(data: dict) -> Optional[bytes]: |
| """ |
| 从请求 body 中提取 base64 图片并解码为字节 |
| 参数: |
| data: 请求 JSON,需包含 "image" 字段 |
| 支持两种格式: |
| - 带前缀:data:image/png;base64,iVBORw0KGgo... |
| - 纯 base64:iVBORw0KGgo... |
| 返回: |
| 成功 → 图片字节流 |
| 失败 → None(调用方根据返回值判断错误类型) |
| """ |
| import base64 |
|
|
| image_b64 = data.get("image", "") |
| if not image_b64: |
| return None |
|
|
| |
| if "," in image_b64: |
| image_b64 = image_b64.split(",")[1] |
|
|
| try: |
| return base64.b64decode(image_b64) |
| except Exception: |
| return None |
|
|
|
|
| |
| |
| |
|
|
| @app.post("/slide") |
| async def slide(file: UploadFile = File(...)): |
| """ |
| 上传图片文件,只返回滑块位置(图片左侧的那个拼图块) |
| 用法:POST /slide,body 里传图片文件(form-data) |
| 返回示例: |
| { |
| "滑块": [91, 1045, 248, 1200], |
| "相似度": 0.9487, |
| "消息": null |
| } |
| """ |
| contents = await file.read() |
| result = _recognize(contents) |
|
|
| |
| if result is None: |
| return {"滑块": [], "相似度": 0.0, "消息": "不支持的图片格式"} |
|
|
| return { |
| "滑块": result["slider"], |
| "相似度": round(result["slider_confidence"], 4), |
| "消息": None, |
| } |
|
|
|
|
| @app.post("/slide-base64") |
| async def slide_base64(data: dict): |
| """ |
| 上传 base64 图片,只返回滑块位置 |
| 请求格式:{"image": "base64字符串"} |
| 适合懒人精灵等脚本通过 HTTP POST 调用 |
| base64 字符串支持两种格式: |
| - 带 data:image 前缀的:data:image/png;base64,iVBORw0KGgo... |
| - 纯 base64:iVBORw0KGgo... |
| """ |
| image_bytes = _decode_base64(data) |
|
|
| |
| if image_bytes is None: |
| return {"滑块": [], "相似度": 0.0, "消息": "缺少image参数或base64解码失败"} |
|
|
| result = _recognize(image_bytes) |
|
|
| |
| if result is None: |
| return {"滑块": [], "相似度": 0.0, "消息": "不支持的图片格式"} |
|
|
| return { |
| "滑块": result["slider"], |
| "相似度": round(result["slider_confidence"], 4), |
| "消息": None, |
| } |
|
|
|
|
| |
| |
| |
|
|
| @app.post("/hole") |
| async def hole(file: UploadFile = File(...)): |
| """ |
| 上传图片文件,只返回缺口位置(背景上的那个空白拼图区域) |
| 用法:POST /hole,body 里传图片文件(form-data) |
| 返回示例: |
| { |
| "缺口": [803, 1044, 957, 1201], |
| "相似度": 0.9474, |
| "消息": null |
| } |
| """ |
| contents = await file.read() |
| result = _recognize(contents) |
|
|
| if result is None: |
| return {"缺口": [], "相似度": 0.0, "消息": "不支持的图片格式"} |
|
|
| return { |
| "缺口": result["gap"], |
| "相似度": round(result["gap_confidence"], 4), |
| "消息": None, |
| } |
|
|
|
|
| @app.post("/hole-base64") |
| async def hole_base64(data: dict): |
| """ |
| 上传 base64 图片,只返回缺口位置 |
| 请求格式:{"image": "base64字符串"} |
| """ |
| image_bytes = _decode_base64(data) |
|
|
| if image_bytes is None: |
| return {"缺口": [], "相似度": 0.0, "消息": "缺少image参数或base64解码失败"} |
|
|
| result = _recognize(image_bytes) |
|
|
| if result is None: |
| return {"缺口": [], "相似度": 0.0, "消息": "不支持的图片格式"} |
|
|
| return { |
| "缺口": result["gap"], |
| "相似度": round(result["gap_confidence"], 4), |
| "消息": None, |
| } |
|
|
|
|
| |
| |
| |
|
|
| @app.post("/puzzle") |
| async def puzzle(file: UploadFile = File(...)): |
| """ |
| 上传图片文件,返回滑块位置 + 缺口位置 + 滑缺距(滑动距离) |
| 用法:POST /puzzle,body 里传图片文件(form-data) |
| 返回示例: |
| { |
| "滑块": [91, 1045, 248, 1200], |
| "滑块相似度": 0.9487, |
| "缺口": [803, 1044, 957, 1201], |
| "缺口相似度": 0.9474, |
| "滑缺距": 712, |
| "消息": null |
| } |
| "滑缺距" = 缺口x1 - 滑块x1,就是滑块需要从当前位置滑到缺口的像素距离 |
| """ |
| contents = await file.read() |
| result = _recognize(contents) |
|
|
| if result is None: |
| return { |
| "滑块": [], "滑块相似度": 0.0, |
| "缺口": [], "缺口相似度": 0.0, |
| "滑缺距": 0, "消息": "不支持的图片格式", |
| } |
|
|
| return { |
| "滑块": result["slider"], |
| "滑块相似度": round(result["slider_confidence"], 4), |
| "缺口": result["gap"], |
| "缺口相似度": round(result["gap_confidence"], 4), |
| "滑缺距": result["offset"], |
| "消息": None, |
| } |
|
|
|
|
| @app.post("/puzzle-base64") |
| async def puzzle_base64(data: dict): |
| """ |
| 上传 base64 图片,返回滑块位置 + 缺口位置 + 滑缺距 |
| 请求格式:{"image": "base64字符串"} |
| 适合懒人精灵等脚本通过 HTTP POST 调用 |
| base64 字符串支持两种格式: |
| - 带 data:image 前缀的:data:image/png;base64,iVBORw0KGgo... |
| - 纯 base64:iVBORw0KGgo... |
| """ |
| image_bytes = _decode_base64(data) |
|
|
| if image_bytes is None: |
| return { |
| "滑块": [], "滑块相似度": 0.0, |
| "缺口": [], "缺口相似度": 0.0, |
| "滑缺距": 0, "消息": "缺少image参数或base64解码失败", |
| } |
|
|
| result = _recognize(image_bytes) |
|
|
| if result is None: |
| return { |
| "滑块": [], "滑块相似度": 0.0, |
| "缺口": [], "缺口相似度": 0.0, |
| "滑缺距": 0, "消息": "不支持的图片格式", |
| } |
|
|
| return { |
| "滑块": result["slider"], |
| "滑块相似度": round(result["slider_confidence"], 4), |
| "缺口": result["gap"], |
| "缺口相似度": round(result["gap_confidence"], 4), |
| "滑缺距": result["offset"], |
| "消息": None, |
| } |
|
|
|
|
| |
| |
| |
|
|
| def _draw_box_and_upload(image_bytes: bytes) -> tuple: |
| """ |
| 识别滑块并画框,上传到 R2,返回 (结果dict, 图片URL) |
| |
| 返回: |
| (识别结果dict, image_url) 或 (None, None) 如果失败 |
| """ |
| |
| nparr = np.frombuffer(image_bytes, np.uint8) |
| image = cv2.imdecode(nparr, cv2.IMREAD_COLOR) |
|
|
| if image is None: |
| return None, None |
|
|
| |
| result = slider_model.identify_both(source=image) |
|
|
| |
| output = image.copy() |
| |
| |
| if result["slider"]: |
| x1, y1, x2, y2 = result["slider"] |
| cv2.rectangle(output, (x1, y1), (x2, y2), (0, 255, 0), 2) |
| cv2.putText(output, f"滑块:{result['slider_confidence']:.2f}", |
| (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) |
| |
| |
| if result["gap"]: |
| x1, y1, x2, y2 = result["gap"] |
| cv2.rectangle(output, (x1, y1), (x2, y2), (0, 0, 255), 2) |
| cv2.putText(output, f"缺口:{result['gap_confidence']:.2f}", |
| (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1) |
| |
| |
| if result["slider"] and result["gap"]: |
| slider_x = result["slider"][0] |
| gap_x = result["gap"][0] |
| mid_y = (result["slider"][1] + result["gap"][1]) // 2 |
| cv2.putText(output, f"距离:{result['offset']}px", |
| ((slider_x + gap_x) // 2, mid_y - 20), |
| cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 0), 2) |
|
|
| |
| _, buffer = cv2.imencode('.png', output) |
| output_bytes = buffer.tobytes() |
|
|
| |
| filename = generate_image_filename() |
| image_url = upload_to_r2(output_bytes, filename) |
|
|
| |
| del image, nparr, output, buffer, output_bytes |
| gc.collect() |
|
|
| return result, image_url |
|
|
|
|
| @app.post("/visualize") |
| async def puzzle_visualize(file: UploadFile = File(...)): |
| """ |
| 上传图片文件,返回滑块+缺口+滑缺距+带框图片URL |
| 用法:POST /puzzle-visualize,body 里传图片文件(form-data) |
| 返回示例: |
| { |
| "滑块": [91, 1045, 248, 1200], |
| "滑块相似度": 0.9487, |
| "缺口": [803, 1044, 957, 1201], |
| "缺口相似度": 0.9474, |
| "滑缺距": 712, |
| "image_url": "https://xxx.r2.dev/captcha_xxx.png", |
| "消息": null |
| } |
| """ |
| contents = await file.read() |
| result, image_url = _draw_box_and_upload(contents) |
|
|
| if result is None: |
| return { |
| "滑块": [], "滑块相似度": 0.0, |
| "缺口": [], "缺口相似度": 0.0, |
| "滑缺距": 0, |
| "image_url": None, |
| "消息": "不支持的图片格式", |
| } |
|
|
| return { |
| "滑块": result["slider"], |
| "滑块相似度": round(result["slider_confidence"], 4), |
| "缺口": result["gap"], |
| "缺口相似度": round(result["gap_confidence"], 4), |
| "滑缺距": result["offset"], |
| "image_url": image_url, |
| "消息": None, |
| } |
|
|
|
|
| @app.post("/visualize-base64") |
| async def puzzle_visualize_base64(data: dict): |
| """ |
| 上传 base64 图片,返回滑块+缺口+滑缺距+带框图片URL |
| 请求格式:{"image": "base64字符串"} |
| 适合懒人精灵等脚本通过 HTTP POST 调用 |
| base64 字符串支持两种格式: |
| - 带 data:image 前缀的:data:image/png;base64,iVBORw0KGgo... |
| - 纯 base64:iVBORw0KGgo... |
| """ |
| image_bytes = _decode_base64(data) |
|
|
| if image_bytes is None: |
| return { |
| "滑块": [], "滑块相似度": 0.0, |
| "缺口": [], "缺口相似度": 0.0, |
| "滑缺距": 0, |
| "image_url": None, |
| "消息": "缺少image参数或base64解码失败", |
| } |
|
|
| result, image_url = _draw_box_and_upload(image_bytes) |
|
|
| if result is None: |
| return { |
| "滑块": [], "滑块相似度": 0.0, |
| "缺口": [], "缺口相似度": 0.0, |
| "滑缺距": 0, |
| "image_url": None, |
| "消息": "不支持的图片格式", |
| } |
|
|
| return { |
| "滑块": result["slider"], |
| "滑块相似度": round(result["slider_confidence"], 4), |
| "缺口": result["gap"], |
| "缺口相似度": round(result["gap_confidence"], 4), |
| "滑缺距": result["offset"], |
| "image_url": image_url, |
| "消息": None, |
| } |
|
|
|
|
| |
| |
| |
| if __name__ == "__main__": |
| import uvicorn |
| |
| |
| port = int(os.environ.get("PORT", 7860)) |
| uvicorn.run(app, host="0.0.0.0", port=port) |
|
|