bayertap 0.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.

Potentially problematic release.


This version of bayertap might be problematic. Click here for more details.

bayertap-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Serge Rabyking
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.
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: bayertap
3
+ Version: 0.1.0
4
+ Summary: Passive conformance tap for bayerlink links: capture, decode, judge
5
+ Author: Serge Rabyking
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/bayerlink/bayertap
8
+ Project-URL: Source, https://github.com/bayerlink/bayertap
9
+ Project-URL: Issues, https://github.com/bayerlink/bayertap/issues
10
+ Keywords: bayer,raw,hdmi,capture,v4l2,fpga,conformance,tc358743
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Multimedia :: Video :: Capture
15
+ Classifier: Topic :: Software Development :: Embedded Systems
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: numpy
20
+ Requires-Dist: bayerlink>=0.2.0
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # bayertap
26
+
27
+ **A passive conformance tap for bayerlink links.**
28
+
29
+ In a streaming netlist, a *tap* is a sink that observes a stream without
30
+ stalling it. This is that concept as bench equipment: point it at any V4L2
31
+ capture device carrying a [bayerlink](https://github.com/bayerlink/bayerlink)
32
+ source and it tells you whether the bytes survive — judged with the same
33
+ published codec the source encoded with, so the two ends cannot disagree
34
+ about what conformance means.
35
+
36
+ ```bash
37
+ bayertap probe # which byte-lane permutation is this rig?
38
+ bayertap --lane-map 2,1,0 check --pattern counting # every sample, judged
39
+ bayertap --via tunnel check --pattern counting # through a cheap dongle
40
+ bayertap --from-file frame.npy check # no hardware at all
41
+ ```
42
+
43
+ ## Capture tiers
44
+
45
+ | Tier | Hardware | Path | Fidelity |
46
+ | --- | --- | --- | --- |
47
+ | **Trusted** | TC358743 HDMI→CSI-2 bridge (~$25, the PiKVM part) on any Pi or Jetson | `--via direct`, RGB888 | byte-exact |
48
+ | **Cheap** | MS2130S / MS2131S USB3 stick (~$15) on any Linux box | `--via direct`, RGB24 | byte-exact **if** the stick's pipeline is transparent — `check` answers that in minutes |
49
+ | **Drawer** | MS2109-class USB2 stick | `--via tunnel`, YUYV | **bit-exact at 1/6 capacity**, via the luma tunnel — run the source with `--luma-tunnel` |
50
+ | Rejected | any USB2 stick, direct | MJPEG / starved YUYV | physics: USB2 cannot carry uncompressed 1080p, and MJPEG is lossy. Refused in code, not just here |
51
+
52
+ The drawer tier deserves its sentence: a $10 dongle you already own becomes a
53
+ working conformance receiver **today**, because bayerlink's luma tunnel
54
+ carries the unchanged container as grey levels with a pilot line the decoder
55
+ learns the channel from. Slow, and completely sufficient for proving bytes.
56
+
57
+ ## The two bring-up commands
58
+
59
+ **`probe`** resolves the byte-lane permutation — which captured channel holds
60
+ container byte k — by trying all six against the header's magic and CRC. Its
61
+ output is directly the `lane_map` for this tool's `check` *and* for the
62
+ FPGA-side receiver (`np2hw.bayerlink_in`). One command instead of a scope.
63
+
64
+ **`check --pattern X`** regenerates the pattern locally (patterns are pure
65
+ functions — nothing travels out of band) and compares **every sample**,
66
+ watches `frame_seq` for gaps, tolerates scanout repeats (protocol-legal),
67
+ and exits nonzero on any discrepancy. Point CI at it if you like.
68
+
69
+ For the TC358743 tier, remember the bridge needs an EDID loaded
70
+ (`v4l2-ctl --set-edid`) before it captures — and that EDID is *leverage*: it
71
+ is what the source reads, so advertise only RGB 4:4:4 at your one mode and
72
+ the source is steered into the only format the protocol accepts.
73
+
74
+ ## Status
75
+
76
+ Everything except the ioctl layer is proven off-target: struct sizes pinned
77
+ against the kernel uapi, both decode paths (direct with lane maps, tunnel
78
+ with pilot learning) tested end-to-end from files. First runs against real
79
+ capture silicon are the bench session; the known-good device table starts
80
+ there.
81
+
82
+ ## Licence
83
+
84
+ MIT.
@@ -0,0 +1,60 @@
1
+ # bayertap
2
+
3
+ **A passive conformance tap for bayerlink links.**
4
+
5
+ In a streaming netlist, a *tap* is a sink that observes a stream without
6
+ stalling it. This is that concept as bench equipment: point it at any V4L2
7
+ capture device carrying a [bayerlink](https://github.com/bayerlink/bayerlink)
8
+ source and it tells you whether the bytes survive — judged with the same
9
+ published codec the source encoded with, so the two ends cannot disagree
10
+ about what conformance means.
11
+
12
+ ```bash
13
+ bayertap probe # which byte-lane permutation is this rig?
14
+ bayertap --lane-map 2,1,0 check --pattern counting # every sample, judged
15
+ bayertap --via tunnel check --pattern counting # through a cheap dongle
16
+ bayertap --from-file frame.npy check # no hardware at all
17
+ ```
18
+
19
+ ## Capture tiers
20
+
21
+ | Tier | Hardware | Path | Fidelity |
22
+ | --- | --- | --- | --- |
23
+ | **Trusted** | TC358743 HDMI→CSI-2 bridge (~$25, the PiKVM part) on any Pi or Jetson | `--via direct`, RGB888 | byte-exact |
24
+ | **Cheap** | MS2130S / MS2131S USB3 stick (~$15) on any Linux box | `--via direct`, RGB24 | byte-exact **if** the stick's pipeline is transparent — `check` answers that in minutes |
25
+ | **Drawer** | MS2109-class USB2 stick | `--via tunnel`, YUYV | **bit-exact at 1/6 capacity**, via the luma tunnel — run the source with `--luma-tunnel` |
26
+ | Rejected | any USB2 stick, direct | MJPEG / starved YUYV | physics: USB2 cannot carry uncompressed 1080p, and MJPEG is lossy. Refused in code, not just here |
27
+
28
+ The drawer tier deserves its sentence: a $10 dongle you already own becomes a
29
+ working conformance receiver **today**, because bayerlink's luma tunnel
30
+ carries the unchanged container as grey levels with a pilot line the decoder
31
+ learns the channel from. Slow, and completely sufficient for proving bytes.
32
+
33
+ ## The two bring-up commands
34
+
35
+ **`probe`** resolves the byte-lane permutation — which captured channel holds
36
+ container byte k — by trying all six against the header's magic and CRC. Its
37
+ output is directly the `lane_map` for this tool's `check` *and* for the
38
+ FPGA-side receiver (`np2hw.bayerlink_in`). One command instead of a scope.
39
+
40
+ **`check --pattern X`** regenerates the pattern locally (patterns are pure
41
+ functions — nothing travels out of band) and compares **every sample**,
42
+ watches `frame_seq` for gaps, tolerates scanout repeats (protocol-legal),
43
+ and exits nonzero on any discrepancy. Point CI at it if you like.
44
+
45
+ For the TC358743 tier, remember the bridge needs an EDID loaded
46
+ (`v4l2-ctl --set-edid`) before it captures — and that EDID is *leverage*: it
47
+ is what the source reads, so advertise only RGB 4:4:4 at your one mode and
48
+ the source is steered into the only format the protocol accepts.
49
+
50
+ ## Status
51
+
52
+ Everything except the ioctl layer is proven off-target: struct sizes pinned
53
+ against the kernel uapi, both decode paths (direct with lane maps, tunnel
54
+ with pilot learning) tested end-to-end from files. First runs against real
55
+ capture silicon are the bench session; the known-good device table starts
56
+ there.
57
+
58
+ ## Licence
59
+
60
+ MIT.
@@ -0,0 +1,9 @@
1
+ """bayertap -- a passive conformance tap for bayerlink links.
2
+
3
+ In a revela/np2hw netlist, a tap is a sink that observes a stream without
4
+ stalling it. This is that concept as bench equipment: point it at any V4L2
5
+ capture device carrying a bayerlink source and it tells you whether the
6
+ bytes survive. It judges with the same published codec the source encoded
7
+ with, so the two ends cannot disagree about what conformance means.
8
+ """
9
+ __version__ = "0.1.0"
@@ -0,0 +1,186 @@
1
+ """bayertap: a passive conformance tap for bayerlink links.
2
+
3
+ Point it at any V4L2 capture device carrying a bayerlink source and it
4
+ tells you whether the bytes survive. Three subcommands, one per question:
5
+
6
+ check does every frame decode, does the pattern match, does
7
+ frame_seq advance without gaps?
8
+ probe which byte-lane permutation is this platform pair using?
9
+ save keep decoded raw frames for offline analysis.
10
+
11
+ Two capture paths, chosen by --via:
12
+
13
+ direct RGB3/BGR3 capture (TC358743 bridge, S-variant USB3 stick):
14
+ the container arrives as bytes, possibly lane-permuted --
15
+ which probe resolves and check accepts via --lane-map.
16
+ tunnel YUYV capture (MS2109-class USB2 dongles): only luma survives
17
+ that hardware, so the source must run --luma-tunnel, and the
18
+ container is recovered from grey levels via the pilot line.
19
+
20
+ Everything also runs from a saved .npy instead of a device (--from-file),
21
+ so the whole validation path is testable -- and tested -- with no capture
22
+ hardware at all.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import sys
28
+
29
+ import numpy as np
30
+
31
+ from bayerlink import decode_frame, detect_lane_map, pattern, tunnel
32
+ from bayerlink.protocol import Header
33
+
34
+
35
+ def _validate(container: np.ndarray, expect_pattern: str | None,
36
+ last_seq: int | None):
37
+ """One container -> (Header, list of problem strings)."""
38
+ problems = []
39
+ try:
40
+ header, raw = decode_frame(container)
41
+ except ValueError as error:
42
+ return None, [f"decode: {error}"]
43
+ if last_seq is not None:
44
+ gap = (header.frame_seq - last_seq) & 0xFFFFFFFF
45
+ if gap == 0:
46
+ return header, ["duplicate (same frame_seq; scanout repeat)"]
47
+ if gap != 1:
48
+ problems.append(f"frame_seq gap: {last_seq} -> {header.frame_seq}")
49
+ if expect_pattern is not None:
50
+ if not header.is_test_pattern:
51
+ problems.append("header does not claim a test pattern")
52
+ expected = pattern.generate(expect_pattern, header.width, header.height)
53
+ if not np.array_equal(raw, expected):
54
+ wrong = int((raw != expected).sum())
55
+ first = tuple(int(v) for v in np.argwhere(raw != expected)[0])
56
+ problems.append(
57
+ f"{wrong} of {raw.size} samples differ from {expect_pattern}; "
58
+ f"first at (row, col) {first}")
59
+ return header, problems
60
+
61
+
62
+ def _containers(args):
63
+ """Yield (tag, container) from the device or the file, per --via."""
64
+ if args.from_file:
65
+ physical = np.load(args.from_file)
66
+ if args.via == "tunnel":
67
+ luma = physical[:, :, 0] if physical.ndim == 3 else physical
68
+ _, inner_h = tunnel.inner_display(luma.shape[1], luma.shape[0])
69
+ yield 0, tunnel.decode(luma, inner_height=inner_h)
70
+ else:
71
+ frame = physical[:, :, list(args.lane_map)]
72
+ yield 0, frame
73
+ return
74
+
75
+ from . import v4l2
76
+
77
+ device = v4l2.Device(args.device)
78
+ print(f"{device.path}: {device.card} ({device.driver}), "
79
+ f"offers {device.formats()}")
80
+ width, height = args.capture
81
+ if args.via == "tunnel":
82
+ for sequence, luma in device.frames(width, height, v4l2.FORMAT_YUYV):
83
+ _, inner_h = tunnel.inner_display(width, height)
84
+ try:
85
+ yield sequence, tunnel.decode(luma, inner_height=inner_h)
86
+ except ValueError as error:
87
+ yield sequence, error
88
+ else:
89
+ pixelformat = v4l2.FORMAT_BGR3 if args.bgr else v4l2.FORMAT_RGB3
90
+ for sequence, frame in device.frames(width, height, pixelformat):
91
+ yield sequence, frame[:, :, list(args.lane_map)]
92
+
93
+
94
+ def main(argv=None) -> int:
95
+ parser = argparse.ArgumentParser(
96
+ prog="bayertap",
97
+ description="Passive conformance tap for bayerlink links.")
98
+ parser.add_argument("--device", default="/dev/video0")
99
+ parser.add_argument("--from-file", default=None,
100
+ help=".npy physical frame instead of a device")
101
+ parser.add_argument("--via", choices=["direct", "tunnel"], default="direct",
102
+ help="direct: RGB capture; tunnel: YUYV luma capture "
103
+ "of a --luma-tunnel source")
104
+ parser.add_argument("--capture", type=lambda s: tuple(
105
+ int(v) for v in s.split("x")), default=(1280, 720),
106
+ help="capture geometry WxH (default 1280x720)")
107
+ parser.add_argument("--lane-map", type=lambda s: tuple(
108
+ int(v) for v in s.split(",")), default=(0, 1, 2),
109
+ help="byte permutation for direct capture; find it "
110
+ "with `bayertap probe`")
111
+ parser.add_argument("--bgr", action="store_true",
112
+ help="capture BGR3 instead of RGB3")
113
+ sub = parser.add_subparsers(dest="command", required=True)
114
+
115
+ check = sub.add_parser("check", help="validate frames continuously")
116
+ check.add_argument("--pattern", default=None,
117
+ choices=sorted(pattern.PATTERNS),
118
+ help="regenerate this pattern locally and compare "
119
+ "every sample")
120
+ check.add_argument("--frames", type=int, default=0,
121
+ help="stop after N NEW frames (0 = forever)")
122
+
123
+ sub.add_parser("probe", help="resolve the byte-lane permutation "
124
+ "(direct capture of one frame)")
125
+
126
+ save = sub.add_parser("save", help="write decoded raw frames as .npy")
127
+ save.add_argument("--out", required=True)
128
+ save.add_argument("--frames", type=int, default=1)
129
+
130
+ args = parser.parse_args(argv)
131
+
132
+ if args.command == "probe":
133
+ if args.via == "tunnel":
134
+ parser.error("probe needs direct RGB capture: the tunnel is "
135
+ "greyscale, so lanes are indistinguishable there")
136
+ args.lane_map = (0, 1, 2) # probe must see raw order
137
+ for _, frame in _containers(args):
138
+ if isinstance(frame, Exception):
139
+ continue
140
+ permutation, header = detect_lane_map(frame)
141
+ print(f"lane_map = {permutation} "
142
+ f"(container byte k is on captured channel lane_map[k])")
143
+ print(f"header: {header.fourcc} {header.width}x{header.height} "
144
+ f"seq {header.frame_seq}")
145
+ return 0
146
+ return 1
147
+
148
+ seen = last_seq = None
149
+ good = bad = saved = 0
150
+ for tag, container in _containers(args):
151
+ if isinstance(container, Exception):
152
+ bad += 1
153
+ print(f"[{tag}] tunnel: {container}")
154
+ continue
155
+ header, problems = _validate(
156
+ container, getattr(args, "pattern", None), last_seq)
157
+ if header is not None and problems == ["duplicate (same frame_seq; "
158
+ "scanout repeat)"]:
159
+ continue # repeats are protocol-legal
160
+ if problems:
161
+ bad += 1
162
+ print(f"[{tag}] " + "; ".join(problems))
163
+ else:
164
+ good += 1
165
+ last_seq = header.frame_seq
166
+ if args.command == "save":
167
+ _, raw = decode_frame(container)
168
+ path = f"{args.out.rstrip('/')}/frame_{header.frame_seq:08d}.npy"
169
+ np.save(path, raw)
170
+ saved += 1
171
+ print(f"saved {path}")
172
+ elif good % 30 == 1:
173
+ print(f"[{tag}] ok: {header.fourcc} {header.width}x"
174
+ f"{header.height} seq {header.frame_seq}"
175
+ + (f", pattern {args.pattern} exact"
176
+ if getattr(args, 'pattern', None) else ""))
177
+ limit = getattr(args, "frames", 0)
178
+ if limit and (good if args.command == "check" else saved) >= limit:
179
+ break
180
+
181
+ print(f"\n{good} good, {bad} bad")
182
+ return 0 if bad == 0 and good > 0 else 1
183
+
184
+
185
+ if __name__ == "__main__":
186
+ sys.exit(main())
@@ -0,0 +1,210 @@
1
+ """V4L2 capture via raw ioctls: open, negotiate, mmap, stream.
2
+
3
+ The same zero-dependency posture as picam2hdmi's KMS layer, for the same
4
+ reason: an appliance should not carry a bindings package for the nine
5
+ ioctls it uses, and the V4L2 uapi is a stable kernel ABI. Every struct is
6
+ pinned against its known uapi size by the tests -- a mislaid ctypes field
7
+ fails as one assert here instead of as corruption on a bench.
8
+
9
+ This layer knows nothing about bayerlink. It negotiates a pixel format,
10
+ maps buffers, and yields frames; what the frames MEAN belongs to the
11
+ caller. That split is what lets one tool serve every capture tier -- a
12
+ TC358743 bridge delivering RGB888, an S-variant USB3 stick delivering
13
+ RGB24, an MS2109 delivering YUY2 for the luma tunnel -- they are all just
14
+ V4L2 devices with different formats.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import ctypes
19
+ import fcntl
20
+ import mmap as _mmap
21
+ import os
22
+ import select
23
+
24
+ import numpy as np
25
+
26
+ u8, u32, u64 = ctypes.c_uint8, ctypes.c_uint32, ctypes.c_uint64
27
+
28
+
29
+ class Capability(ctypes.Structure):
30
+ _fields_ = [("driver", ctypes.c_char * 16), ("card", ctypes.c_char * 32),
31
+ ("bus_info", ctypes.c_char * 32), ("version", u32),
32
+ ("capabilities", u32), ("device_caps", u32),
33
+ ("reserved", u32 * 3)] # 104 bytes
34
+
35
+
36
+ class PixFormat(ctypes.Structure):
37
+ _fields_ = [("width", u32), ("height", u32), ("pixelformat", u32),
38
+ ("field", u32), ("bytesperline", u32), ("sizeimage", u32),
39
+ ("colorspace", u32), ("priv", u32), ("flags", u32),
40
+ ("ycbcr_enc", u32), ("quantization", u32),
41
+ ("xfer_func", u32)] # 48 bytes
42
+
43
+
44
+ class Format(ctypes.Structure):
45
+ _fields_ = [("type", u32), ("pad", u32),
46
+ ("fmt", ctypes.c_uint8 * 200)] # 208 bytes
47
+
48
+ @property
49
+ def pix(self) -> PixFormat:
50
+ return PixFormat.from_buffer(self, 8)
51
+
52
+
53
+ class RequestBuffers(ctypes.Structure):
54
+ _fields_ = [("count", u32), ("type", u32), ("memory", u32),
55
+ ("capabilities", u32), ("flags", u8),
56
+ ("reserved", u8 * 3)] # 20 bytes
57
+
58
+
59
+ class Timeval(ctypes.Structure):
60
+ _fields_ = [("sec", ctypes.c_long), ("usec", ctypes.c_long)]
61
+
62
+
63
+ class Timecode(ctypes.Structure):
64
+ _fields_ = [("type", u32), ("flags", u32), ("frames", u8), ("seconds", u8),
65
+ ("minutes", u8), ("hours", u8), ("userbits", u8 * 4)]
66
+
67
+
68
+ class Buffer(ctypes.Structure):
69
+ _fields_ = [("index", u32), ("type", u32), ("bytesused", u32),
70
+ ("flags", u32), ("field", u32), ("pad", u32),
71
+ ("timestamp", Timeval), ("timecode", Timecode),
72
+ ("sequence", u32), ("memory", u32),
73
+ ("m_offset", u64), # union: offset/userptr/planes/fd
74
+ ("length", u32), ("reserved2", u32),
75
+ ("request_fd", u32)] # 88 bytes (LP64)
76
+
77
+
78
+ class FmtDesc(ctypes.Structure):
79
+ _fields_ = [("index", u32), ("type", u32), ("flags", u32),
80
+ ("description", ctypes.c_char * 32), ("pixelformat", u32),
81
+ ("mbus_code", u32), ("reserved", u32 * 3)] # 64 bytes
82
+
83
+
84
+ def _ioc(direction: int, nr: int, size: int) -> int:
85
+ return (direction << 30) | (size << 16) | (ord("V") << 8) | nr
86
+
87
+
88
+ IOCTL_QUERYCAP = _ioc(2, 0, ctypes.sizeof(Capability))
89
+ IOCTL_ENUM_FMT = _ioc(3, 2, ctypes.sizeof(FmtDesc))
90
+ IOCTL_S_FMT = _ioc(3, 5, ctypes.sizeof(Format))
91
+ IOCTL_REQBUFS = _ioc(3, 8, ctypes.sizeof(RequestBuffers))
92
+ IOCTL_QUERYBUF = _ioc(3, 9, ctypes.sizeof(Buffer))
93
+ IOCTL_QBUF = _ioc(3, 15, ctypes.sizeof(Buffer))
94
+ IOCTL_DQBUF = _ioc(3, 17, ctypes.sizeof(Buffer))
95
+ IOCTL_STREAMON = _ioc(1, 18, 4)
96
+ IOCTL_STREAMOFF = _ioc(1, 19, 4)
97
+
98
+ CAPTURE = 1 # V4L2_BUF_TYPE_VIDEO_CAPTURE
99
+ MEMORY_MMAP = 1
100
+
101
+
102
+ def fourcc(text: str) -> int:
103
+ return int.from_bytes(text.encode("ascii"), "little")
104
+
105
+
106
+ FORMAT_YUYV = fourcc("YUYV")
107
+ FORMAT_RGB3 = fourcc("RGB3") # RGB24: memory R,G,B per pixel
108
+ FORMAT_BGR3 = fourcc("BGR3") # memory B,G,R per pixel
109
+ FORMAT_MJPG = fourcc("MJPG") # rejected for payload: lossy
110
+
111
+
112
+ def frame_array(data: np.ndarray, pixelformat: int, width: int,
113
+ height: int, bytesperline: int) -> np.ndarray:
114
+ """Raw captured bytes -> a channel-shaped array, per format.
115
+
116
+ YUYV yields the LUMA plane (height, width) -- the only channel the luma
117
+ tunnel needs, extracted here so callers never index interleaved bytes.
118
+ RGB3/BGR3 yield (height, width, 3) in MEMORY byte order, untouched:
119
+ which byte is which lane is detect_lane_map()'s question, not this
120
+ layer's.
121
+ """
122
+ rows = data[:height * bytesperline].reshape(height, bytesperline)
123
+ if pixelformat == FORMAT_YUYV:
124
+ return rows[:, :width * 2][:, 0::2].copy()
125
+ if pixelformat in (FORMAT_RGB3, FORMAT_BGR3):
126
+ return rows[:, :width * 3].reshape(height, width, 3).copy()
127
+ raise ValueError(
128
+ f"unhandled pixelformat {pixelformat:#010x}; this tool captures "
129
+ "YUYV (luma tunnel) and RGB3/BGR3 (direct), and refuses MJPEG "
130
+ "outright -- lossy compression cannot carry a byte container.")
131
+
132
+
133
+ class Device:
134
+ """One V4L2 capture device, negotiated and streaming."""
135
+
136
+ def __init__(self, path: str = "/dev/video0"):
137
+ self.path = path
138
+ self.fd = os.open(path, os.O_RDWR | os.O_NONBLOCK)
139
+ cap = Capability()
140
+ fcntl.ioctl(self.fd, IOCTL_QUERYCAP, cap)
141
+ self.driver = cap.driver.decode(errors="replace")
142
+ self.card = cap.card.decode(errors="replace")
143
+ self._maps: list = []
144
+
145
+ def formats(self) -> list[str]:
146
+ """The fourccs the device offers, for diagnostics and refusals."""
147
+ found = []
148
+ index = 0
149
+ while True:
150
+ desc = FmtDesc(index=index, type=CAPTURE)
151
+ try:
152
+ fcntl.ioctl(self.fd, IOCTL_ENUM_FMT, desc)
153
+ except OSError:
154
+ return found
155
+ found.append(int(desc.pixelformat).to_bytes(4, "little")
156
+ .decode(errors="replace"))
157
+ index += 1
158
+
159
+ def set_format(self, width: int, height: int, pixelformat: int):
160
+ """Negotiate; RETURNS what the device actually granted.
161
+
162
+ V4L2 is a negotiation, not a command: the driver may adjust every
163
+ field. The caller compares granted against requested and refuses a
164
+ substitute -- silently capturing at the wrong geometry is how a
165
+ bench lies.
166
+ """
167
+ request = Format(type=CAPTURE)
168
+ pix = request.pix
169
+ pix.width, pix.height = width, height
170
+ pix.pixelformat = pixelformat
171
+ pix.field = 1 # V4L2_FIELD_NONE
172
+ fcntl.ioctl(self.fd, IOCTL_S_FMT, request)
173
+ granted = request.pix
174
+ if (granted.width, granted.height) != (width, height) or \
175
+ granted.pixelformat != pixelformat:
176
+ got = int(granted.pixelformat).to_bytes(4, "little").decode(
177
+ errors="replace")
178
+ want = int(pixelformat).to_bytes(4, "little").decode(errors="replace")
179
+ raise RuntimeError(
180
+ f"{self.path} granted {granted.width}x{granted.height} {got} "
181
+ f"instead of {width}x{height} {want}; it offers formats "
182
+ f"{self.formats()}. Refusing the substitute.")
183
+ return granted.width, granted.height, granted.bytesperline
184
+
185
+ def frames(self, width: int, height: int, pixelformat: int, buffers: int = 4):
186
+ """Yield frames forever: (sequence, array) per :func:`frame_array`."""
187
+ width, height, bytesperline = self.set_format(width, height, pixelformat)
188
+
189
+ request = RequestBuffers(count=buffers, type=CAPTURE, memory=MEMORY_MMAP)
190
+ fcntl.ioctl(self.fd, IOCTL_REQBUFS, request)
191
+ for index in range(request.count):
192
+ buffer = Buffer(index=index, type=CAPTURE, memory=MEMORY_MMAP)
193
+ fcntl.ioctl(self.fd, IOCTL_QUERYBUF, buffer)
194
+ self._maps.append(_mmap.mmap(self.fd, buffer.length,
195
+ offset=buffer.m_offset))
196
+ fcntl.ioctl(self.fd, IOCTL_QBUF, buffer)
197
+
198
+ fcntl.ioctl(self.fd, IOCTL_STREAMON, ctypes.c_int(CAPTURE))
199
+ try:
200
+ while True:
201
+ select.select([self.fd], [], [])
202
+ buffer = Buffer(type=CAPTURE, memory=MEMORY_MMAP)
203
+ fcntl.ioctl(self.fd, IOCTL_DQBUF, buffer)
204
+ data = np.frombuffer(self._maps[buffer.index], np.uint8,
205
+ count=buffer.bytesused)
206
+ yield buffer.sequence, frame_array(
207
+ data, pixelformat, width, height, bytesperline)
208
+ fcntl.ioctl(self.fd, IOCTL_QBUF, buffer)
209
+ finally:
210
+ fcntl.ioctl(self.fd, IOCTL_STREAMOFF, ctypes.c_int(CAPTURE))
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: bayertap
3
+ Version: 0.1.0
4
+ Summary: Passive conformance tap for bayerlink links: capture, decode, judge
5
+ Author: Serge Rabyking
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/bayerlink/bayertap
8
+ Project-URL: Source, https://github.com/bayerlink/bayertap
9
+ Project-URL: Issues, https://github.com/bayerlink/bayertap/issues
10
+ Keywords: bayer,raw,hdmi,capture,v4l2,fpga,conformance,tc358743
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Multimedia :: Video :: Capture
15
+ Classifier: Topic :: Software Development :: Embedded Systems
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: numpy
20
+ Requires-Dist: bayerlink>=0.2.0
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # bayertap
26
+
27
+ **A passive conformance tap for bayerlink links.**
28
+
29
+ In a streaming netlist, a *tap* is a sink that observes a stream without
30
+ stalling it. This is that concept as bench equipment: point it at any V4L2
31
+ capture device carrying a [bayerlink](https://github.com/bayerlink/bayerlink)
32
+ source and it tells you whether the bytes survive — judged with the same
33
+ published codec the source encoded with, so the two ends cannot disagree
34
+ about what conformance means.
35
+
36
+ ```bash
37
+ bayertap probe # which byte-lane permutation is this rig?
38
+ bayertap --lane-map 2,1,0 check --pattern counting # every sample, judged
39
+ bayertap --via tunnel check --pattern counting # through a cheap dongle
40
+ bayertap --from-file frame.npy check # no hardware at all
41
+ ```
42
+
43
+ ## Capture tiers
44
+
45
+ | Tier | Hardware | Path | Fidelity |
46
+ | --- | --- | --- | --- |
47
+ | **Trusted** | TC358743 HDMI→CSI-2 bridge (~$25, the PiKVM part) on any Pi or Jetson | `--via direct`, RGB888 | byte-exact |
48
+ | **Cheap** | MS2130S / MS2131S USB3 stick (~$15) on any Linux box | `--via direct`, RGB24 | byte-exact **if** the stick's pipeline is transparent — `check` answers that in minutes |
49
+ | **Drawer** | MS2109-class USB2 stick | `--via tunnel`, YUYV | **bit-exact at 1/6 capacity**, via the luma tunnel — run the source with `--luma-tunnel` |
50
+ | Rejected | any USB2 stick, direct | MJPEG / starved YUYV | physics: USB2 cannot carry uncompressed 1080p, and MJPEG is lossy. Refused in code, not just here |
51
+
52
+ The drawer tier deserves its sentence: a $10 dongle you already own becomes a
53
+ working conformance receiver **today**, because bayerlink's luma tunnel
54
+ carries the unchanged container as grey levels with a pilot line the decoder
55
+ learns the channel from. Slow, and completely sufficient for proving bytes.
56
+
57
+ ## The two bring-up commands
58
+
59
+ **`probe`** resolves the byte-lane permutation — which captured channel holds
60
+ container byte k — by trying all six against the header's magic and CRC. Its
61
+ output is directly the `lane_map` for this tool's `check` *and* for the
62
+ FPGA-side receiver (`np2hw.bayerlink_in`). One command instead of a scope.
63
+
64
+ **`check --pattern X`** regenerates the pattern locally (patterns are pure
65
+ functions — nothing travels out of band) and compares **every sample**,
66
+ watches `frame_seq` for gaps, tolerates scanout repeats (protocol-legal),
67
+ and exits nonzero on any discrepancy. Point CI at it if you like.
68
+
69
+ For the TC358743 tier, remember the bridge needs an EDID loaded
70
+ (`v4l2-ctl --set-edid`) before it captures — and that EDID is *leverage*: it
71
+ is what the source reads, so advertise only RGB 4:4:4 at your one mode and
72
+ the source is steered into the only format the protocol accepts.
73
+
74
+ ## Status
75
+
76
+ Everything except the ioctl layer is proven off-target: struct sizes pinned
77
+ against the kernel uapi, both decode paths (direct with lane maps, tunnel
78
+ with pilot learning) tested end-to-end from files. First runs against real
79
+ capture silicon are the bench session; the known-good device table starts
80
+ there.
81
+
82
+ ## Licence
83
+
84
+ MIT.
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ bayertap/__init__.py
5
+ bayertap/cli.py
6
+ bayertap/v4l2.py
7
+ bayertap.egg-info/PKG-INFO
8
+ bayertap.egg-info/SOURCES.txt
9
+ bayertap.egg-info/dependency_links.txt
10
+ bayertap.egg-info/entry_points.txt
11
+ bayertap.egg-info/requires.txt
12
+ bayertap.egg-info/top_level.txt
13
+ tests/test_bayertap.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bayertap = bayertap.cli:main
@@ -0,0 +1,5 @@
1
+ numpy
2
+ bayerlink>=0.2.0
3
+
4
+ [test]
5
+ pytest
@@ -0,0 +1 @@
1
+ bayertap
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bayertap"
7
+ version = "0.1.0"
8
+ description = "Passive conformance tap for bayerlink links: capture, decode, judge"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Serge Rabyking" }]
13
+ requires-python = ">=3.9"
14
+ keywords = ["bayer", "raw", "hdmi", "capture", "v4l2", "fpga", "conformance", "tc358743"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Multimedia :: Video :: Capture",
20
+ "Topic :: Software Development :: Embedded Systems",
21
+ ]
22
+ dependencies = ["numpy", "bayerlink>=0.2.0"]
23
+
24
+ [project.optional-dependencies]
25
+ test = ["pytest"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/bayerlink/bayertap"
29
+ Source = "https://github.com/bayerlink/bayertap"
30
+ Issues = "https://github.com/bayerlink/bayertap/issues"
31
+
32
+ [project.scripts]
33
+ bayertap = "bayertap.cli:main"
34
+
35
+ [tool.setuptools.packages.find]
36
+ include = ["bayertap*"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,84 @@
1
+ """Everything that does not need capture hardware, which is everything but
2
+ the ioctls: struct sizes pinned to the uapi, frame_array per format, and the
3
+ whole check pipeline end to end from files -- the same code paths a device
4
+ feeds, minus the device.
5
+ """
6
+ import ctypes
7
+
8
+ import numpy as np
9
+ import pytest
10
+ from bayerlink import encode_frame, pattern, tunnel
11
+ from bayerlink.protocol import FLAG_TEST_PATTERN
12
+
13
+ from bayertap import cli, v4l2
14
+
15
+
16
+ def test_struct_sizes_match_the_kernel_uapi():
17
+ expected = {v4l2.Capability: 104, v4l2.PixFormat: 48, v4l2.Format: 208,
18
+ v4l2.RequestBuffers: 20, v4l2.Buffer: 88, v4l2.FmtDesc: 64}
19
+ for struct, size in expected.items():
20
+ assert ctypes.sizeof(struct) == size, struct.__name__
21
+
22
+
23
+ def test_frame_array_extracts_luma_from_yuyv_and_passes_rgb_untouched():
24
+ height, width, stride = 4, 8, 8 * 2 + 4 # padded line
25
+ yuyv = np.arange(height * stride, dtype=np.uint8)
26
+ luma = v4l2.frame_array(yuyv, v4l2.FORMAT_YUYV, width, height, stride)
27
+ assert luma.shape == (height, width)
28
+ assert np.array_equal(luma[0], yuyv[:width * 2:2])
29
+
30
+ stride = 8 * 3
31
+ rgb = np.arange(height * stride, dtype=np.uint8)
32
+ frame = v4l2.frame_array(rgb, v4l2.FORMAT_RGB3, width, height, stride)
33
+ assert frame.shape == (height, width, 3)
34
+ assert np.array_equal(frame.reshape(-1), rgb)
35
+
36
+ with pytest.raises(ValueError, match="refuses MJPEG"):
37
+ v4l2.frame_array(rgb, v4l2.FORMAT_MJPG, width, height, stride)
38
+
39
+
40
+ def _direct_file(tmp_path, lane_perm=(0, 1, 2), seq=4):
41
+ raw = pattern.generate("corners", 32, 8)
42
+ container = encode_frame(raw, "RGGB", frame_seq=seq, display=(32, 12),
43
+ flags=FLAG_TEST_PATTERN)
44
+ scrambled = np.empty_like(container)
45
+ for k in range(3):
46
+ scrambled[:, :, lane_perm[k]] = container[:, :, k]
47
+ path = tmp_path / "direct.npy"
48
+ np.save(path, scrambled)
49
+ return path
50
+
51
+
52
+ def test_check_from_file_direct_passes_and_fails_honestly(tmp_path, capsys):
53
+ path = _direct_file(tmp_path)
54
+ code = cli.main(["--from-file", str(path), "check", "--pattern", "corners"])
55
+ assert code == 0 and "1 good, 0 bad" in capsys.readouterr().out
56
+
57
+ code = cli.main(["--from-file", str(path), "check", "--pattern", "checker"])
58
+ out = capsys.readouterr().out
59
+ assert code == 1 and "differ from checker" in out
60
+
61
+
62
+ def test_check_applies_the_lane_map(tmp_path, capsys):
63
+ path = _direct_file(tmp_path, lane_perm=(2, 0, 1))
64
+ # Without the map: not a bayerlink frame at all.
65
+ assert cli.main(["--from-file", str(path), "check"]) == 1
66
+ capsys.readouterr()
67
+ # detect_lane_map's answer, applied: (container byte k is on channel...)
68
+ assert cli.main(["--from-file", str(path), "--lane-map", "2,0,1",
69
+ "check", "--pattern", "corners"]) == 0
70
+
71
+
72
+ def test_check_from_file_via_tunnel(tmp_path, capsys):
73
+ display = (192, 40)
74
+ inner_w, inner_h = tunnel.inner_display(*display)
75
+ raw = pattern.generate("counting", inner_w * 2, 20)
76
+ container = encode_frame(raw, "BGGR", frame_seq=0,
77
+ display=(inner_w, inner_h),
78
+ flags=FLAG_TEST_PATTERN)
79
+ grey = tunnel.encode(container, display)
80
+ path = tmp_path / "tunnel.npy"
81
+ np.save(path, grey)
82
+ code = cli.main(["--from-file", str(path), "--via", "tunnel",
83
+ "check", "--pattern", "counting"])
84
+ assert code == 0 and "1 good, 0 bad" in capsys.readouterr().out