capture_reader_c3_baseline.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. #!/usr/bin/env python3
  2. """Capture repeatable Track C3 reader parity evidence from one Android device.
  3. The script toggles the existing Lab Compose renderer flag through the app UI, restores the
  4. previous Lab settings on exit, opens one book, captures Legacy/Compose pairs, and records
  5. `gfxinfo framestats`. It intentionally does not install an APK or create reader data.
  6. """
  7. from __future__ import annotations
  8. import argparse
  9. import json
  10. import re
  11. import struct
  12. import subprocess
  13. import sys
  14. import time
  15. import xml.etree.ElementTree as element_tree
  16. import zlib
  17. from datetime import datetime
  18. from pathlib import Path
  19. PACKAGE_DEFAULT = "io.legato.kazusa.debug"
  20. MAIN_ACTIVITY_CLASS = "io.legado.app.ui.main.MainActivity"
  21. LAB_ROUTE = "settings/lab_config"
  22. READ_ROUTE = "book/read"
  23. LAB_LABELS = ("Enable Lab", "启用实验室", "啟用實驗室")
  24. COMPOSE_LABELS = (
  25. "Compose Reader Rendering",
  26. "Compose 阅读渲染",
  27. "Compose 閱讀渲染",
  28. )
  29. PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
  30. class BaselineError(RuntimeError):
  31. pass
  32. class Adb:
  33. def __init__(self, serial: str | None):
  34. self.prefix = ["adb"] + (["-s", serial] if serial else [])
  35. def run(
  36. self,
  37. *args: str,
  38. capture: bool = True,
  39. timeout_seconds: float = 15.0,
  40. ) -> str:
  41. completed = subprocess.run(
  42. [*self.prefix, *args],
  43. check=True,
  44. stdout=subprocess.PIPE if capture else None,
  45. stderr=subprocess.PIPE,
  46. timeout=timeout_seconds,
  47. )
  48. return completed.stdout.decode("utf-8", errors="replace") if capture else ""
  49. def bytes(self, *args: str, timeout_seconds: float = 15.0) -> bytes:
  50. return subprocess.run(
  51. [*self.prefix, *args],
  52. check=True,
  53. stdout=subprocess.PIPE,
  54. stderr=subprocess.PIPE,
  55. timeout=timeout_seconds,
  56. ).stdout
  57. def connected_serial(requested: str | None) -> str:
  58. if requested:
  59. return requested
  60. output = subprocess.run(
  61. ["adb", "devices"], check=True, stdout=subprocess.PIPE, text=True
  62. ).stdout.splitlines()[1:]
  63. devices = [line.split("\t", 1)[0] for line in output if "\tdevice" in line]
  64. if len(devices) != 1:
  65. raise BaselineError("请用 --serial 指定唯一已连接且处于 device 状态的真机")
  66. return devices[0]
  67. def screen_size(adb: Adb) -> tuple[int, int]:
  68. output = adb.run("shell", "wm", "size")
  69. match = re.search(r"(\d+)x(\d+)", output)
  70. if not match:
  71. raise BaselineError(f"无法解析屏幕尺寸:{output.strip()}")
  72. return int(match.group(1)), int(match.group(2))
  73. def require_unlocked(adb: Adb) -> None:
  74. trust = adb.run("shell", "dumpsys", "trust")
  75. current_user = re.search(
  76. r"\(current\):.*?deviceLocked=(\d)",
  77. trust,
  78. flags=re.DOTALL,
  79. )
  80. if current_user is not None and current_user.group(1) == "1":
  81. raise BaselineError("真机已锁定;请解锁并保持屏幕常亮后重试")
  82. def parse_bounds(raw: str) -> tuple[int, int]:
  83. values = [int(value) for value in re.findall(r"-?\d+", raw)]
  84. if len(values) != 4:
  85. raise BaselineError(f"无法解析控件 bounds:{raw}")
  86. return (values[0] + values[2]) // 2, (values[1] + values[3]) // 2
  87. def dump_ui(adb: Adb) -> element_tree.Element:
  88. remote = "/sdcard/codex-reader-c3-window.xml"
  89. adb.run("shell", "uiautomator", "dump", remote)
  90. return element_tree.fromstring(adb.bytes("exec-out", "cat", remote))
  91. def find_toggle(root: element_tree.Element, labels: tuple[str, ...]) -> element_tree.Element:
  92. parents = {child: parent for parent in root.iter() for child in parent}
  93. label_nodes = [node for node in root.iter("node") if node.attrib.get("text") in labels]
  94. for label in label_nodes:
  95. node: element_tree.Element | None = label
  96. while node is not None:
  97. if node.attrib.get("checkable") == "true":
  98. return node
  99. node = parents.get(node)
  100. names = ", ".join(labels)
  101. raise BaselineError(f"UI dump 中找不到可切换的设置:{names}")
  102. def open_route(adb: Adb, package: str, route: str, extra: tuple[str, str] | None = None) -> None:
  103. command = [
  104. # MainActivity only consumes startRoute while establishing its navigation state. Reusing
  105. # the existing instance can leave the previous screen visible while the script clicks it.
  106. "shell", "am", "start", "-S", "-W", "-n", f"{package}/{MAIN_ACTIVITY_CLASS}",
  107. "--es", "startRoute", route,
  108. ]
  109. if extra is not None:
  110. command.extend(["--es", extra[0], extra[1]])
  111. adb.run(*command)
  112. def switch_state(adb: Adb, labels: tuple[str, ...], target: bool, timeout: float = 6.0) -> bool:
  113. toggle = find_toggle(dump_ui(adb), labels)
  114. raw_state = toggle.attrib.get("checked")
  115. if raw_state not in ("true", "false"):
  116. raise BaselineError(f"设置未暴露 checked 状态:{labels[0]}")
  117. previous = raw_state == "true"
  118. if previous == target:
  119. return previous
  120. adb.run("shell", "input", "tap", *(str(value) for value in parse_bounds(toggle.attrib["bounds"])))
  121. deadline = time.monotonic() + timeout
  122. while time.monotonic() < deadline:
  123. time.sleep(0.2)
  124. current = find_toggle(dump_ui(adb), labels).attrib.get("checked") == "true"
  125. if current == target:
  126. return previous
  127. raise BaselineError(f"切换 {labels[0]} 到 {target} 超时")
  128. def set_compose_renderer(adb: Adb, package: str, enabled: bool) -> tuple[bool, bool]:
  129. open_route(adb, package, LAB_ROUTE)
  130. lab_was_enabled = switch_state(adb, LAB_LABELS, True)
  131. compose_was_enabled = switch_state(adb, COMPOSE_LABELS, enabled)
  132. return lab_was_enabled, compose_was_enabled
  133. def restore_lab_state(adb: Adb, package: str, original: tuple[bool, bool] | None) -> None:
  134. if original is None:
  135. return
  136. lab_enabled, compose_enabled = original
  137. open_route(adb, package, LAB_ROUTE)
  138. switch_state(adb, LAB_LABELS, True)
  139. switch_state(adb, COMPOSE_LABELS, compose_enabled)
  140. switch_state(adb, LAB_LABELS, lab_enabled)
  141. def capture(adb: Adb, path: Path) -> None:
  142. path.write_bytes(adb.bytes("exec-out", "screencap", "-p"))
  143. def png_rows(path: Path) -> tuple[int, int, int, list[bytearray]]:
  144. data = path.read_bytes()
  145. if not data.startswith(PNG_SIGNATURE):
  146. raise BaselineError(f"不是 PNG:{path}")
  147. pos = len(PNG_SIGNATURE)
  148. width = height = bit_depth = color_type = None
  149. compressed = bytearray()
  150. while pos < len(data):
  151. length = struct.unpack(">I", data[pos:pos + 4])[0]
  152. kind = data[pos + 4:pos + 8]
  153. payload = data[pos + 8:pos + 8 + length]
  154. pos += 12 + length
  155. if kind == b"IHDR":
  156. width, height, bit_depth, color_type, compression, filtering, interlace = struct.unpack(
  157. ">IIBBBBB", payload
  158. )
  159. if bit_depth != 8 or color_type not in (2, 6) or compression or filtering or interlace:
  160. raise BaselineError(f"不支持的 PNG 格式:{path}")
  161. elif kind == b"IDAT":
  162. compressed.extend(payload)
  163. elif kind == b"IEND":
  164. break
  165. if width is None or height is None or color_type is None:
  166. raise BaselineError(f"PNG 缺少 IHDR:{path}")
  167. channels = 4 if color_type == 6 else 3
  168. stride = width * channels
  169. raw = zlib.decompress(compressed)
  170. rows: list[bytearray] = []
  171. offset = 0
  172. for _ in range(height):
  173. filter_type = raw[offset]
  174. offset += 1
  175. row = bytearray(raw[offset:offset + stride])
  176. offset += stride
  177. previous = rows[-1] if rows else bytearray(stride)
  178. for index, value in enumerate(row):
  179. left = row[index - channels] if index >= channels else 0
  180. up = previous[index]
  181. up_left = previous[index - channels] if index >= channels else 0
  182. if filter_type == 1:
  183. row[index] = (value + left) & 0xFF
  184. elif filter_type == 2:
  185. row[index] = (value + up) & 0xFF
  186. elif filter_type == 3:
  187. row[index] = (value + ((left + up) // 2)) & 0xFF
  188. elif filter_type == 4:
  189. p = left + up - up_left
  190. pa, pb, pc = abs(p - left), abs(p - up), abs(p - up_left)
  191. row[index] = (value + (left if pa <= pb and pa <= pc else up if pb <= pc else up_left)) & 0xFF
  192. elif filter_type != 0:
  193. raise BaselineError(f"不支持的 PNG filter:{filter_type}")
  194. rows.append(row)
  195. return width, height, channels, rows
  196. def compare_pngs(legacy: Path, compose: Path, crop: tuple[int, int, int, int] | None) -> dict[str, float | int | list[int]]:
  197. lw, lh, lc, legacy_rows = png_rows(legacy)
  198. cw, ch, cc, compose_rows = png_rows(compose)
  199. if (lw, lh) != (cw, ch):
  200. raise BaselineError("Legacy 与 Compose 截图尺寸不同,无法比较")
  201. left, top, right, bottom = crop or (0, 0, lw, lh)
  202. if not (0 <= left < right <= lw and 0 <= top < bottom <= lh):
  203. raise BaselineError(f"无效裁剪区域:{crop}")
  204. total_difference = 0
  205. changed_pixels = 0
  206. pixels = (right - left) * (bottom - top)
  207. for y in range(top, bottom):
  208. legacy_row, compose_row = legacy_rows[y], compose_rows[y]
  209. for x in range(left, right):
  210. legacy_offset, compose_offset = x * lc, x * cc
  211. differences = [abs(legacy_row[legacy_offset + channel] - compose_row[compose_offset + channel]) for channel in range(3)]
  212. total_difference += sum(differences)
  213. if max(differences) > 8:
  214. changed_pixels += 1
  215. return {
  216. "crop": [left, top, right, bottom],
  217. "meanAbsoluteChannelDifference": total_difference / (pixels * 3),
  218. "changedPixelCount": changed_pixels,
  219. "changedPixelPercent": changed_pixels * 100 / pixels,
  220. }
  221. def parse_crop(value: str | None) -> tuple[int, int, int, int] | None:
  222. if value is None:
  223. return None
  224. values = [int(item) for item in value.split(",")]
  225. if len(values) != 4:
  226. raise argparse.ArgumentTypeError("--crop 采用 left,top,right,bottom")
  227. return tuple(values) # type: ignore[return-value]
  228. def parse_framestats(path: Path) -> dict[str, int | float]:
  229. text = path.read_text(encoding="utf-8")
  230. def required(pattern: str, label: str) -> str:
  231. match = re.search(pattern, text)
  232. if not match:
  233. raise BaselineError(f"无法从 {path.name} 解析 {label}")
  234. return match.group(1)
  235. return {
  236. "totalFrames": int(required(r"Total frames rendered:\s+(\d+)", "总帧数")),
  237. "jankyFrames": int(required(r"Janky frames:\s+(\d+)", "卡顿帧数")),
  238. "jankyPercent": float(required(r"Janky frames:\s+\d+\s+\(([\d.]+)%\)", "卡顿率")),
  239. "p50Ms": int(required(r"50th percentile:\s+(\d+)ms", "P50")),
  240. "p90Ms": int(required(r"90th percentile:\s+(\d+)ms", "P90")),
  241. "p95Ms": int(required(r"95th percentile:\s+(\d+)ms", "P95")),
  242. "p99Ms": int(required(r"99th percentile:\s+(\d+)ms", "P99")),
  243. }
  244. def run_mode(adb: Adb, args: argparse.Namespace, output: Path, mode: str, size: tuple[int, int]) -> dict[str, object]:
  245. set_compose_renderer(adb, args.package, mode == "compose")
  246. book_extra = ("bookUrl", args.book_url) if args.book_url else None
  247. open_route(adb, args.package, READ_ROUTE, book_extra)
  248. time.sleep(args.settle_seconds)
  249. visible_texts = {node.attrib.get("text") for node in dump_ui(adb).iter("node")}
  250. if any(label in visible_texts for label in (*LAB_LABELS, *COMPOSE_LABELS)):
  251. raise BaselineError(f"{mode} 模式未进入阅读页,仍停留在实验室设置")
  252. plain = output / f"plain-page-{mode}.png"
  253. capture(adb, plain)
  254. adb.run("shell", "dumpsys", "gfxinfo", args.package, "reset")
  255. next_x, next_y = int(size[0] * args.next_x_ratio), int(size[1] * args.tap_y_ratio)
  256. prev_x, prev_y = int(size[0] * args.prev_x_ratio), int(size[1] * args.tap_y_ratio)
  257. forward = output / f"forward-turn-{mode}.png"
  258. forward_difference: dict[str, float | int | list[int]] | None = None
  259. for round_index in range(args.rounds):
  260. for _ in range(args.turns):
  261. adb.run("shell", "input", "tap", str(next_x), str(next_y))
  262. time.sleep(args.tap_delay)
  263. if round_index == 0:
  264. time.sleep(args.direction_settle_seconds)
  265. capture(adb, forward)
  266. forward_difference = compare_pngs(plain, forward, args.crop)
  267. if forward_difference["changedPixelPercent"] < 1.0:
  268. raise BaselineError(f"{mode} 模式前进 {args.turns} 页后画面未发生有效变化")
  269. for _ in range(args.turns):
  270. adb.run("shell", "input", "tap", str(prev_x), str(prev_y))
  271. time.sleep(args.tap_delay)
  272. time.sleep(args.settle_seconds)
  273. rapid = output / f"rapid-turn-{mode}.png"
  274. capture(adb, rapid)
  275. return_difference = compare_pngs(plain, rapid, args.crop)
  276. if return_difference["changedPixelPercent"] > 1.0:
  277. raise BaselineError(
  278. f"{mode} 模式完成 {args.rounds} 轮往返后未回到起点:"
  279. f"差异像素 {return_difference['changedPixelPercent']:.2f}%"
  280. )
  281. framestats = output / f"{mode}-framestats.txt"
  282. framestats.write_text(
  283. adb.run("shell", "dumpsys", "gfxinfo", args.package, "framestats"), encoding="utf-8"
  284. )
  285. return {
  286. "plain": plain.name,
  287. "forwardTurn": forward.name,
  288. "rapidTurn": rapid.name,
  289. "plainVsForward": forward_difference,
  290. "plainVsRapidTurn": return_difference,
  291. "frameStats": parse_framestats(framestats),
  292. }
  293. def main() -> int:
  294. parser = argparse.ArgumentParser(description=__doc__)
  295. parser.add_argument("--book-url", help="书架中可打开的 bookUrl;省略时自动使用最后阅读的书")
  296. parser.add_argument("--serial", help="adb device serial;未指定时要求仅连接一台设备")
  297. parser.add_argument("--package", default=PACKAGE_DEFAULT)
  298. parser.add_argument("--turns", type=int, default=20)
  299. parser.add_argument("--rounds", type=int, default=5)
  300. parser.add_argument("--tap-delay", type=float, default=0.18)
  301. parser.add_argument("--settle-seconds", type=float, default=1.5)
  302. parser.add_argument("--direction-settle-seconds", type=float, default=1.0)
  303. parser.add_argument("--next-x-ratio", type=float, default=0.84)
  304. parser.add_argument("--prev-x-ratio", type=float, default=0.16)
  305. parser.add_argument("--tap-y-ratio", type=float, default=0.50)
  306. parser.add_argument("--crop", type=parse_crop, help="正文比较区域:left,top,right,bottom")
  307. parser.add_argument("--out", type=Path)
  308. args = parser.parse_args()
  309. if args.turns < 1:
  310. parser.error("--turns 必须大于 0")
  311. if args.rounds < 1:
  312. parser.error("--rounds 必须大于 0")
  313. serial = connected_serial(args.serial)
  314. adb = Adb(serial)
  315. require_unlocked(adb)
  316. output = args.out or Path("build/reader-c3-baseline") / datetime.now().strftime("%Y%m%d-%H%M%S")
  317. try:
  318. output.mkdir(parents=True, exist_ok=False)
  319. except FileExistsError as error:
  320. raise BaselineError(f"输出目录已存在:{output};请换一个 --out 路径") from error
  321. original: tuple[bool, bool] | None = None
  322. try:
  323. original = set_compose_renderer(adb, args.package, False)
  324. size = screen_size(adb)
  325. artifacts = {mode: run_mode(adb, args, output, mode, size) for mode in ("legacy", "compose")}
  326. comparisons = {
  327. scenario: compare_pngs(
  328. output / artifacts["legacy"][scenario], output / artifacts["compose"][scenario], args.crop
  329. )
  330. for scenario in ("plain", "rapidTurn")
  331. }
  332. metadata = {
  333. "serial": serial,
  334. "package": args.package,
  335. "bookUrl": args.book_url,
  336. "screen": list(size),
  337. "turns": args.turns,
  338. "rounds": args.rounds,
  339. "crop": args.crop,
  340. "artifacts": artifacts,
  341. "comparisons": comparisons,
  342. "device": adb.run("shell", "getprop").splitlines(),
  343. }
  344. (output / "result.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
  345. print(json.dumps({"output": str(output), "comparisons": comparisons}, ensure_ascii=False, indent=2))
  346. return 0
  347. finally:
  348. try:
  349. restore_lab_state(adb, args.package, original)
  350. except Exception as error: # Preserve the evidence even if settings restoration fails.
  351. print(f"警告:未能还原实验室设置:{error}", file=sys.stderr)
  352. if __name__ == "__main__":
  353. try:
  354. raise SystemExit(main())
  355. except (BaselineError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
  356. print(f"C3 基线采集失败:{error}", file=sys.stderr)
  357. raise SystemExit(2)