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.
- adb_scrollshot/__init__.py +6 -0
- adb_scrollshot/__main__.py +3 -0
- adb_scrollshot/adb.py +54 -0
- adb_scrollshot/api.py +118 -0
- adb_scrollshot/cli.py +273 -0
- adb_scrollshot/images.py +46 -0
- adb_scrollshot/matching.py +91 -0
- adb_scrollshot/selection.py +44 -0
- adb_scrollshot-0.1.0.dist-info/METADATA +204 -0
- adb_scrollshot-0.1.0.dist-info/RECORD +13 -0
- adb_scrollshot-0.1.0.dist-info/WHEEL +4 -0
- adb_scrollshot-0.1.0.dist-info/entry_points.txt +2 -0
- adb_scrollshot-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
adb_scrollshot/images.py
ADDED
|
@@ -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,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.
|