#!/usr/bin/env python3 """离线分析切片乱序图片验证码并尝试还原。 脚本只读取本地证据,不打开网页、不控制浏览器、不提交验证码、 不发送网络请求。 """ from __future__ import annotations import argparse import itertools import json import math import re import sys from pathlib import Path from typing import Any try: from PIL import Image, ImageStat except ImportError: # pragma: no cover - depends on local environment Image = None # type: ignore[assignment] ImageStat = None # type: ignore[assignment] def configure_utf8_stdio() -> None: for stream in (sys.stdout, sys.stderr): if hasattr(stream, "reconfigure"): stream.reconfigure(encoding="utf-8") configure_utf8_stdio() def read_value(value: str | None) -> str: if not value: return "" path = Path(value) if path.exists() and path.is_file(): for encoding in ("utf-8", "utf-8-sig", "gb18030"): try: return path.read_text(encoding=encoding) except UnicodeDecodeError: continue return path.read_text(errors="replace") return value def parse_order(value: str | None) -> list[int] | None: if not value: return None numbers = [int(item) for item in re.findall(r"-?\d+", value)] return numbers or None def compact(text: str, limit: int = 100) -> str: cleaned = re.sub(r"\s+", " ", text.strip()) if len(cleaned) <= limit: return cleaned return cleaned[: limit - 1] + "…" def collect_text_signals(text: str) -> list[dict[str, str]]: patterns = [ ("切片乱序中文文案", r"切片乱序|分块乱序|图片分割|瓦片重排|分割顺序打乱|乱序图片还原|图片被切成多块|顺序打乱"), ("切片乱序英文文案", r"\b(?:scrambled tiles|tile scramble|scrambled image tiles|image tile restore|reorder tiles)\b"), ("tileOrder/pieceOrder 字段", r"\b(?:tileOrder|pieceOrder|tile_order|piece_order|restoreOrder|sliceOrder)\b"), ("CSS 背景切片线索", r"background-position|background-size|sprite"), ("Canvas 绘制切片线索", r"\bdrawImage\s*\("), ("布局位置线索", r"\b(?:transform|translate3d|translate|left|top)\b.{0,80}\b(?:px|tile|piece|slice)\b"), ("shuffle/scramble 线索", r"\b(?:shuffle|scramble|unscramble|slice|tile|piece)\b.{0,80}\b(?:captcha|verify|challenge|image)\b"), ] signals: list[dict[str, str]] = [] for label, pattern in patterns: match = re.search(pattern, text, flags=re.IGNORECASE | re.DOTALL) if match: signals.append({"label": label, "evidence": compact(match.group(0)), "source": "text"}) return signals def extract_order_arrays(text: str) -> tuple[list[int] | None, list[dict[str, str]]]: signals: list[dict[str, str]] = [] for pattern in ( r"\b(?:tileOrder|pieceOrder|tile_order|piece_order|restoreOrder|sliceOrder)\b\s*[:=]\s*\[([^\]]+)\]", r"[\"'](?:tileOrder|pieceOrder|tile_order|piece_order|restoreOrder|sliceOrder)[\"']\s*:\s*\[([^\]]+)\]", ): match = re.search(pattern, text, flags=re.IGNORECASE | re.DOTALL) if not match: continue order = [int(item) for item in re.findall(r"-?\d+", match.group(1))] if order: signals.append({"label": "抽取显式切片顺序数组", "evidence": compact(match.group(0)), "source": "html"}) return order, signals return None, signals def extract_background_position_order( text: str, rows: int | None, cols: int | None, tile_width: int | None, tile_height: int | None, ) -> tuple[list[int] | None, list[dict[str, str]]]: if not rows or not cols or not tile_width or not tile_height: return None, [] pattern = r"background-position\s*:\s*(-?\d+(?:\.\d+)?)px\s+(-?\d+(?:\.\d+)?)px" order: list[int] = [] signals: list[dict[str, str]] = [] for match in re.finditer(pattern, text, flags=re.IGNORECASE): x = abs(float(match.group(1))) y = abs(float(match.group(2))) col = int(round(x / tile_width)) row = int(round(y / tile_height)) if 0 <= row < rows and 0 <= col < cols: order.append(row * cols + col) signals.append({"label": "抽取 background-position 切片来源", "evidence": compact(match.group(0)), "source": "html"}) expected = rows * cols if len(order) == expected: return order, signals return None, signals def extract_draw_image_order( text: str, rows: int | None, cols: int | None, tile_width: int | None, tile_height: int | None, ) -> tuple[list[int] | None, list[dict[str, str]]]: if not rows or not cols or not tile_width or not tile_height: return None, [] pattern = r"drawImage\s*\([^,]+,\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)" found: dict[int, int] = {} signals: list[dict[str, str]] = [] for match in re.finditer(pattern, text, flags=re.IGNORECASE): sx = float(match.group(1)) sy = float(match.group(2)) dx = float(match.group(5)) dy = float(match.group(6)) src_col = int(round(sx / tile_width)) src_row = int(round(sy / tile_height)) dst_col = int(round(dx / tile_width)) dst_row = int(round(dy / tile_height)) if 0 <= src_row < rows and 0 <= src_col < cols and 0 <= dst_row < rows and 0 <= dst_col < cols: found[dst_row * cols + dst_col] = src_row * cols + src_col signals.append({"label": "抽取 drawImage 源/目标切片", "evidence": compact(match.group(0)), "source": "html"}) expected = rows * cols if len(found) == expected: return [found[index] for index in range(expected)], signals return None, signals def normalize_order(order: list[int], total: int) -> tuple[list[int] | None, str | None]: if len(order) != total: return None, f"顺序数组长度为 {len(order)},但网格需要 {total} 个切片" if sorted(order) == list(range(total)): return order, None if sorted(item - 1 for item in order) == list(range(total)): return [item - 1 for item in order], None return None, "顺序数组不是 0 基或 1 基的完整排列" def infer_grid( image: Any, rows: int | None, cols: int | None, tile_width: int | None, tile_height: int | None, ) -> tuple[int | None, int | None, int | None, int | None, list[str]]: warnings: list[str] = [] width, height = image.size if tile_width and not cols: cols = width // tile_width if tile_height and not rows: rows = height // tile_height if rows and not tile_height: tile_height = height // rows if cols and not tile_width: tile_width = width // cols if not rows or not cols or not tile_width or not tile_height: warnings.append("需要提供 rows/cols 或 tile-width/tile-height 才能切片分析图片") return rows, cols, tile_width, tile_height, warnings if rows * tile_height > height or cols * tile_width > width: warnings.append("网格尺寸超过图片尺寸") return rows, cols, tile_width, tile_height, warnings def split_tiles(image: Any, rows: int, cols: int, tile_width: int, tile_height: int) -> list[Any]: tiles = [] for row in range(rows): for col in range(cols): left = col * tile_width top = row * tile_height tiles.append(image.crop((left, top, left + tile_width, top + tile_height)).convert("RGB")) return tiles def edge_cost(a: Any, b: Any, direction: str) -> float: if direction == "right": edge_a = a.crop((a.width - 1, 0, a.width, a.height)) edge_b = b.crop((0, 0, 1, b.height)) else: edge_a = a.crop((0, a.height - 1, a.width, a.height)) edge_b = b.crop((0, 0, b.width, 1)) diff = ImageStat.Stat(Image.eval(ImageChopsCompat.difference(edge_a, edge_b), lambda px: px)).mean return float(sum(diff) / len(diff)) class ImageChopsCompat: @staticmethod def difference(left: Any, right: Any) -> Any: from PIL import ImageChops return ImageChops.difference(left, right) def arrangement_cost(order: tuple[int, ...], tiles: list[Any], rows: int, cols: int) -> float: total = 0.0 count = 0 for row in range(rows): for col in range(cols): current = tiles[order[row * cols + col]] if col + 1 < cols: total += edge_cost(current, tiles[order[row * cols + col + 1]], "right") count += 1 if row + 1 < rows: total += edge_cost(current, tiles[order[(row + 1) * cols + col]], "down") count += 1 return total / max(count, 1) def tile_diversity(tiles: list[Any]) -> float: means: list[float] = [] for tile in tiles: stat = ImageStat.Stat(tile) means.extend(float(value) for value in stat.mean) if not means: return 0.0 avg = sum(means) / len(means) variance = sum((value - avg) ** 2 for value in means) / len(means) return math.sqrt(variance) def image_edge_match_order(tiles: list[Any], rows: int, cols: int) -> tuple[list[int] | None, float, float, list[str]]: total = rows * cols warnings: list[str] = [] if total != len(tiles): return None, 0.0, 0.0, ["切片数量和网格不一致"] if total > 9: warnings.append("切片超过 9 个,第一版不做全排列搜索;请优先使用页面顺序或显式 order") return list(range(total)), 0.25, 0.0, warnings best_order: tuple[int, ...] | None = None best_cost = float("inf") second_cost = float("inf") for order in itertools.permutations(range(total)): cost = arrangement_cost(order, tiles, rows, cols) if cost < best_cost: second_cost = best_cost best_cost = cost best_order = order elif cost < second_cost: second_cost = cost diversity = tile_diversity(tiles) separation = 0.0 if not math.isfinite(second_cost) else max(0.0, (second_cost - best_cost) / (second_cost + 1e-6)) confidence = max(0.25, min(0.95, separation * 4.0 + min(diversity / 128.0, 0.35))) if separation < 0.02: confidence = min(confidence, 0.42) warnings.append("候选排列分数接近,可能是重复纹理、纯色块或边缘线索不足") if diversity < 6: confidence = min(confidence, 0.35) warnings.append("切片视觉差异很低,建议改用页面顺序或人工/平台复核") return list(best_order or range(total)), round(confidence, 2), round(best_cost, 4), warnings def restore_image( image: Any, rows: int, cols: int, tile_width: int, tile_height: int, order_source_by_target: list[int], output_path: Path, ) -> None: tiles = split_tiles(image, rows, cols, tile_width, tile_height) restored = Image.new("RGB", (cols * tile_width, rows * tile_height)) for target_index, source_index in enumerate(order_source_by_target): row, col = divmod(target_index, cols) restored.paste(tiles[source_index], (col * tile_width, row * tile_height)) output_path.parent.mkdir(parents=True, exist_ok=True) restored.save(output_path) def build_report(args: argparse.Namespace) -> dict[str, Any]: html_text = read_value(args.html) plain_text = read_value(args.text) combined_text = "\n".join(part for part in (html_text, plain_text) if part) signals = collect_text_signals(combined_text) warnings: list[str] = [] rows = args.rows cols = args.cols tile_width = args.tile_width tile_height = args.tile_height explicit_order = parse_order(args.order_source_by_target) extracted_order: list[int] | None = None for extractor in ( extract_order_arrays, lambda text: extract_background_position_order(text, rows, cols, tile_width, tile_height), lambda text: extract_draw_image_order(text, rows, cols, tile_width, tile_height), ): order, found_signals = extractor(combined_text) signals.extend(found_signals) if order: extracted_order = order break image = None if args.image: if Image is None: warnings.append("当前 Python 环境缺少 Pillow,无法读取图片;只能分析文本/HTML 线索") else: image_path = Path(args.image) if image_path.exists() and image_path.is_file(): image = Image.open(image_path).convert("RGB") rows, cols, tile_width, tile_height, grid_warnings = infer_grid(image, rows, cols, tile_width, tile_height) warnings.extend(grid_warnings) else: warnings.append("图片文件不存在") total = rows * cols if rows and cols else 0 normalized_order: list[int] | None = None order_warning: str | None = None order_source = "" if explicit_order and total: normalized_order, order_warning = normalize_order(explicit_order, total) order_source = "argument" elif extracted_order and total: normalized_order, order_warning = normalize_order(extracted_order, total) order_source = "page" if order_warning: warnings.append(order_warning) confidence = 0.0 edge_cost_value: float | None = None restore_strategy = "manual-or-platform" if normalized_order: confidence = 0.92 if order_source == "argument" else 0.86 restore_strategy = "page-order" elif image is not None and rows and cols and tile_width and tile_height: tiles = split_tiles(image, rows, cols, tile_width, tile_height) normalized_order, confidence, edge_cost_value, edge_warnings = image_edge_match_order(tiles, rows, cols) warnings.extend(edge_warnings) restore_strategy = "image-edge-match" if confidence >= 0.55 else "manual-or-platform" elif signals: confidence = 0.58 restored_image: str | None = None if image is not None and rows and cols and tile_width and tile_height and normalized_order and args.output_image: if len(normalized_order) == rows * cols: output_path = Path(args.output_image) restore_image(image, rows, cols, tile_width, tile_height, normalized_order, output_path) restored_image = str(output_path) else: warnings.append("顺序长度不匹配,未输出还原图片") is_tile_scramble = bool(signals or normalized_order or (image is not None and rows and cols)) if not signals and normalized_order: signals.append({"label": "提供显式切片顺序", "evidence": ",".join(str(item) for item in normalized_order), "source": "argument"}) if image is not None and rows and cols: signals.append({"label": "图片网格切片证据", "evidence": f"{rows}x{cols}", "source": "image"}) return { "is_tile_scramble": is_tile_scramble, "grid": { "rows": rows, "cols": cols, "tile_width": tile_width, "tile_height": tile_height, "total": total, }, "order_source_by_target": normalized_order, "confidence": round(confidence, 2), "restore_strategy": restore_strategy, "edge_match_cost": edge_cost_value, "signals": signals, "warnings": warnings, "restored_image": restored_image, "notes": [ "脚本只做离线切片判断和图片还原,不打开网页、不点击页面、不提交验证。", "优先相信页面逻辑中的顺序字段;纯图片边缘匹配低置信度时应切换人工或平台复核。", ], } def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="离线分析切片乱序图片验证码") parser.add_argument("--image", help="待分析的乱序图片路径") parser.add_argument("--html", help="HTML/CSS/JS 片段或文件路径") parser.add_argument("--text", help="页面文案或分析备注") parser.add_argument("--rows", type=int, help="切片行数") parser.add_argument("--cols", type=int, help="切片列数") parser.add_argument("--tile-width", type=int, help="单块宽度") parser.add_argument("--tile-height", type=int, help="单块高度") parser.add_argument("--order-source-by-target", help="还原顺序:每个目标位置使用哪个当前切片索引,支持逗号分隔") parser.add_argument("--output-image", help="输出还原图片路径") parser.add_argument("--pretty", action="store_true", help="以缩进格式输出 JSON") return parser def main() -> int: args = build_parser().parse_args() report = build_report(args) print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None)) return 0 if __name__ == "__main__": raise SystemExit(main())