adb-scrollshot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """Lossless Android scrolling screenshot capture and stitching."""
2
+
3
+ from .api import capture_screenshot, export_pages, stitch_screenshots
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["__version__", "capture_screenshot", "export_pages", "stitch_screenshots"]
@@ -0,0 +1,3 @@
1
+ from .cli import entrypoint
2
+
3
+ raise SystemExit(entrypoint())
adb_scrollshot/adb.py ADDED
@@ -0,0 +1,54 @@
1
+ """Android device discovery and lossless PNG capture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+
8
+ import cv2
9
+ import numpy as np
10
+
11
+
12
+ def run_adb(prefix: list[str], *args: str, timeout: int = 35) -> bytes:
13
+ cmd = [*prefix, *args]
14
+ try:
15
+ result = subprocess.run(
16
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, timeout=timeout
17
+ )
18
+ except subprocess.TimeoutExpired as exc:
19
+ raise RuntimeError(f"ADB timed out: {' '.join(cmd)}") from exc
20
+ except OSError as exc:
21
+ raise RuntimeError(f"Unable to run adb: {exc}") from exc
22
+ if result.returncode != 0:
23
+ err = result.stderr.decode("utf-8", "replace").strip()
24
+ raise RuntimeError(f"ADB failed: {' '.join(cmd)}\n{err}")
25
+ return result.stdout
26
+
27
+
28
+ def get_adb_prefix(serial: str | None) -> list[str]:
29
+ if shutil.which("adb") is None:
30
+ raise RuntimeError("adb is not installed. On macOS: brew install android-platform-tools")
31
+ raw = run_adb(["adb"], "devices").decode("utf-8", "replace")
32
+ entries = [row.split() for row in raw.splitlines()[1:] if row.strip()]
33
+ online = [parts[0] for parts in entries if len(parts) > 1 and parts[1] == "device"]
34
+ if serial is not None and serial not in online:
35
+ raise RuntimeError(f"Device {serial!r} is not connected/authorized. adb devices:\n{raw}")
36
+ if not online:
37
+ raise RuntimeError(
38
+ "No authorized Android device found. Check adb devices, USB debugging or wireless ADB."
39
+ )
40
+ if serial is None and len(online) != 1:
41
+ raise RuntimeError(
42
+ f"Multiple devices connected ({', '.join(online)}). Pass --serial DEVICE_ID."
43
+ )
44
+ return ["adb", "-s", serial or online[0]]
45
+
46
+
47
+ def get_screenshot(prefix: list[str]) -> tuple[np.ndarray, bytes]:
48
+ data = run_adb(prefix, "exec-out", "screencap", "-p", timeout=60)
49
+ if not data.startswith(b"\x89PNG\r\n\x1a\n"):
50
+ raise RuntimeError("ADB screenshot output was not a valid PNG")
51
+ image = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR)
52
+ if image is None:
53
+ raise RuntimeError("Unable to decode screenshot PNG")
54
+ return image, data
adb_scrollshot/api.py ADDED
@@ -0,0 +1,118 @@
1
+ """Public functions for lossless capture and stitching from Python."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ import numpy as np
10
+
11
+ from .adb import get_adb_prefix, get_screenshot
12
+ from .images import export_tiles, save_composite
13
+ from .matching import overlapping_shift, unchanged
14
+ from .selection import validate_roi
15
+
16
+
17
+ def _png_path(output: str | Path) -> Path:
18
+ path = Path(output)
19
+ if path.suffix.lower() != ".png":
20
+ raise ValueError("Output must end in .png to preserve lossless quality")
21
+ return path
22
+
23
+
24
+ def _validate_image(image: np.ndarray) -> None:
25
+ if (
26
+ not isinstance(image, np.ndarray)
27
+ or image.dtype != np.uint8
28
+ or image.ndim != 3
29
+ or image.shape[2] != 3
30
+ or min(image.shape[:2]) < 1
31
+ ):
32
+ raise ValueError("Expected a nonempty uint8 BGR image with shape (height, width, 3)")
33
+
34
+
35
+ def capture_screenshot(
36
+ output: str | Path | None = None, *, serial: str | None = None
37
+ ) -> np.ndarray:
38
+ """Capture a native-resolution BGR image; optionally save the original PNG.
39
+
40
+ Requires ADB on PATH and an authorized, unlocked Android phone. Raises
41
+ RuntimeError on device errors, ValueError for invalid output, and OSError
42
+ for file errors. Does not scroll or change phone settings.
43
+ """
44
+ path = _png_path(output) if output is not None else None
45
+ image, png = get_screenshot(get_adb_prefix(serial))
46
+ if path is not None:
47
+ path.parent.mkdir(parents=True, exist_ok=True)
48
+ path.write_bytes(png)
49
+ return image
50
+
51
+
52
+ def export_pages(image: np.ndarray, output: str | Path, *, tile_height: int = 3120) -> list[Path]:
53
+ """Save native-resolution sections to <output-stem>_pages/page_0001.png, etc.
54
+
55
+ The output argument names the reference PNG, not a directory. This function
56
+ writes only the sections. Use a fresh stem to avoid files from earlier runs.
57
+ """
58
+ path = _png_path(output)
59
+ _validate_image(image)
60
+ if tile_height < 1:
61
+ raise ValueError("tile_height must be positive")
62
+ return export_tiles(image, path, tile_height)
63
+
64
+
65
+ def stitch_screenshots(
66
+ frames: Iterable[np.ndarray],
67
+ output: str | Path,
68
+ *,
69
+ roi: tuple[int, int, int, int] | None = None,
70
+ layout: Literal["continuous", "full-first"] = "continuous",
71
+ min_score: float = 0.80,
72
+ ) -> Path:
73
+ """Stitch ordered screenshots at their captured resolution and save a PNG.
74
+
75
+ Frames are uint8 BGR arrays (as returned by OpenCV), captured top-to-bottom
76
+ with overlapping content. ROI is (x, y, width, height) in source pixels;
77
+ omit it only when the entire screen scrolls. Fixed UI must be outside ROI.
78
+ Identical consecutive frames are ignored. This function never operates the
79
+ phone; use the CLI for automatic swiping and capture.
80
+
81
+ Raises ValueError for empty/invalid input or an unsafe overlap. No output
82
+ is written unless all input frames are accepted. Holds appended strips in
83
+ memory. Existing output is overwritten on success.
84
+ """
85
+ path = _png_path(output)
86
+ if layout not in ("continuous", "full-first"):
87
+ raise ValueError("layout must be continuous or full-first")
88
+ if not 0 < min_score <= 1:
89
+ raise ValueError("min_score must be in (0, 1]")
90
+ iterator = iter(frames)
91
+ try:
92
+ first = next(iterator)
93
+ except StopIteration as exc:
94
+ raise ValueError("At least one screenshot is required") from exc
95
+ _validate_image(first)
96
+ first = first.copy()
97
+ height, width = first.shape[:2]
98
+ rect = validate_roi(first, roi if roi is not None else (0, 0, width, height))
99
+ x, y, w, h = rect
100
+ previous = first[y : y + h, x : x + w].copy()
101
+ strips = []
102
+ for index, frame in enumerate(iterator, start=2):
103
+ _validate_image(frame)
104
+ if frame.shape != first.shape:
105
+ raise ValueError(f"Screenshot {index} has a different size or orientation")
106
+ current = frame[y : y + h, x : x + w]
107
+ if unchanged(previous, current):
108
+ continue
109
+ match = overlapping_shift(previous, current, min_score=min_score)
110
+ if match is None:
111
+ raise ValueError(f"Cannot safely match overlap for screenshot {index}")
112
+ shift = match[0]
113
+ extra = first[y + h - shift : y + h].copy()
114
+ extra[:, x : x + w] = current[h - shift : h]
115
+ strips.append(extra)
116
+ previous = current.copy()
117
+ save_composite(first, rect, strips, path, layout)
118
+ return path
adb_scrollshot/cli.py ADDED
@@ -0,0 +1,273 @@
1
+ """Capture lossless Android scrolling screenshots with automatic overlap removal.
2
+
3
+ Start with the phone unlocked at the top of a page. Select only the scrolling
4
+ viewport, excluding fixed headers and footers. Outputs retain captured pixels.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+ import time
12
+ from pathlib import Path
13
+
14
+ import cv2
15
+ import numpy as np
16
+
17
+ from . import __version__
18
+ from .adb import get_adb_prefix, get_screenshot, run_adb
19
+ from .images import export_tiles, save_composite
20
+ from .matching import overlapping_shift, unchanged
21
+ from .selection import choose_roi, validate_roi
22
+
23
+
24
+ def parse_roi(value: str) -> tuple[int, int, int, int]:
25
+ try:
26
+ values = tuple(int(v.strip()) for v in value.split(","))
27
+ except ValueError as exc:
28
+ raise argparse.ArgumentTypeError("Use --roi x,y,width,height") from exc
29
+ if len(values) != 4 or any(v < 0 for v in values[:2]) or min(values[2:]) <= 0:
30
+ raise argparse.ArgumentTypeError("Use --roi x,y,width,height with positive dimensions")
31
+ return values
32
+
33
+
34
+ def main(argv: list[str] | None = None) -> int:
35
+ p = argparse.ArgumentParser(
36
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
37
+ )
38
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
39
+ choose = p.add_mutually_exclusive_group()
40
+ choose.add_argument(
41
+ "--select",
42
+ action="store_true",
43
+ help="Draw a rectangle around the scrollable part (default)",
44
+ )
45
+ choose.add_argument(
46
+ "--roi", type=parse_roi, help="Scrollable rectangle in phone pixels: x,y,width,height"
47
+ )
48
+ choose.add_argument(
49
+ "--whole-screen", action="store_true", help="Treat full phone screen as scrollable"
50
+ )
51
+ p.add_argument("--output", type=Path, default=Path("captures/long_screenshot.png"))
52
+ p.add_argument(
53
+ "--tile-height",
54
+ type=int,
55
+ default=0,
56
+ help="Also export full-resolution PNG pages of this height (e.g. 3120); 0 disables",
57
+ )
58
+ p.add_argument(
59
+ "--layout",
60
+ choices=["continuous", "full-first"],
61
+ default="continuous",
62
+ help="continuous: footer only at bottom; full-first: exact original screenshot at top",
63
+ )
64
+ p.add_argument("--serial", help="ADB device serial (required with multiple connected devices)")
65
+ p.add_argument("--max-swipes", type=int, default=45, help="Safety cap (default 45)")
66
+ p.add_argument(
67
+ "--swipe-duration", type=int, default=650, help="Swipe length in milliseconds (default 650)"
68
+ )
69
+ p.add_argument(
70
+ "--pause", type=float, default=0.85, help="Seconds to wait after each swipe (default 0.85)"
71
+ )
72
+ p.add_argument(
73
+ "--swipe-x", type=int, help="Override swipe horizontal coordinate in device pixels"
74
+ )
75
+ p.add_argument(
76
+ "--stop-after",
77
+ type=int,
78
+ default=2,
79
+ help="Consecutive unchanged screenshots to stop (default 2)",
80
+ )
81
+ p.add_argument(
82
+ "--keep-frames", action="store_true", help="Save every lossless PNG in a *_frames folder"
83
+ )
84
+ p.add_argument(
85
+ "--preview-height", type=int, default=760, help="Selection preview max height (default 760)"
86
+ )
87
+ p.add_argument(
88
+ "--min-score", type=float, default=0.80, help="Match confidence 0..1 (default .80)"
89
+ )
90
+ args = p.parse_args(argv)
91
+ if args.output.suffix.lower() != ".png":
92
+ p.error("--output must end in .png to preserve lossless screenshot quality")
93
+ if args.tile_height < 0:
94
+ p.error("--tile-height must be 0 or a positive number of pixels")
95
+ if (
96
+ args.max_swipes < 1
97
+ or args.stop_after < 1
98
+ or args.swipe_duration < 100
99
+ or args.pause < 0
100
+ or args.preview_height < 200
101
+ ):
102
+ p.error(
103
+ "Invalid value for max-swipes, stop-after, swipe-duration, pause, or preview-height"
104
+ )
105
+ if not 0 < args.min_score <= 1:
106
+ p.error("--min-score must be in (0, 1]")
107
+
108
+ prefix = get_adb_prefix(args.serial)
109
+ print(f"Connected to {prefix[-1]}. Keep the desired page open at its TOP.", flush=True)
110
+ first, png = get_screenshot(prefix)
111
+ screen_h, screen_w = first.shape[:2]
112
+ print(f"Native screenshot: {screen_w} x {screen_h} pixels (lossless PNG).", flush=True)
113
+ if args.whole_screen:
114
+ rect = (0, 0, screen_w, screen_h)
115
+ elif args.roi:
116
+ rect = args.roi
117
+ else:
118
+ rect = choose_roi(first, args.preview_height)
119
+ rect = validate_roi(first, rect)
120
+ x, y, w, h = rect
121
+ print(f"Chosen scrolling region: x={x}, y={y}, width={w}, height={h}", flush=True)
122
+
123
+ # Swipes are directed *inside* the scrollable viewport, so fixed toolbars
124
+ # outside the selected region are never touched.
125
+ sx = args.swipe_x if args.swipe_x is not None else x + w // 2
126
+ sy1 = y + int(h * 0.77)
127
+ sy2 = y + int(h * 0.30)
128
+ if not 0 <= sx < screen_w or sy2 < 0 or sy1 >= screen_h or sy1 - sy2 < 80:
129
+ p.error("Computed swipe coordinates invalid. Check ROI / --swipe-x")
130
+ print(f"Auto swipe: ({sx}, {sy1}) -> ({sx}, {sy2}) for {args.swipe_duration} ms", flush=True)
131
+
132
+ frame_dir = args.output.parent / f"{args.output.stem}_frames"
133
+ if args.keep_frames:
134
+ frame_dir.mkdir(parents=True, exist_ok=True)
135
+ (frame_dir / "0000.png").write_bytes(png)
136
+
137
+ previous = first[y : y + h, x : x + w].copy()
138
+ strips: list[np.ndarray] = []
139
+ stable_count = 0
140
+ completed = False
141
+ stopped_for_error = False
142
+ accepted = 0
143
+ total_scrolled = 0
144
+ print(
145
+ "Capturing and stitching automatically. Press Ctrl+C to stop and save progress.\n",
146
+ flush=True,
147
+ )
148
+ try:
149
+ for step in range(1, args.max_swipes + 1):
150
+ run_adb(
151
+ prefix,
152
+ "shell",
153
+ "input",
154
+ "swipe",
155
+ str(sx),
156
+ str(sy1),
157
+ str(sx),
158
+ str(sy2),
159
+ str(args.swipe_duration),
160
+ )
161
+ time.sleep(args.pause)
162
+ new, raw = get_screenshot(prefix)
163
+ if new.shape != first.shape:
164
+ print(
165
+ "Display resolution/orientation changed. Stopping to avoid corrupt image.",
166
+ file=sys.stderr,
167
+ )
168
+ stopped_for_error = True
169
+ break
170
+ if args.keep_frames:
171
+ (frame_dir / f"{step:04d}.png").write_bytes(raw)
172
+ current = new[y : y + h, x : x + w]
173
+ if unchanged(previous, current):
174
+ stable_count += 1
175
+ print(
176
+ f" Swipe {step:02}: no movement ({stable_count}/{args.stop_after}).",
177
+ flush=True,
178
+ )
179
+ if stable_count >= args.stop_after:
180
+ completed = True
181
+ break
182
+ continue
183
+ stable_count = 0
184
+ matched = overlapping_shift(previous, current, min_score=args.min_score)
185
+ if matched is None:
186
+ # If screenshots were taken during animation, a delayed capture
187
+ # can fix them. Do not swipe again before resolving this match.
188
+ print(
189
+ f" Swipe {step:02}: overlap unclear; retrying screenshot after settling...",
190
+ flush=True,
191
+ )
192
+ time.sleep(max(0.8, args.pause))
193
+ settled, settled_png = get_screenshot(prefix)
194
+ if settled.shape == first.shape:
195
+ new, raw = settled, settled_png
196
+ current = new[y : y + h, x : x + w]
197
+ if args.keep_frames:
198
+ (frame_dir / f"{step:04d}.png").write_bytes(raw)
199
+ if unchanged(previous, current):
200
+ stable_count = 1
201
+ print(" No visible movement after settling.", flush=True)
202
+ continue
203
+ matched = overlapping_shift(previous, current, min_score=args.min_score)
204
+ if matched is None:
205
+ print(
206
+ " STOP: could not safely match new content to earlier screenshot.",
207
+ file=sys.stderr,
208
+ )
209
+ print(
210
+ " The partial image will be saved. Try a tighter ROI, slower swipes,",
211
+ file=sys.stderr,
212
+ )
213
+ print(" or a longer --swipe-duration / --pause.", file=sys.stderr)
214
+ stopped_for_error = True
215
+ break
216
+ shift, conf, error = matched
217
+ # Append exactly the freshly revealed strip from the LOSSLESS PNG.
218
+ # Any side margin outside the selected ROI comes from the first
219
+ # screenshot (like the original video-based partial stitcher).
220
+ extra = first[y + h - shift : y + h].copy()
221
+ extra[:, x : x + w] = current[h - shift : h]
222
+ strips.append(extra)
223
+ accepted += 1
224
+ total_scrolled += shift
225
+ previous = current.copy()
226
+ print(
227
+ f" Swipe {step:02}: +{shift} px | match {conf:.3f} | error {error:.2f}", flush=True
228
+ )
229
+ else:
230
+ print(
231
+ f"Maximum of {args.max_swipes} swipes reached; output may be incomplete.",
232
+ file=sys.stderr,
233
+ )
234
+ except (RuntimeError, OSError) as exc:
235
+ print(f"Capture stopped: {exc}. Saving captured content.", file=sys.stderr)
236
+ stopped_for_error = True
237
+ except KeyboardInterrupt:
238
+ print("\nInterrupted. Saving the captured content.", flush=True)
239
+
240
+ out_w, out_h = save_composite(first, rect, strips, args.output, args.layout)
241
+ print(f"\nSaved: {args.output.resolve()}", flush=True)
242
+ print(
243
+ f"Resolution: {out_w} x {out_h} pixels; {accepted} matched swipes; {total_scrolled} added rows.",
244
+ flush=True,
245
+ )
246
+ print("View at 100% / Actual Size to inspect detail. Fit-to-window shrinks long images.")
247
+ if args.tile_height:
248
+ composite = cv2.imread(str(args.output), cv2.IMREAD_COLOR)
249
+ if composite is None:
250
+ raise RuntimeError(f"Could not read saved PNG: {args.output}")
251
+ pages = export_tiles(composite, args.output, args.tile_height)
252
+ print(f"Also saved {len(pages)} full-resolution PNG pages: {pages[0].parent.resolve()}")
253
+ if args.keep_frames:
254
+ print(f"Original PNGs: {frame_dir.resolve()}", flush=True)
255
+ if args.layout == "full-first" and y + h < screen_h and accepted:
256
+ print("Note: full-first places the original bottom bar before the appended content.")
257
+ if completed:
258
+ print("End detected: scrolling stopped changing the selected area.", flush=True)
259
+ if stopped_for_error:
260
+ print(
261
+ "Partial result saved; stitching stopped because matching was unreliable.", flush=True
262
+ )
263
+ return 2
264
+ return 0
265
+
266
+
267
+ def entrypoint() -> int:
268
+ """Present actionable errors without an internal traceback."""
269
+ try:
270
+ return main()
271
+ except (RuntimeError, ValueError, OSError, cv2.error) as exc:
272
+ print(f"Error: {exc}", file=sys.stderr)
273
+ return 1
@@ -0,0 +1,46 @@
1
+ """Compose and export native-resolution PNG images."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import cv2
8
+ import numpy as np
9
+
10
+
11
+ def save_composite(
12
+ first: np.ndarray,
13
+ rect: tuple[int, int, int, int],
14
+ additions: list[np.ndarray],
15
+ output: Path,
16
+ layout: str,
17
+ ) -> tuple[int, int]:
18
+ x, y, rw, rh = rect
19
+ parts: list[np.ndarray] = [first.copy() if layout == "full-first" else first[: y + rh].copy()]
20
+ parts.extend(additions)
21
+ if layout == "continuous" and y + rh < first.shape[0]:
22
+ parts.append(first[y + rh :].copy())
23
+ height = sum(part.shape[0] for part in parts)
24
+ width = first.shape[1]
25
+ if height > 2**31 - 1:
26
+ raise RuntimeError("Image height exceeds supported limits; decrease --max-swipes")
27
+ composite = np.concatenate(parts, axis=0)
28
+ output.parent.mkdir(parents=True, exist_ok=True)
29
+ if not cv2.imwrite(str(output), composite, [cv2.IMWRITE_PNG_COMPRESSION, 6]):
30
+ raise RuntimeError(f"Could not save PNG to {output}")
31
+ return width, height
32
+
33
+
34
+ def export_tiles(image: np.ndarray, output: Path, tile_height: int) -> list[Path]:
35
+ """Split into native-resolution PNGs without resampling or losing any rows."""
36
+ folder = output.parent / f"{output.stem}_pages"
37
+ folder.mkdir(parents=True, exist_ok=True)
38
+ paths = []
39
+ for index, top in enumerate(range(0, image.shape[0], tile_height), start=1):
40
+ path = folder / f"page_{index:04d}.png"
41
+ if not cv2.imwrite(
42
+ str(path), image[top : top + tile_height], [cv2.IMWRITE_PNG_COMPRESSION, 6]
43
+ ):
44
+ raise RuntimeError(f"Could not save PNG to {path}")
45
+ paths.append(path)
46
+ return paths
@@ -0,0 +1,91 @@
1
+ """Detect vertical overlap without scaling the saved pixels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cv2
6
+ import numpy as np
7
+
8
+
9
+ def small_gray(image: np.ndarray) -> tuple[np.ndarray, float]:
10
+ h, w = image.shape[:2]
11
+ scale = min(1.0, 540 / w, 1250 / h)
12
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
13
+ if scale < 1:
14
+ gray = cv2.resize(
15
+ gray, (max(1, round(w * scale)), max(1, round(h * scale))), interpolation=cv2.INTER_AREA
16
+ )
17
+ # Return actual y scaling, not ideal scaling after rounding dimensions.
18
+ return gray, gray.shape[0] / h
19
+
20
+
21
+ def unchanged(old: np.ndarray, new: np.ndarray) -> bool:
22
+ """Check for end of scrolling, tolerating a small animation / indicator."""
23
+ a, _ = small_gray(old)
24
+ b, _ = small_gray(new)
25
+ if a.shape != b.shape:
26
+ return False
27
+ # Ignore a little of the sides (scrollbars/selection highlights).
28
+ w = a.shape[1]
29
+ x0, x1 = int(w * 0.08), max(int(w * 0.92), int(w * 0.08) + 1)
30
+ diff = cv2.absdiff(a[:, x0:x1], b[:, x0:x1])
31
+ return float(np.mean(diff)) < 1.0 and float(np.mean(diff > 12)) < 0.008
32
+
33
+
34
+ def overlapping_shift(
35
+ old: np.ndarray,
36
+ new: np.ndarray,
37
+ min_scroll: int = 8,
38
+ min_score: float = 0.80,
39
+ max_error: float = 17.0,
40
+ ) -> tuple[int, float, float] | None:
41
+ """Find upward movement by exact-resolution row alignment.
42
+
43
+ Shrinks ONLY the horizontal sampling density for faster template matching;
44
+ vertical positions are never scaled, to avoid misaligning repeated chat
45
+ messages, lines, or lists by one or more line heights.
46
+ """
47
+ h, w = old.shape[:2]
48
+ if old.shape != new.shape:
49
+ return None
50
+ left = int(0.12 * w)
51
+ right = max(left + 1, int(0.88 * w))
52
+ # ADB captures native-res PNGs. Avoid vertically resizing pixel positions.
53
+ stride_x = max(1, (right - left) // 190)
54
+ oa = cv2.cvtColor(old, cv2.COLOR_BGR2GRAY)[:, left:right:stride_x]
55
+ nb = cv2.cvtColor(new, cv2.COLOR_BGR2GRAY)[:, left:right:stride_x]
56
+ max_shift = int(0.80 * h)
57
+ candidate_scores: dict[int, float] = {}
58
+ for rel_y in (0.055, 0.14, 0.27, 0.38):
59
+ anchor = int(h * rel_y)
60
+ th = min(max(35, int(0.14 * h)), h - anchor - 3)
61
+ if th < 20:
62
+ continue
63
+ templ = nb[anchor : anchor + th]
64
+ if float(np.std(templ)) < 8:
65
+ continue
66
+ scores = cv2.matchTemplate(oa, templ, cv2.TM_CCOEFF_NORMED)[:, 0]
67
+ local = cv2.dilate(scores.reshape(-1, 1), np.ones((9, 1), np.uint8))[:, 0]
68
+ peaks = np.where((scores >= min_score) & (scores >= local - 1e-6))[0]
69
+ peaks = sorted(peaks, key=lambda iy: float(scores[iy]), reverse=True)[:10]
70
+ for loc_y in peaks:
71
+ shift = int(loc_y) - anchor
72
+ if min_scroll <= shift <= max_shift:
73
+ candidate_scores[shift] = max(
74
+ candidate_scores.get(shift, 0.0), float(scores[loc_y])
75
+ )
76
+ if not candidate_scores:
77
+ return None
78
+
79
+ # Compare all feature-containing rows, in the native vertical pixel grid.
80
+ # One correct alignment should have nearly identical pixel values, even
81
+ # when different rows contain repeated UI patterns.
82
+ trials: list[tuple[float, float, int]] = []
83
+ for shift, score in candidate_scores.items():
84
+ # Robust to a small amount of live text, clocks, cursor blinking, etc.
85
+ diff = cv2.absdiff(oa[shift:], nb[: h - shift])
86
+ error = float(np.mean(diff))
87
+ trials.append((error, -score, shift))
88
+ error, neg_score, shift = min(trials)
89
+ if error > max_error:
90
+ return None
91
+ return shift, -neg_score, error
@@ -0,0 +1,44 @@
1
+ """Select and validate the scrolling viewport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cv2
6
+ import numpy as np
7
+
8
+
9
+ def choose_roi(first: np.ndarray, preview_height: int) -> tuple[int, int, int, int]:
10
+ height, width = first.shape[:2]
11
+ scale = min(1.0, preview_height / height, 1000.0 / width)
12
+ preview = (
13
+ cv2.resize(
14
+ first,
15
+ (max(1, round(width * scale)), max(1, round(height * scale))),
16
+ interpolation=cv2.INTER_AREA,
17
+ )
18
+ if scale < 1
19
+ else first
20
+ )
21
+ caption = "Select ONLY scrolling area, ENTER to accept, ESC to cancel"
22
+ try:
23
+ selected = cv2.selectROI(caption, preview, showCrosshair=True, fromCenter=False)
24
+ cv2.destroyAllWindows()
25
+ except cv2.error as exc:
26
+ raise RuntimeError(
27
+ f"Cannot open selection window ({exc}). Use --roi x,y,width,height instead."
28
+ ) from exc
29
+ sx, sy, sw, sh = map(int, selected)
30
+ if sw <= 0 or sh <= 0:
31
+ raise RuntimeError("No scrolling area selected. Please retry.")
32
+ x = max(0, round(sx / scale))
33
+ y = max(0, round(sy / scale))
34
+ w = min(width - x, round(sw / scale))
35
+ h = min(height - y, round(sh / scale))
36
+ return x, y, w, h
37
+
38
+
39
+ def validate_roi(image: np.ndarray, rect: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
40
+ h, w = image.shape[:2]
41
+ x, y, rw, rh = rect
42
+ if x < 0 or y < 0 or rw < 80 or rh < 160 or x + rw > w or y + rh > h:
43
+ raise ValueError(f"Invalid ROI {rect}; screen is {w}x{h}. Use at least 80x160.")
44
+ return rect
@@ -0,0 +1,204 @@
1
+ Metadata-Version: 2.5
2
+ Name: adb-scrollshot
3
+ Version: 0.1.0
4
+ Summary: Lossless Android scrolling screenshots with automatic capture and overlap removal
5
+ Project-URL: Repository, https://github.com/vishalnagda1/adb-scrollshot
6
+ Project-URL: Documentation, https://github.com/vishalnagda1/adb-scrollshot/blob/main/README.md
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: adb,android,image-stitching,screenshot,scrolling
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: Multimedia :: Graphics :: Capture :: Screen Capture
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: numpy<3,>=2
16
+ Requires-Dist: opencv-python<6,>=4.10
17
+ Description-Content-Type: text/markdown
18
+
19
+ # ADB Scrollshot
20
+
21
+ Capture a sharp, continuous scrolling screenshot from an Android phone. The
22
+ command automatically swipes, captures lossless PNG screenshots, removes
23
+ matching overlap, and stops when the selected content stops moving.
24
+
25
+ The final image keeps the original captured pixel resolution. Optional shorter
26
+ PNG pages make long captures easier to view and share.
27
+
28
+ ## Requirements
29
+
30
+ - Python 3.11 or newer and [uv](https://docs.astral.sh/uv/).
31
+ - Android Platform Tools (`adb`) on your PATH.
32
+ - An unlocked Android phone with USB debugging enabled and this computer authorized.
33
+ - A desktop session for interactive area selection; use `--roi` otherwise.
34
+
35
+ On macOS, install ADB with `brew install android-platform-tools`. On other
36
+ platforms, install Android Platform Tools and add it to PATH. Confirm that
37
+ `adb devices` lists your phone with the state `device`, not `unauthorized`.
38
+ `adb` is a separate system prerequisite and is not included in this package.
39
+
40
+ ## Install from PyPI
41
+
42
+ ```sh
43
+ python -m pip install adb-scrollshot
44
+ adb-scrollshot --select --keep-frames --tile-height 3120 --output captures/high_quality.png
45
+ ```
46
+
47
+ For an isolated command-line installation, use `uv tool install adb-scrollshot`.
48
+ For Python programs, install into your project's virtual environment and import
49
+ `adb_scrollshot`. Python 3.11 or newer is required.
50
+
51
+ ## Quick start
52
+
53
+ From this project directory:
54
+
55
+ ```sh
56
+ uv sync --locked
57
+ uv run adb-scrollshot --select --keep-frames --tile-height 3120 --output captures/high_quality.png
58
+ ```
59
+
60
+ 1. Open the desired page on your phone and return to its top.
61
+ 2. Run the command. Draw a rectangle around **only the scrolling content**,
62
+ excluding fixed toolbars, status bars, navigation bars, and sticky headers.
63
+ 3. Press **Enter**. Leave the phone untouched while it scrolls and captures.
64
+ 4. Find `captures/high_quality.png`, its original screenshots in
65
+ `captures/high_quality_frames/`, and shorter PNGs in `captures/high_quality_pages/`.
66
+
67
+ `scrcpy` is optional; it can mirror the phone while ADB captures directly.
68
+ Without `--output`, the destination is `captures/long_screenshot.png`.
69
+ Use a fresh output name for each run; existing output files may be overwritten.
70
+ Use a fresh name especially with frame/page export to avoid leftover files
71
+ from a previous, longer capture.
72
+
73
+ The original launcher still works:
74
+
75
+ ```sh
76
+ uv run python adb_scrollshot.py --select --keep-frames --tile-height 3120
77
+ ```
78
+
79
+ You can also use `uv run python -m adb_scrollshot --help`.
80
+
81
+ ## Common options
82
+
83
+ Run `uv run adb-scrollshot --help` for all options.
84
+
85
+ | Option | Purpose |
86
+ | --- | --- |
87
+ | `--select` | Draw the scrolling area; this is the default selection mode. |
88
+ | `--roi 0,250,1440,2500` | Specify x, y, width, height in source screenshot pixels. |
89
+ | `--whole-screen` | Use only if the entire screen scrolls without fixed UI. |
90
+ | `--serial DEVICE_ID` | Choose one device when several are connected. |
91
+ | `--keep-frames` | Keep original PNGs for inspection or troubleshooting. |
92
+ | `--tile-height 3120` | Also export native-resolution sections, at most 3120 pixels tall. |
93
+ | `--pause 1.5` | Wait longer for rendering after each swipe. |
94
+ | `--swipe-duration 900` | Make the upward swipe slower, in milliseconds. |
95
+ | `--max-swipes 80` | Raise the safety limit; the default is 45. |
96
+ | `--layout full-first` | Preserve the entire initial screen, including its footer, before appended content. |
97
+
98
+ The default `continuous` layout places the initial fixed header at the top and
99
+ its fixed footer at the end. When the selected region is narrower than the
100
+ screen, appended side margins are copied from the first screenshot; select the
101
+ full content width to avoid repeating unrelated side content.
102
+
103
+ ## Quality and limits
104
+
105
+ View the PNG at **Actual Size / 100%** in your image viewer. Fitting a very tall
106
+ image into a window can make sharp text appear blurry. Some messaging apps
107
+ also shrink images; send the PNG as a file or share the shorter pages.
108
+
109
+ The area-selection preview is reduced for convenience, but the saved content
110
+ is not resized. PNG compression is lossless. Upscaling or recording at a higher
111
+ video bitrate cannot add detail missing from the source screenshots.
112
+
113
+ Overlap matching assumes vertically translating, static content. Animations,
114
+ video, sticky elements inside the selection, repeated patterns, or app layout
115
+ changes can prevent reliable matching. The tool stops and saves a partial PNG
116
+ when it cannot match safely. Page boundaries may cut through a line of text;
117
+ the continuous PNG remains available. Very long captures consume memory.
118
+
119
+ - **Ctrl+C:** saves progress captured so far.
120
+ - **Connection loss during scrolling:** attempts to save progress and exits with code 2.
121
+ - **Exit 0:** normal end, user interruption, or swipe limit reached; read the
122
+ terminal message to see whether the bottom was actually detected.
123
+ - **Exit 1:** setup or output error; **exit 2:** invalid arguments or a partial
124
+ capture caused by unreliable matching, display changes, or connection failure.
125
+
126
+ No image is uploaded by this application. Captures can contain private screen
127
+ content; they and recordings are excluded from Git and release archives.
128
+
129
+ ## Development
130
+
131
+ ```sh
132
+ uv sync --locked
133
+ uv run pytest
134
+ uv run ruff check .
135
+ uv run ruff format --check .
136
+ uv build
137
+ uv run python scripts/package_release.py
138
+ ```
139
+
140
+ The lockfile pins dependencies. CI checks Python 3.11 and 3.14 on Linux and builds
141
+ artifacts. The automated tests use synthetic screenshots and simulated ADB;
142
+ physical-device behavior still needs a manual smoke test.
143
+
144
+ ```text
145
+ src/adb_scrollshot/
146
+ adb.py Device discovery, commands, and lossless screen capture
147
+ cli.py Argument parsing and automatic capture loop
148
+ images.py PNG composition and page export
149
+ matching.py Overlap matching and end detection
150
+ selection.py Interactive region selection and validation
151
+ __main__.py Python module entry point
152
+ tests/ Device-free regression tests
153
+ scripts/ Source-delivery ZIP builder
154
+ docs/ Delivery, installation, and manual validation guide
155
+ .github/workflows/ci.yml
156
+ ```
157
+
158
+ See [docs/DELIVERY.md](docs/DELIVERY.md) for installation from a wheel, release
159
+ contents, a manual acceptance checklist, and Git handoff commands.
160
+
161
+ ## Use as a Python library
162
+
163
+ Install with `python -m pip install adb-scrollshot` and import `adb_scrollshot`.
164
+ You can also install the supplied wheel with
165
+ `python -m pip install ./dist/adb_scrollshot-0.1.0-py3-none-any.whl`, or run these
166
+ examples inside this checkout with `uv run python`.
167
+
168
+ Capture a single lossless screen, without scrolling:
169
+
170
+ ```python
171
+ from adb_scrollshot import capture_screenshot
172
+
173
+ image = capture_screenshot("captures/screen.png")
174
+ print(image.shape) # (height, width, 3), uint8 BGR pixels
175
+ ```
176
+
177
+ Stitch screenshots captured in top-to-bottom order (each must overlap the last):
178
+
179
+ ```python
180
+ import cv2
181
+ from adb_scrollshot import export_pages, stitch_screenshots
182
+
183
+ frames = [cv2.imread(path) for path in ["screen1.png", "screen2.png", "screen3.png"]]
184
+ output = stitch_screenshots(
185
+ frames,
186
+ "captures/stitched.png",
187
+ roi=(0, 250, 1440, 2500), # adapt to your screenshots; exclude fixed UI
188
+ )
189
+ pages = export_pages(cv2.imread(str(output)), output, tile_height=3120)
190
+ ```
191
+
192
+ `stitch_screenshots` accepts an iterable of OpenCV BGR arrays, skips unchanged
193
+ captures, and raises `ValueError` on invalid input or unreliable overlap. It
194
+ writes only after all frames have been accepted. `capture_screenshot` raises
195
+ `RuntimeError` for ADB errors; filesystem errors propagate as `OSError`.
196
+ For automatic scrolling, use the `adb-scrollshot` CLI. ADB is required for live
197
+ capture, but not for stitching existing frames or exporting pages.
198
+
199
+ Public PyPI publishing is configured separately. See
200
+ [docs/PUBLISHING.md](docs/PUBLISHING.md) for the one-time account setup and release procedure.
201
+
202
+ ## License
203
+
204
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ adb_scrollshot/__init__.py,sha256=E5B9VLXs38hlx9fwTcEHtkWCxfC-qUa3BgbmSdD8cNQ,247
2
+ adb_scrollshot/__main__.py,sha256=PIJIOolexzGnrOJz3yXWQZ6eCKD9h2LLAao-qtQ8HYE,60
3
+ adb_scrollshot/adb.py,sha256=RFNa0K_PmkT2VC0aNutgwG1P7OR-lURUtANw0SCZeN0,2187
4
+ adb_scrollshot/api.py,sha256=SfPOWTRWdAYb9xygQ7qqplLDpEEJhs9p8moHJZ8M5kQ,4488
5
+ adb_scrollshot/cli.py,sha256=jkrxuFByXdOu7OTlLilcKO1hCyp_s_HhObIWevrDYIk,11157
6
+ adb_scrollshot/images.py,sha256=B1FGJ7RGBUAkY5zy0334RuvjSxpPW6nkSeyJwFtb4AU,1683
7
+ adb_scrollshot/matching.py,sha256=2BVxfpnEJxRSlrd3NSxJWa5j6IM5iFpJZSfPgcOhzac,3606
8
+ adb_scrollshot/selection.py,sha256=L9-GvvMN-pyWTpkgpE_SRMBBEFGHMgCj0LwrLnEiD2c,1545
9
+ adb_scrollshot-0.1.0.dist-info/METADATA,sha256=vC1Qu76dreCFL_nHs-1cqJQS0hWO2o2YOpjH-ZekEjs,8635
10
+ adb_scrollshot-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
11
+ adb_scrollshot-0.1.0.dist-info/entry_points.txt,sha256=yB8ix9n1mf_VeuTpAmWVSIRbqOLxbXrWjVK0kak4lmQ,65
12
+ adb_scrollshot-0.1.0.dist-info/licenses/LICENSE,sha256=z7wQoYD3QaQdUt1NBQdiEgtvYDgdNFOEBqm-HzB3rDQ,1069
13
+ adb_scrollshot-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ adb-scrollshot = adb_scrollshot.cli:entrypoint
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vishal Nagda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.