rv565 1.1.0__tar.gz

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.
rv565-1.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ayushman Bhattacharya and Elixpo
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.
rv565-1.1.0/PKG-INFO ADDED
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: rv565
3
+ Version: 1.1.0
4
+ Summary: Read, write and check RV565 frame-independent RGB565 video files; Pillow plugin included.
5
+ Author-email: Ayushman Bhattacharya <ayushman.elixpo@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/elixpo/rv565
8
+ Project-URL: Documentation, https://github.com/elixpo/rv565/tree/main/python#readme
9
+ Project-URL: Paper, https://arxiv.org/abs/2610.35935
10
+ Project-URL: Issues, https://github.com/elixpo/rv565/issues
11
+ Keywords: rgb565,video,esp32,st7789,microcontroller,pillow,arduino
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Multimedia :: Video :: Conversion
16
+ Classifier: Topic :: Software Development :: Embedded Systems
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Provides-Extra: pillow
21
+ Requires-Dist: Pillow>=10.1; extra == "pillow"
22
+ Dynamic: license-file
23
+
24
+ # rv565 for Python
25
+
26
+ Read, write and check [RV565](https://github.com/elixpo/rv565) video files:
27
+ independently zlib-compressed RGB565 frames, the format the RV565 library
28
+ plays at 24 FPS on ESP32-S3 SPI displays
29
+ ([arXiv:2610.35935](https://arxiv.org/abs/2610.35935)).
30
+
31
+ ```bash
32
+ pip install rv565 # core and the `rv565` command, no dependencies
33
+ pip install "rv565[pillow]" # plus the Pillow plugin
34
+ ```
35
+
36
+ ## Command line
37
+
38
+ ```bash
39
+ rv565 encode input.mp4 clip.rv565 --size 180x135 --fps 24 # needs FFmpeg
40
+ rv565 info clip.rv565 # sizes and predicted missed frames on ESP32-S3
41
+ rv565 verify clip.rv565
42
+ rv565 c-array clip.rv565 clip.h # for Arduino sketches
43
+ ```
44
+
45
+ ## Library
46
+
47
+ ```python
48
+ import rv565
49
+
50
+ clip = rv565.open("clip.rv565")
51
+ print(clip.width, clip.height, clip.fps, len(clip))
52
+ frame = clip.frame(0) # RGB565 big-endian bytes, O(1) seek
53
+ report = clip.check_schedule(overlap=True)
54
+ print(report.late_frames) # 0: plays without misses (ESP32-S3 model)
55
+
56
+ rv565.write("out.rv565", 180, 135, 24, frames) # frames: RGB565BE bytes each
57
+ ```
58
+
59
+ ## Pillow
60
+
61
+ ```python
62
+ import rv565.pillow # registers the format
63
+ from PIL import Image, ImageSequence
64
+
65
+ with Image.open("clip.rv565") as im: # mode "RGB", im.n_frames, im.info["fps"]
66
+ frames = [f.copy() for f in ImageSequence.Iterator(im)]
67
+
68
+ frames[0].save("out.rv565", save_all=True, append_images=frames[1:], fps=24)
69
+ ```
70
+
71
+ Any image sequence Pillow can open (GIF, APNG, WebP) can be saved as RV565 the
72
+ same way; colours are reduced to RGB565.
73
+
74
+ ## License
75
+
76
+ MIT.
rv565-1.1.0/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # rv565 for Python
2
+
3
+ Read, write and check [RV565](https://github.com/elixpo/rv565) video files:
4
+ independently zlib-compressed RGB565 frames, the format the RV565 library
5
+ plays at 24 FPS on ESP32-S3 SPI displays
6
+ ([arXiv:2610.35935](https://arxiv.org/abs/2610.35935)).
7
+
8
+ ```bash
9
+ pip install rv565 # core and the `rv565` command, no dependencies
10
+ pip install "rv565[pillow]" # plus the Pillow plugin
11
+ ```
12
+
13
+ ## Command line
14
+
15
+ ```bash
16
+ rv565 encode input.mp4 clip.rv565 --size 180x135 --fps 24 # needs FFmpeg
17
+ rv565 info clip.rv565 # sizes and predicted missed frames on ESP32-S3
18
+ rv565 verify clip.rv565
19
+ rv565 c-array clip.rv565 clip.h # for Arduino sketches
20
+ ```
21
+
22
+ ## Library
23
+
24
+ ```python
25
+ import rv565
26
+
27
+ clip = rv565.open("clip.rv565")
28
+ print(clip.width, clip.height, clip.fps, len(clip))
29
+ frame = clip.frame(0) # RGB565 big-endian bytes, O(1) seek
30
+ report = clip.check_schedule(overlap=True)
31
+ print(report.late_frames) # 0: plays without misses (ESP32-S3 model)
32
+
33
+ rv565.write("out.rv565", 180, 135, 24, frames) # frames: RGB565BE bytes each
34
+ ```
35
+
36
+ ## Pillow
37
+
38
+ ```python
39
+ import rv565.pillow # registers the format
40
+ from PIL import Image, ImageSequence
41
+
42
+ with Image.open("clip.rv565") as im: # mode "RGB", im.n_frames, im.info["fps"]
43
+ frames = [f.copy() for f in ImageSequence.Iterator(im)]
44
+
45
+ frames[0].save("out.rv565", save_all=True, append_images=frames[1:], fps=24)
46
+ ```
47
+
48
+ Any image sequence Pillow can open (GIF, APNG, WebP) can be saved as RV565 the
49
+ same way; colours are reduced to RGB565.
50
+
51
+ ## License
52
+
53
+ MIT.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rv565"
7
+ dynamic = ["version"]
8
+ description = "Read, write and check RV565 frame-independent RGB565 video files; Pillow plugin included."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Ayushman Bhattacharya", email = "ayushman.elixpo@gmail.com" }]
13
+ requires-python = ">=3.9"
14
+ dependencies = []
15
+ keywords = ["rgb565", "video", "esp32", "st7789", "microcontroller", "pillow", "arduino"]
16
+ classifiers = [
17
+ "Development Status :: 5 - Production/Stable",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Multimedia :: Video :: Conversion",
21
+ "Topic :: Software Development :: Embedded Systems",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ pillow = ["Pillow>=10.1"]
26
+
27
+ [project.scripts]
28
+ rv565 = "rv565.cli:main"
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/elixpo/rv565"
32
+ Documentation = "https://github.com/elixpo/rv565/tree/main/python#readme"
33
+ Paper = "https://arxiv.org/abs/2610.35935"
34
+ Issues = "https://github.com/elixpo/rv565/issues"
35
+
36
+ [tool.setuptools.dynamic]
37
+ version = { attr = "rv565.__version__" }
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
rv565-1.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,286 @@
1
+ """RV565: frame-independent RGB565 video files.
2
+
3
+ Read, write and check RV565 files (container version 6), the format played by
4
+ the RV565 microcontroller library (arXiv:2610.35935):
5
+
6
+ import rv565
7
+
8
+ clip = rv565.open("clip.rv565")
9
+ print(clip.width, clip.height, clip.fps, len(clip))
10
+ frame = clip.frame(0) # bytes, RGB565 big-endian
11
+
12
+ rv565.write("out.rv565", 180, 135, 24, frames)
13
+
14
+ With Pillow installed (``pip install rv565[pillow]``), ``import rv565.pillow``
15
+ lets ``PIL.Image`` open and save RV565 files as animations.
16
+ """
17
+
18
+ import struct
19
+ import zlib
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ __all__ = [
24
+ "RV565Error", "Header", "Clip", "CostModel", "ScheduleReport",
25
+ "COST_ESP32S3_180x135", "open", "write", "encode_frames",
26
+ "rgb888_to_rgb565be", "rgb565be_to_rgb888",
27
+ ]
28
+
29
+ __version__ = "1.1.0"
30
+
31
+ MAGIC = b"RV5"
32
+ VERSION = 6
33
+ MAX_FPS = 30
34
+ MAX_FRAMES = 0xFFFF
35
+ HEADER = struct.Struct("<3sBHHBBH") # magic, version, width, height, fps, flags, frames
36
+ LENGTH = struct.Struct("<I")
37
+
38
+
39
+ class RV565Error(ValueError):
40
+ """The data is not a valid RV565 file, or an argument is out of range."""
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Header:
45
+ width: int
46
+ height: int
47
+ fps: int
48
+ flags: int
49
+ frames: int
50
+
51
+ @property
52
+ def frame_bytes(self):
53
+ """Decoded size of one frame: 2 * width * height."""
54
+ return 2 * self.width * self.height
55
+
56
+ @property
57
+ def duration(self):
58
+ """Length in seconds."""
59
+ return self.frames / self.fps
60
+
61
+
62
+ def parse_header(data):
63
+ """Parse and range-check the 12-byte header."""
64
+ if len(data) < HEADER.size:
65
+ raise RV565Error("file too short")
66
+ magic, version, width, height, fps, flags, frames = HEADER.unpack_from(data)
67
+ if magic != MAGIC:
68
+ raise RV565Error("not an RV565 file")
69
+ if version != VERSION:
70
+ raise RV565Error("unsupported RV565 version %d" % version)
71
+ if not (width and height and 1 <= fps <= MAX_FPS and frames):
72
+ raise RV565Error("invalid header")
73
+ return Header(width, height, fps, flags, frames)
74
+
75
+
76
+ class Clip:
77
+ """An RV565 file held in memory, with an O(1) frame index.
78
+
79
+ The whole container is validated on open: every length must be nonzero and
80
+ inside the data, and nothing may follow the last frame.
81
+ """
82
+
83
+ def __init__(self, data):
84
+ self.data = bytes(data)
85
+ self.header = parse_header(self.data)
86
+ offsets, offset = [], HEADER.size
87
+ for i in range(self.header.frames):
88
+ if len(self.data) - offset < LENGTH.size:
89
+ raise RV565Error("truncated frame table at frame %d" % i)
90
+ (length,) = LENGTH.unpack_from(self.data, offset)
91
+ offset += LENGTH.size
92
+ if length == 0 or len(self.data) - offset < length:
93
+ raise RV565Error("invalid length for frame %d" % i)
94
+ offsets.append((offset, length))
95
+ offset += length
96
+ if offset != len(self.data):
97
+ raise RV565Error("trailing bytes after the last frame")
98
+ self._index = offsets
99
+
100
+ width = property(lambda self: self.header.width)
101
+ height = property(lambda self: self.header.height)
102
+ fps = property(lambda self: self.header.fps)
103
+
104
+ def __len__(self):
105
+ return self.header.frames
106
+
107
+ def payload_sizes(self):
108
+ """Compressed length of every frame, in order."""
109
+ return [length for _, length in self._index]
110
+
111
+ def payload(self, i):
112
+ """The zlib payload of frame i."""
113
+ offset, length = self._index[self._check(i)]
114
+ return self.data[offset:offset + length]
115
+
116
+ def frame(self, i):
117
+ """Frame i as RGB565 big-endian bytes (2 * width * height of them)."""
118
+ try:
119
+ frame = zlib.decompress(self.payload(i))
120
+ except zlib.error as exc:
121
+ raise RV565Error("frame %d does not inflate: %s" % (i, exc)) from None
122
+ if len(frame) != self.header.frame_bytes:
123
+ raise RV565Error("frame %d inflates to %d bytes, expected %d"
124
+ % (i, len(frame), self.header.frame_bytes))
125
+ return frame
126
+
127
+ def __iter__(self):
128
+ return (self.frame(i) for i in range(len(self)))
129
+
130
+ def verify(self):
131
+ """Inflate every frame; raises RV565Error on the first bad one."""
132
+ for i in range(len(self)):
133
+ self.frame(i)
134
+ return len(self)
135
+
136
+ def check_schedule(self, model=None, transfer_us=31312.0, overlap=True):
137
+ """Predict missed deadlines from the payload sizes alone.
138
+
139
+ ``transfer_us`` is the display transfer per frame (31312 us is 320x240
140
+ at 40 MHz SPI). With ``overlap`` the next frame inflates on another
141
+ core during the transfer, so frame work is max(transfer, inflate);
142
+ otherwise it is transfer + inflate.
143
+ """
144
+ model = model or COST_ESP32S3_180x135
145
+ period = 1e6 / self.fps
146
+ sizes = self.payload_sizes()
147
+ work = [max(transfer_us, model.predict(s)) if overlap else transfer_us + model.predict(s)
148
+ for s in sizes]
149
+ return ScheduleReport(late_frames=sum(w > period for w in work),
150
+ max_payload=max(sizes), worst_frame_us=max(work),
151
+ period_us=period)
152
+
153
+ def _check(self, i):
154
+ if not 0 <= i < len(self):
155
+ raise IndexError("frame %d out of range (0-%d)" % (i, len(self) - 1))
156
+ return i
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class CostModel:
161
+ """T_inflate(L) = intercept_us + slope_us_per_byte * L."""
162
+ intercept_us: float
163
+ slope_us_per_byte: float
164
+
165
+ def predict(self, payload_len):
166
+ return self.intercept_us + self.slope_us_per_byte * payload_len
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class ScheduleReport:
171
+ late_frames: int
172
+ max_payload: int
173
+ worst_frame_us: float
174
+ period_us: float
175
+
176
+
177
+ # Pooled fit from the RV565 paper: ESP32-S3 at 240 MHz, ROM tinfl, 180x135
178
+ # frames. Refit for other chips, clocks or frame sizes.
179
+ COST_ESP32S3_180x135 = CostModel(4858.0, 0.1515)
180
+
181
+
182
+ def open(source): # noqa: A001 - mirrors the module-level open() idiom
183
+ """Open an RV565 file from a path, bytes, or a binary file object."""
184
+ if isinstance(source, (bytes, bytearray, memoryview)):
185
+ return Clip(source)
186
+ if hasattr(source, "read"):
187
+ return Clip(source.read())
188
+ return Clip(Path(source).read_bytes())
189
+
190
+
191
+ def encode_frames(frames, width, height, level=1):
192
+ """Yield the zlib payload of each RGB565BE frame (level 1 is what the
193
+ paper measured; higher levels give smaller files, slower to encode)."""
194
+ frame_bytes = 2 * width * height
195
+ for i, frame in enumerate(frames):
196
+ if len(frame) != frame_bytes:
197
+ raise RV565Error("frame %d has %d bytes, expected %d" % (i, len(frame), frame_bytes))
198
+ yield zlib.compress(bytes(frame), level)
199
+
200
+
201
+ def write(target, width, height, fps, frames, level=1):
202
+ """Write RGB565BE frames (an iterable of bytes) as an RV565 file.
203
+
204
+ ``target`` is a path or a seekable binary file object. Returns the number
205
+ of frames written.
206
+ """
207
+ if not (0 < width <= 0xFFFF and 0 < height <= 0xFFFF):
208
+ raise RV565Error("size %dx%d out of range" % (width, height))
209
+ if not 1 <= fps <= MAX_FPS:
210
+ raise RV565Error("fps must be 1-%d" % MAX_FPS)
211
+ if hasattr(target, "write"):
212
+ return _write(target, width, height, fps, frames, level)
213
+ with Path(target).open("wb") as out:
214
+ return _write(out, width, height, fps, frames, level)
215
+
216
+
217
+ def _write(out, width, height, fps, frames, level):
218
+ start = out.tell()
219
+ out.write(HEADER.pack(MAGIC, VERSION, width, height, fps, 0, 0))
220
+ count = 0
221
+ for payload in encode_frames(frames, width, height, level):
222
+ count += 1
223
+ if count > MAX_FRAMES:
224
+ raise RV565Error("more than %d frames" % MAX_FRAMES)
225
+ out.write(LENGTH.pack(len(payload)))
226
+ out.write(payload)
227
+ if count == 0:
228
+ raise RV565Error("no frames")
229
+ end = out.tell()
230
+ out.seek(start + 10)
231
+ out.write(struct.pack("<H", count))
232
+ out.seek(end)
233
+ return count
234
+
235
+
236
+ # Pixel conversion -----------------------------------------------------------
237
+
238
+ _R5 = bytes(v & 0xF8 for v in range(256))
239
+ _G_HI = bytes(v >> 5 for v in range(256))
240
+ _G_LO = bytes((v & 0x1C) << 3 for v in range(256))
241
+ _B5 = bytes(v >> 3 for v in range(256))
242
+
243
+
244
+ def _or_bytes(a, b):
245
+ return (int.from_bytes(a, "big") | int.from_bytes(b, "big")).to_bytes(len(a), "big")
246
+
247
+
248
+ def rgb888_to_rgb565be(rgb):
249
+ """Convert packed RGB888 bytes to RGB565 big-endian bytes."""
250
+ rgb = bytes(rgb)
251
+ if len(rgb) % 3:
252
+ raise RV565Error("RGB data length is not a multiple of 3")
253
+ r, g, b = rgb[0::3], rgb[1::3], rgb[2::3]
254
+ hi = _or_bytes(r.translate(_R5), g.translate(_G_HI))
255
+ lo = _or_bytes(g.translate(_G_LO), b.translate(_B5))
256
+ out = bytearray(2 * len(r))
257
+ out[0::2] = hi
258
+ out[1::2] = lo
259
+ return bytes(out)
260
+
261
+
262
+ def _expand(bits):
263
+ top = (1 << bits) - 1
264
+ return [(v * 255 + top // 2) // top for v in range(1 << bits)]
265
+
266
+
267
+ _E5, _E6 = _expand(5), _expand(6)
268
+ _HI_R = bytes(_E5[v >> 3] for v in range(256))
269
+ _LO_B = bytes(_E5[v & 0x1F] for v in range(256))
270
+ _HI_G = bytes((v & 0x07) << 3 for v in range(256))
271
+ _LO_G = bytes(v >> 5 for v in range(256))
272
+ _G6 = bytes(_E6[v & 0x3F] for v in range(256))
273
+
274
+
275
+ def rgb565be_to_rgb888(data):
276
+ """Convert RGB565 big-endian bytes to packed RGB888 bytes."""
277
+ data = bytes(data)
278
+ if len(data) % 2:
279
+ raise RV565Error("RGB565 data length is odd")
280
+ hi, lo = data[0::2], data[1::2]
281
+ r = hi.translate(_HI_R)
282
+ b = lo.translate(_LO_B)
283
+ g = _or_bytes(hi.translate(_HI_G), lo.translate(_LO_G)).translate(_G6)
284
+ out = bytearray(3 * len(hi))
285
+ out[0::3], out[1::3], out[2::3] = r, g, b
286
+ return bytes(out)
@@ -0,0 +1,173 @@
1
+ """rv565: encode, inspect, verify and export RV565 video files.
2
+
3
+ rv565 encode SOURCE OUT.rv565 [--start S] [--seconds N] [--fps F] [--size WxH] [--level L]
4
+ rv565 info FILE.rv565 [--transfer-us T]
5
+ rv565 verify FILE.rv565
6
+ rv565 c-array FILE.rv565 OUT.h [--name NAME]
7
+ rv565 testpattern OUT.rv565 [--frames N] [--fps F] [--size WxH]
8
+
9
+ `encode` needs FFmpeg on PATH. Everything else uses only the standard library.
10
+ """
11
+
12
+ import argparse
13
+ import hashlib
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from . import COST_ESP32S3_180x135, HEADER, VERSION, RV565Error, open as open_clip, write
20
+
21
+
22
+ def parse_size(text):
23
+ try:
24
+ width, height = (int(v) for v in text.lower().split("x"))
25
+ except ValueError:
26
+ raise argparse.ArgumentTypeError("size must look like 180x135")
27
+ if not (0 < width <= 1024 and 0 < height <= 1024):
28
+ raise argparse.ArgumentTypeError("size out of range")
29
+ return width, height
30
+
31
+
32
+ def ffmpeg_frames(source, start, seconds, fps, width, height):
33
+ vf = ("fps=%d,scale=%d:%d:force_original_aspect_ratio=increase:flags=lanczos,"
34
+ "crop=%d:%d,format=rgb565be" % (fps, width, height, width, height))
35
+ cmd = ["ffmpeg", "-v", "error", "-ss", str(start), "-i", str(source),
36
+ "-t", str(seconds), "-an", "-vf", vf, "-pix_fmt", "rgb565be",
37
+ "-f", "rawvideo", "-"]
38
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
39
+ frame_bytes = width * height * 2
40
+ try:
41
+ while True:
42
+ frame = proc.stdout.read(frame_bytes)
43
+ if not frame:
44
+ break
45
+ if len(frame) != frame_bytes:
46
+ raise SystemExit("ffmpeg returned a partial frame")
47
+ yield frame
48
+ finally:
49
+ proc.stdout.close()
50
+ if proc.wait() != 0:
51
+ raise SystemExit("ffmpeg failed")
52
+
53
+
54
+ def cmd_encode(args):
55
+ if shutil.which("ffmpeg") is None:
56
+ raise SystemExit("ffmpeg is required for encode")
57
+ width, height = args.size
58
+ count = write(args.output, width, height, args.fps,
59
+ ffmpeg_frames(args.source, args.start, args.seconds, args.fps, width, height),
60
+ args.level)
61
+ print("encoded %d frames, %dx%d, %d FPS -> %s (%d bytes)" % (
62
+ count, width, height, args.fps, args.output, Path(args.output).stat().st_size))
63
+
64
+
65
+ def cmd_info(args):
66
+ clip = open_clip(args.file)
67
+ h, sizes = clip.header, clip.payload_sizes()
68
+ raw = HEADER.size + h.frame_bytes * h.frames
69
+ print("file %s" % args.file)
70
+ print("sha256 %s" % hashlib.sha256(clip.data).hexdigest())
71
+ print("format RV565 v%d, %dx%d, %d FPS, %d frames (%.2f s)" % (
72
+ VERSION, h.width, h.height, h.fps, h.frames, h.duration))
73
+ print("size %d bytes (raw %d, ratio %.2fx)" % (len(clip.data), raw, raw / len(clip.data)))
74
+ print("payloads min %d, mean %.0f, max %d bytes" % (
75
+ min(sizes), sum(sizes) / len(sizes), max(sizes)))
76
+ if (h.width, h.height) != (180, 135):
77
+ print("schedule cost model fitted for 180x135; refit before trusting it")
78
+ for label, overlap in (("serial", False), ("overlap", True)):
79
+ r = clip.check_schedule(COST_ESP32S3_180x135, args.transfer_us, overlap)
80
+ print("schedule %-7s worst %.2f ms, %d/%d frames late at %d FPS (ESP32-S3 model)" % (
81
+ label, r.worst_frame_us / 1e3, r.late_frames, h.frames, h.fps))
82
+
83
+
84
+ def cmd_verify(args):
85
+ count = open_clip(args.file).verify()
86
+ print("ok: %d frames verified" % count)
87
+
88
+
89
+ def cmd_c_array(args):
90
+ clip = open_clip(args.file)
91
+ h, data, name = clip.header, clip.data, args.name
92
+ lines = ["/* Generated by tools/rv565.py from %s: %dx%d, %d FPS, %d frames. */" % (
93
+ Path(args.file).name, h.width, h.height, h.fps, h.frames),
94
+ "#pragma once", "#include <stddef.h>", "#include <stdint.h>", "",
95
+ "static const uint8_t %s[] __attribute__((aligned(4))) = {" % name]
96
+ for i in range(0, len(data), 16):
97
+ lines.append(" " + ", ".join("0x%02x" % b for b in data[i:i + 16]) + ",")
98
+ lines += ["};", "static const size_t %s_size = sizeof(%s);" % (name, name), ""]
99
+ Path(args.output).write_text("\n".join(lines))
100
+ print("wrote %s (%d bytes of data)" % (args.output, len(data)))
101
+
102
+
103
+ def testpattern_frames(count, width, height):
104
+ """Scrolling colour bars: deterministic and highly compressible."""
105
+ bars = [(0xFF, 0xFF, 0xFF), (0xFF, 0xFF, 0x00), (0x00, 0xFF, 0xFF), (0x00, 0xFF, 0x00),
106
+ (0xFF, 0x00, 0xFF), (0xFF, 0x00, 0x00), (0x00, 0x00, 0xFF), (0x00, 0x00, 0x00)]
107
+
108
+ def rgb565be(r, g, b):
109
+ v = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
110
+ return bytes((v >> 8, v & 0xFF))
111
+
112
+ for f in range(count):
113
+ shift = f * width // count
114
+ row = b"".join(rgb565be(*bars[((x + shift) % width) * len(bars) // width])
115
+ for x in range(width))
116
+ yield row * height
117
+
118
+
119
+ def cmd_testpattern(args):
120
+ width, height = args.size
121
+ count = write(args.output, width, height, args.fps,
122
+ testpattern_frames(args.frames, width, height), 9)
123
+ print("wrote %d-frame test pattern %dx%d -> %s (%d bytes)" % (
124
+ count, width, height, args.output, Path(args.output).stat().st_size))
125
+
126
+
127
+ def main(argv=None):
128
+ parser = argparse.ArgumentParser(prog="rv565", description=__doc__.splitlines()[0])
129
+ sub = parser.add_subparsers(dest="command", required=True)
130
+
131
+ p = sub.add_parser("encode", help="encode a video with FFmpeg")
132
+ p.add_argument("source", type=Path)
133
+ p.add_argument("output", type=Path)
134
+ p.add_argument("--start", type=float, default=0.0, help="offset into the source, in seconds")
135
+ p.add_argument("--seconds", type=float, default=10.0)
136
+ p.add_argument("--fps", type=int, default=24, choices=range(1, 31), metavar="1-30")
137
+ p.add_argument("--size", type=parse_size, default=(180, 135))
138
+ p.add_argument("--level", type=int, default=1, choices=range(1, 10), metavar="1-9",
139
+ help="zlib level; 1 is what the paper measured")
140
+ p.set_defaults(func=cmd_encode)
141
+
142
+ p = sub.add_parser("info", help="print header, sizes and a schedule prediction")
143
+ p.add_argument("file", type=Path)
144
+ p.add_argument("--transfer-us", type=float, default=31312.0,
145
+ help="display transfer per frame (31312 us: 320x240 at 40 MHz SPI)")
146
+ p.set_defaults(func=cmd_info)
147
+
148
+ p = sub.add_parser("verify", help="check that every frame inflates to its RGB565 size")
149
+ p.add_argument("file", type=Path)
150
+ p.set_defaults(func=cmd_verify)
151
+
152
+ p = sub.add_parser("c-array", help="export a file as a C header for Arduino sketches")
153
+ p.add_argument("file", type=Path)
154
+ p.add_argument("output", type=Path)
155
+ p.add_argument("--name", default="rv565_clip")
156
+ p.set_defaults(func=cmd_c_array)
157
+
158
+ p = sub.add_parser("testpattern", help="write a synthetic scrolling colour-bar clip")
159
+ p.add_argument("output", type=Path)
160
+ p.add_argument("--frames", type=int, default=24)
161
+ p.add_argument("--fps", type=int, default=24, choices=range(1, 31), metavar="1-30")
162
+ p.add_argument("--size", type=parse_size, default=(180, 135))
163
+ p.set_defaults(func=cmd_testpattern)
164
+
165
+ args = parser.parse_args(argv)
166
+ try:
167
+ args.func(args)
168
+ except RV565Error as exc:
169
+ raise SystemExit("rv565: %s" % exc)
170
+
171
+
172
+ if __name__ == "__main__":
173
+ sys.exit(main())
@@ -0,0 +1,113 @@
1
+ """Pillow plugin: open and save RV565 files as animations.
2
+
3
+ import rv565.pillow # registers the format with Pillow
4
+ from PIL import Image, ImageSequence
5
+
6
+ with Image.open("clip.rv565") as im:
7
+ print(im.size, im.n_frames, im.info["fps"])
8
+ for frame in ImageSequence.Iterator(im):
9
+ ...
10
+
11
+ frames[0].save("out.rv565", save_all=True, append_images=frames[1:], fps=24)
12
+
13
+ Frames decode to mode "RGB". Saving converts to RGB565, so colours keep 5-6-5
14
+ bits. ``fps`` (1-30) defaults to 24, or is taken from ``duration`` (ms) if
15
+ given; ``level`` sets the zlib level (default 1).
16
+ """
17
+
18
+ from array import array
19
+
20
+ from PIL import Image, ImageFile
21
+
22
+ from . import MAGIC, RV565Error, open as open_clip, rgb888_to_rgb565be, write
23
+
24
+
25
+ def _accept(prefix):
26
+ return prefix[:3] == MAGIC
27
+
28
+
29
+ class RV565ImageFile(ImageFile.ImageFile):
30
+ format = "RV565"
31
+ format_description = "RV565 frame-independent RGB565 video"
32
+
33
+ def _open(self):
34
+ try:
35
+ self._clip = open_clip(self.fp.read())
36
+ except RV565Error as exc:
37
+ raise SyntaxError(str(exc)) from None
38
+ h = self._clip.header
39
+ self._mode = "RGB"
40
+ self._size = (h.width, h.height)
41
+ self.info["fps"] = h.fps
42
+ self.info["duration"] = 1000.0 / h.fps
43
+ self.info["loop"] = 0
44
+ self.n_frames = h.frames
45
+ self.is_animated = h.frames > 1
46
+ self._frame = -1
47
+ self.tile = []
48
+ self._load_frame(0)
49
+
50
+ def _load_frame(self, index):
51
+ swapped = array("H")
52
+ swapped.frombytes(self._clip.frame(index))
53
+ swapped.byteswap() # big-endian pairs -> the little-endian layout "BGR;16" unpacks
54
+ self.im = Image.core.new(self.mode, self.size)
55
+ self.frombytes(swapped.tobytes(), "raw", "BGR;16")
56
+ self._frame = index
57
+
58
+ def seek(self, frame):
59
+ if not self._seek_check(frame):
60
+ return
61
+ self._load_frame(frame)
62
+
63
+ def tell(self):
64
+ return self._frame
65
+
66
+
67
+ def _frames(im, append_images):
68
+ seen = [im] + list(append_images or [])
69
+ for image in seen:
70
+ for i in range(getattr(image, "n_frames", 1)):
71
+ if getattr(image, "n_frames", 1) > 1:
72
+ image.seek(i)
73
+ yield image.convert("RGB")
74
+
75
+
76
+ def _fps(info, encoderinfo):
77
+ if "fps" in encoderinfo:
78
+ fps = encoderinfo["fps"]
79
+ elif "duration" in encoderinfo:
80
+ duration = encoderinfo["duration"]
81
+ if isinstance(duration, (list, tuple)):
82
+ duration = duration[0]
83
+ fps = round(1000.0 / duration)
84
+ else:
85
+ fps = info.get("fps", 24)
86
+ return max(1, min(30, int(fps)))
87
+
88
+
89
+ def _save(im, fp, filename, save_all=False):
90
+ enc = im.encoderinfo
91
+ frames = _frames(im, enc.get("append_images")) if save_all else [im.convert("RGB")]
92
+ width, height = im.size
93
+
94
+ def packed():
95
+ for frame in frames:
96
+ if frame.size != (width, height):
97
+ raise ValueError("all frames must be %dx%d" % (width, height))
98
+ yield rgb888_to_rgb565be(frame.tobytes())
99
+
100
+ write(fp, width, height, _fps(im.info, enc), packed(), enc.get("level", 1))
101
+
102
+
103
+ def _save_all(im, fp, filename):
104
+ _save(im, fp, filename, save_all=True)
105
+
106
+
107
+ Image.register_open(RV565ImageFile.format, RV565ImageFile, _accept)
108
+ Image.register_save(RV565ImageFile.format, _save)
109
+ Image.register_save_all(RV565ImageFile.format, _save_all)
110
+ Image.register_extension(RV565ImageFile.format, ".rv565")
111
+ Image.register_mime(RV565ImageFile.format, "video/x-rv565")
112
+
113
+ __all__ = ["RV565ImageFile"]
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: rv565
3
+ Version: 1.1.0
4
+ Summary: Read, write and check RV565 frame-independent RGB565 video files; Pillow plugin included.
5
+ Author-email: Ayushman Bhattacharya <ayushman.elixpo@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/elixpo/rv565
8
+ Project-URL: Documentation, https://github.com/elixpo/rv565/tree/main/python#readme
9
+ Project-URL: Paper, https://arxiv.org/abs/2610.35935
10
+ Project-URL: Issues, https://github.com/elixpo/rv565/issues
11
+ Keywords: rgb565,video,esp32,st7789,microcontroller,pillow,arduino
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Multimedia :: Video :: Conversion
16
+ Classifier: Topic :: Software Development :: Embedded Systems
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Provides-Extra: pillow
21
+ Requires-Dist: Pillow>=10.1; extra == "pillow"
22
+ Dynamic: license-file
23
+
24
+ # rv565 for Python
25
+
26
+ Read, write and check [RV565](https://github.com/elixpo/rv565) video files:
27
+ independently zlib-compressed RGB565 frames, the format the RV565 library
28
+ plays at 24 FPS on ESP32-S3 SPI displays
29
+ ([arXiv:2610.35935](https://arxiv.org/abs/2610.35935)).
30
+
31
+ ```bash
32
+ pip install rv565 # core and the `rv565` command, no dependencies
33
+ pip install "rv565[pillow]" # plus the Pillow plugin
34
+ ```
35
+
36
+ ## Command line
37
+
38
+ ```bash
39
+ rv565 encode input.mp4 clip.rv565 --size 180x135 --fps 24 # needs FFmpeg
40
+ rv565 info clip.rv565 # sizes and predicted missed frames on ESP32-S3
41
+ rv565 verify clip.rv565
42
+ rv565 c-array clip.rv565 clip.h # for Arduino sketches
43
+ ```
44
+
45
+ ## Library
46
+
47
+ ```python
48
+ import rv565
49
+
50
+ clip = rv565.open("clip.rv565")
51
+ print(clip.width, clip.height, clip.fps, len(clip))
52
+ frame = clip.frame(0) # RGB565 big-endian bytes, O(1) seek
53
+ report = clip.check_schedule(overlap=True)
54
+ print(report.late_frames) # 0: plays without misses (ESP32-S3 model)
55
+
56
+ rv565.write("out.rv565", 180, 135, 24, frames) # frames: RGB565BE bytes each
57
+ ```
58
+
59
+ ## Pillow
60
+
61
+ ```python
62
+ import rv565.pillow # registers the format
63
+ from PIL import Image, ImageSequence
64
+
65
+ with Image.open("clip.rv565") as im: # mode "RGB", im.n_frames, im.info["fps"]
66
+ frames = [f.copy() for f in ImageSequence.Iterator(im)]
67
+
68
+ frames[0].save("out.rv565", save_all=True, append_images=frames[1:], fps=24)
69
+ ```
70
+
71
+ Any image sequence Pillow can open (GIF, APNG, WebP) can be saved as RV565 the
72
+ same way; colours are reduced to RGB565.
73
+
74
+ ## License
75
+
76
+ MIT.
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/rv565/__init__.py
5
+ src/rv565/cli.py
6
+ src/rv565/pillow.py
7
+ src/rv565.egg-info/PKG-INFO
8
+ src/rv565.egg-info/SOURCES.txt
9
+ src/rv565.egg-info/dependency_links.txt
10
+ src/rv565.egg-info/entry_points.txt
11
+ src/rv565.egg-info/requires.txt
12
+ src/rv565.egg-info/top_level.txt
13
+ tests/test_rv565.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rv565 = rv565.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [pillow]
3
+ Pillow>=10.1
@@ -0,0 +1 @@
1
+ rv565
@@ -0,0 +1,127 @@
1
+ """Tests for the rv565 package. Run from the repository root:
2
+
3
+ python3 -m unittest discover -s python/tests
4
+ """
5
+
6
+ import io
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import unittest
12
+ from pathlib import Path
13
+
14
+ ROOT = Path(__file__).resolve().parents[2]
15
+ sys.path.insert(0, str(ROOT / "python" / "src"))
16
+
17
+ import rv565 # noqa: E402
18
+ from rv565.cli import testpattern_frames # noqa: E402
19
+
20
+ VECTOR = ROOT / "tests" / "data" / "testpattern.rv565"
21
+
22
+ try:
23
+ from PIL import Image, ImageSequence
24
+ import rv565.pillow # noqa: F401
25
+ except ImportError: # Pillow is optional
26
+ Image = None
27
+
28
+
29
+ class CoreTests(unittest.TestCase):
30
+ def test_vector(self):
31
+ clip = rv565.open(VECTOR)
32
+ self.assertEqual((clip.width, clip.height, clip.fps, len(clip)), (180, 135, 24, 24))
33
+ self.assertEqual(clip.verify(), 24)
34
+ self.assertEqual(len(clip.frame(5)), 180 * 135 * 2)
35
+
36
+ def test_round_trip(self):
37
+ frames = list(testpattern_frames(6, 32, 16))
38
+ buf = io.BytesIO()
39
+ self.assertEqual(rv565.write(buf, 32, 16, 12, frames), 6)
40
+ clip = rv565.open(buf.getvalue())
41
+ self.assertEqual(list(clip), frames)
42
+ self.assertEqual(clip.header.duration, 0.5)
43
+
44
+ def test_matches_committed_vector_frames(self):
45
+ # zlib output can differ between versions, so compare decoded frames.
46
+ clip = rv565.open(VECTOR)
47
+ self.assertEqual(list(clip), list(testpattern_frames(24, 180, 135)))
48
+
49
+ def test_rejects_malformed(self):
50
+ data = VECTOR.read_bytes()
51
+ cases = {
52
+ "not an RV565 file": b"XYZ" + data[3:],
53
+ "unsupported RV565 version": data[:3] + b"\x05" + data[4:],
54
+ "trailing bytes": data + b"\x00",
55
+ "invalid length": data[:-1],
56
+ "file too short": data[:5],
57
+ }
58
+ for message, bad in cases.items():
59
+ with self.subTest(message):
60
+ with self.assertRaisesRegex(rv565.RV565Error, message):
61
+ rv565.open(bad)
62
+
63
+ def test_write_validates(self):
64
+ with self.assertRaises(rv565.RV565Error):
65
+ rv565.write(io.BytesIO(), 4, 4, 31, [bytes(32)])
66
+ with self.assertRaises(rv565.RV565Error):
67
+ rv565.write(io.BytesIO(), 4, 4, 24, [bytes(31)])
68
+ with self.assertRaises(rv565.RV565Error):
69
+ rv565.write(io.BytesIO(), 4, 4, 24, [])
70
+
71
+ def test_schedule_matches_paper_model(self):
72
+ report = rv565.open(VECTOR).check_schedule(overlap=True)
73
+ self.assertEqual(report.late_frames, 0)
74
+ self.assertAlmostEqual(report.worst_frame_us, 31312.0)
75
+
76
+ def test_pixel_conversion(self):
77
+ rgb = bytes([255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, 0, 0, 0])
78
+ be = rv565.rgb888_to_rgb565be(rgb)
79
+ self.assertEqual(be.hex(), "f80007e0001fffff0000")
80
+ self.assertEqual(rv565.rgb565be_to_rgb888(be), rgb)
81
+
82
+
83
+ class CliTests(unittest.TestCase):
84
+ def run_cli(self, *args):
85
+ return subprocess.run([sys.executable, "-I", str(ROOT / "tools" / "rv565.py"), *args],
86
+ capture_output=True, text=True, check=True).stdout
87
+
88
+ def test_info_and_verify(self):
89
+ self.assertIn("180x135, 24 FPS, 24 frames", self.run_cli("info", str(VECTOR)))
90
+ self.assertIn("24 frames verified", self.run_cli("verify", str(VECTOR)))
91
+
92
+ def test_c_array(self):
93
+ with tempfile.TemporaryDirectory() as tmp:
94
+ out = os.path.join(tmp, "clip.h")
95
+ self.run_cli("c-array", str(VECTOR), out)
96
+ self.assertIn("rv565_clip_size", Path(out).read_text())
97
+
98
+
99
+ @unittest.skipIf(Image is None, "Pillow not installed")
100
+ class PillowTests(unittest.TestCase):
101
+ def test_open(self):
102
+ with Image.open(VECTOR) as im:
103
+ self.assertEqual(im.format, "RV565")
104
+ self.assertEqual((im.size, im.mode, im.n_frames), ((180, 135), "RGB", 24))
105
+ self.assertEqual(im.info["fps"], 24)
106
+ first = im.getpixel((0, 0))
107
+ im.seek(23)
108
+ self.assertEqual(im.tell(), 23)
109
+ self.assertEqual(first, (255, 255, 255))
110
+
111
+ def test_save_all_round_trip(self):
112
+ with Image.open(VECTOR) as im:
113
+ frames = [f.copy() for f in ImageSequence.Iterator(im)]
114
+ buf = io.BytesIO()
115
+ frames[0].save(buf, format="RV565", save_all=True, append_images=frames[1:], fps=24)
116
+ clip = rv565.open(buf.getvalue())
117
+ self.assertEqual(list(clip), list(rv565.open(VECTOR)))
118
+
119
+ def test_save_from_duration(self):
120
+ frames = [Image.new("RGB", (8, 8), c) for c in ("red", "blue")]
121
+ buf = io.BytesIO()
122
+ frames[0].save(buf, format="RV565", save_all=True, append_images=frames[1:], duration=100)
123
+ self.assertEqual(rv565.open(buf.getvalue()).fps, 10)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ unittest.main()