supercamera 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.
@@ -0,0 +1,18 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ environment: pypi
12
+ permissions:
13
+ id-token: write
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v4
17
+ - run: uv build
18
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,220 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+
204
+ # Ruff stuff:
205
+ .ruff_cache/
206
+
207
+ # PyPI configuration file
208
+ .pypirc
209
+
210
+ # Marimo
211
+ marimo/_static/
212
+ marimo/_lsp/
213
+ __marimo__/
214
+
215
+ # Streamlit
216
+ .streamlit/secrets.toml
217
+
218
+ # macOS
219
+ .DS_Store
220
+ *.jpg
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: supercamera
3
+ Version: 0.1.0
4
+ Summary: Python driver for USB endoscopes using the supercamera/useeplus protocol (Oasis, Depstech, etc.)
5
+ Project-URL: Homepage, https://github.com/Revise-Robotics/supercamera-endoscope
6
+ Project-URL: Issues, https://github.com/Revise-Robotics/supercamera-endoscope/issues
7
+ Author: g
8
+ License-Expression: MIT
9
+ Keywords: borescope,camera,endoscope,oasis,opencv,supercamera,usb,useeplus
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Multimedia :: Video :: Capture
18
+ Classifier: Topic :: Scientific/Engineering
19
+ Requires-Python: >=3.8
20
+ Requires-Dist: pyusb>=1.2.0
21
+ Provides-Extra: all
22
+ Requires-Dist: numpy; extra == 'all'
23
+ Requires-Dist: opencv-python; extra == 'all'
24
+ Requires-Dist: pillow; extra == 'all'
25
+ Provides-Extra: opencv
26
+ Requires-Dist: numpy; extra == 'opencv'
27
+ Requires-Dist: opencv-python; extra == 'opencv'
28
+ Provides-Extra: validate
29
+ Requires-Dist: pillow; extra == 'validate'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # supercamera
33
+
34
+ Python driver for USB endoscopes that use the **supercamera / useeplus protocol** — the cheap endoscopes sold under brands like Oasis, Depstech, and others that only work with the "UseeePlus" mobile app.
35
+
36
+ These devices don't implement standard UVC, so they won't show up as webcams. This package talks to them directly over USB and gives you JPEG frames or numpy arrays.
37
+
38
+ ## Supported devices
39
+
40
+ | USB ID | Device name | Chip |
41
+ |--------|-------------|------|
42
+ | `2ce3:3828` | supercamera (Geek szitman) | Common |
43
+ | `0329:2022` | supercamera (variant) | Common |
44
+
45
+ Check yours with `lsusb` (Linux) or `system_profiler SPUSBDataType` (macOS).
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install supercamera
51
+ ```
52
+
53
+ For OpenCV/numpy support (`.read()` returns numpy arrays):
54
+
55
+ ```bash
56
+ pip install supercamera[opencv]
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ ### Python API
62
+
63
+ ```python
64
+ from supercamera import Camera
65
+
66
+ # Like cv2.VideoCapture
67
+ with Camera() as cam:
68
+ ret, frame = cam.read() # numpy array (BGR), requires opencv
69
+ # or
70
+ jpeg_bytes = cam.read_jpeg() # raw JPEG, no extra deps
71
+ ```
72
+
73
+ ### Multiple cameras
74
+
75
+ ```python
76
+ from supercamera import Camera, list_devices
77
+
78
+ list_devices() # returns list of CameraInfo with bus/address
79
+ Camera(index=0) # first camera
80
+ Camera(index=1) # second camera
81
+ ```
82
+
83
+ Note: these devices often share the same serial number, so use `index` or `bus`/`address` to distinguish them.
84
+
85
+ ### CLI
86
+
87
+ ```bash
88
+ supercamera --list # list connected cameras
89
+ supercamera # capture one frame
90
+ supercamera -n 10 # capture 10 frames
91
+ supercamera -i 1 # use second camera
92
+ supercamera --show # live view (requires opencv-python)
93
+ ```
94
+
95
+ ### Frame validation
96
+
97
+ ```python
98
+ from supercamera import is_valid_jpeg
99
+ is_valid_jpeg(jpeg_bytes) # True/False (uses Pillow full decode if available)
100
+ ```
101
+
102
+ ### OpenCV pipeline example
103
+
104
+ ```python
105
+ import cv2
106
+ from supercamera import Camera
107
+
108
+ cam = Camera()
109
+ while True:
110
+ ret, frame = cam.read()
111
+ if not ret:
112
+ continue
113
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
114
+ cv2.imshow("endoscope", gray)
115
+ if cv2.waitKey(1) & 0xFF == ord("q"):
116
+ break
117
+ cam.release()
118
+ ```
119
+
120
+ ## How it works
121
+
122
+ These endoscopes use a proprietary USB protocol (`com.useeplus.protocol`) instead of UVC. The driver:
123
+
124
+ 1. Claims USB interfaces, sends magic init (`ff 55 ff 55 ee 10`) and connect command (`bb aa 05 00 00`)
125
+ 2. Reads bulk USB packets containing JPEG-encoded 640x480 frames
126
+ 3. Properly resets the device on disconnect so it's ready for the next session
127
+
128
+ Resolution is **640x480** regardless of what the product listing claims.
129
+
130
+ ## Testing
131
+
132
+ Plug in one or both cameras, then:
133
+
134
+ ```bash
135
+ python test_camera.py
136
+ ```
137
+
138
+ Runs: device listing, single capture, 3x reconnect (no replug), OpenCV read, and two-camera simultaneous capture (if two connected).
139
+
140
+ ## Platform notes
141
+
142
+ - **macOS**: Works out of the box. No kernel extensions needed.
143
+ - **Linux**: Works. You may need a udev rule for non-root access:
144
+ ```bash
145
+ echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="2ce3", ATTR{idProduct}=="3828", MODE="0666"' | \
146
+ sudo tee /etc/udev/rules.d/99-supercamera.rules
147
+ sudo udevadm control --reload-rules
148
+ ```
149
+ - **Windows**: Should work with [libusb](https://libusb.info/) + [Zadig](https://zadig.akeo.ie/) driver. Untested.
150
+
151
+ ## Credits
152
+
153
+ Protocol reverse-engineered by:
154
+ - [hbens/geek-szitman-supercamera](https://github.com/hbens/geek-szitman-supercamera) (C++ PoC, CC0)
155
+ - [MAkcanca/useeplus-linux-driver](https://github.com/MAkcanca/useeplus-linux-driver) (Linux kernel driver)
156
+
157
+ ## License
158
+
159
+ MIT
@@ -0,0 +1,128 @@
1
+ # supercamera
2
+
3
+ Python driver for USB endoscopes that use the **supercamera / useeplus protocol** — the cheap endoscopes sold under brands like Oasis, Depstech, and others that only work with the "UseeePlus" mobile app.
4
+
5
+ These devices don't implement standard UVC, so they won't show up as webcams. This package talks to them directly over USB and gives you JPEG frames or numpy arrays.
6
+
7
+ ## Supported devices
8
+
9
+ | USB ID | Device name | Chip |
10
+ |--------|-------------|------|
11
+ | `2ce3:3828` | supercamera (Geek szitman) | Common |
12
+ | `0329:2022` | supercamera (variant) | Common |
13
+
14
+ Check yours with `lsusb` (Linux) or `system_profiler SPUSBDataType` (macOS).
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install supercamera
20
+ ```
21
+
22
+ For OpenCV/numpy support (`.read()` returns numpy arrays):
23
+
24
+ ```bash
25
+ pip install supercamera[opencv]
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ### Python API
31
+
32
+ ```python
33
+ from supercamera import Camera
34
+
35
+ # Like cv2.VideoCapture
36
+ with Camera() as cam:
37
+ ret, frame = cam.read() # numpy array (BGR), requires opencv
38
+ # or
39
+ jpeg_bytes = cam.read_jpeg() # raw JPEG, no extra deps
40
+ ```
41
+
42
+ ### Multiple cameras
43
+
44
+ ```python
45
+ from supercamera import Camera, list_devices
46
+
47
+ list_devices() # returns list of CameraInfo with bus/address
48
+ Camera(index=0) # first camera
49
+ Camera(index=1) # second camera
50
+ ```
51
+
52
+ Note: these devices often share the same serial number, so use `index` or `bus`/`address` to distinguish them.
53
+
54
+ ### CLI
55
+
56
+ ```bash
57
+ supercamera --list # list connected cameras
58
+ supercamera # capture one frame
59
+ supercamera -n 10 # capture 10 frames
60
+ supercamera -i 1 # use second camera
61
+ supercamera --show # live view (requires opencv-python)
62
+ ```
63
+
64
+ ### Frame validation
65
+
66
+ ```python
67
+ from supercamera import is_valid_jpeg
68
+ is_valid_jpeg(jpeg_bytes) # True/False (uses Pillow full decode if available)
69
+ ```
70
+
71
+ ### OpenCV pipeline example
72
+
73
+ ```python
74
+ import cv2
75
+ from supercamera import Camera
76
+
77
+ cam = Camera()
78
+ while True:
79
+ ret, frame = cam.read()
80
+ if not ret:
81
+ continue
82
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
83
+ cv2.imshow("endoscope", gray)
84
+ if cv2.waitKey(1) & 0xFF == ord("q"):
85
+ break
86
+ cam.release()
87
+ ```
88
+
89
+ ## How it works
90
+
91
+ These endoscopes use a proprietary USB protocol (`com.useeplus.protocol`) instead of UVC. The driver:
92
+
93
+ 1. Claims USB interfaces, sends magic init (`ff 55 ff 55 ee 10`) and connect command (`bb aa 05 00 00`)
94
+ 2. Reads bulk USB packets containing JPEG-encoded 640x480 frames
95
+ 3. Properly resets the device on disconnect so it's ready for the next session
96
+
97
+ Resolution is **640x480** regardless of what the product listing claims.
98
+
99
+ ## Testing
100
+
101
+ Plug in one or both cameras, then:
102
+
103
+ ```bash
104
+ python test_camera.py
105
+ ```
106
+
107
+ Runs: device listing, single capture, 3x reconnect (no replug), OpenCV read, and two-camera simultaneous capture (if two connected).
108
+
109
+ ## Platform notes
110
+
111
+ - **macOS**: Works out of the box. No kernel extensions needed.
112
+ - **Linux**: Works. You may need a udev rule for non-root access:
113
+ ```bash
114
+ echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="2ce3", ATTR{idProduct}=="3828", MODE="0666"' | \
115
+ sudo tee /etc/udev/rules.d/99-supercamera.rules
116
+ sudo udevadm control --reload-rules
117
+ ```
118
+ - **Windows**: Should work with [libusb](https://libusb.info/) + [Zadig](https://zadig.akeo.ie/) driver. Untested.
119
+
120
+ ## Credits
121
+
122
+ Protocol reverse-engineered by:
123
+ - [hbens/geek-szitman-supercamera](https://github.com/hbens/geek-szitman-supercamera) (C++ PoC, CC0)
124
+ - [MAkcanca/useeplus-linux-driver](https://github.com/MAkcanca/useeplus-linux-driver) (Linux kernel driver)
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "supercamera"
3
+ version = "0.1.0"
4
+ description = "Python driver for USB endoscopes using the supercamera/useeplus protocol (Oasis, Depstech, etc.)"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.8"
8
+ authors = [
9
+ { name = "g" },
10
+ ]
11
+ keywords = ["usb", "endoscope", "camera", "supercamera", "useeplus", "oasis", "borescope", "opencv"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "Intended Audience :: Science/Research",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: MacOS",
18
+ "Operating System :: POSIX :: Linux",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Multimedia :: Video :: Capture",
21
+ "Topic :: Scientific/Engineering",
22
+ ]
23
+ dependencies = [
24
+ "pyusb>=1.2.0",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ opencv = ["opencv-python", "numpy"]
29
+ validate = ["pillow"]
30
+ all = ["opencv-python", "numpy", "pillow"]
31
+
32
+ [project.scripts]
33
+ supercamera = "supercamera.cli:main"
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/Revise-Robotics/supercamera-endoscope"
37
+ Issues = "https://github.com/Revise-Robotics/supercamera-endoscope/issues"
38
+
39
+ [build-system]
40
+ requires = ["hatchling"]
41
+ build-backend = "hatchling.build"
@@ -0,0 +1,6 @@
1
+ """Python driver for USB endoscopes using the supercamera/useeplus protocol."""
2
+
3
+ from supercamera.camera import Camera, CameraInfo, list_devices
4
+ from supercamera.validate import is_valid_jpeg
5
+
6
+ __all__ = ["Camera", "CameraInfo", "list_devices", "is_valid_jpeg"]
@@ -0,0 +1,344 @@
1
+ """USB communication with supercamera devices (Oasis, Depstech, etc.)."""
2
+
3
+ import usb.core
4
+ import usb.util
5
+ import time
6
+
7
+ # Known vendor/product ID pairs for supercamera devices
8
+ KNOWN_DEVICES = [
9
+ (0x2CE3, 0x3828),
10
+ (0x0329, 0x2022),
11
+ ]
12
+
13
+ # Interface 1 (com.useeplus.protocol) endpoints
14
+ EP_OUT = 0x01
15
+ EP_IN = 0x81
16
+
17
+ # Interface 0 (iAP) endpoints
18
+ EP_IAP_OUT = 0x02
19
+ EP_IAP_IN = 0x82
20
+
21
+ # Protocol commands (reverse-engineered from C++ PoC and Linux driver)
22
+ MAGIC_INIT = bytes([0xFF, 0x55, 0xFF, 0x55, 0xEE, 0x10])
23
+ CONNECT_CMD = bytes([0xBB, 0xAA, 0x05, 0x00, 0x00])
24
+
25
+ HEADER_SIZE = 12
26
+ JPEG_SOI = bytes([0xFF, 0xD8])
27
+ JPEG_EOI = bytes([0xFF, 0xD9])
28
+
29
+
30
+ class CameraInfo:
31
+ """Info about a detected supercamera device."""
32
+
33
+ def __init__(self, usb_dev):
34
+ self._dev = usb_dev
35
+
36
+ @property
37
+ def vendor_id(self):
38
+ return self._dev.idVendor
39
+
40
+ @property
41
+ def product_id(self):
42
+ return self._dev.idProduct
43
+
44
+ @property
45
+ def serial_number(self):
46
+ try:
47
+ return self._dev.serial_number
48
+ except Exception:
49
+ return None
50
+
51
+ @property
52
+ def manufacturer(self):
53
+ try:
54
+ return self._dev.manufacturer
55
+ except Exception:
56
+ return None
57
+
58
+ @property
59
+ def product(self):
60
+ try:
61
+ return self._dev.product
62
+ except Exception:
63
+ return None
64
+
65
+ @property
66
+ def bus(self):
67
+ return self._dev.bus
68
+
69
+ @property
70
+ def address(self):
71
+ return self._dev.address
72
+
73
+ def __repr__(self):
74
+ return (
75
+ f"CameraInfo({self.vendor_id:04x}:{self.product_id:04x} "
76
+ f"serial={self.serial_number!r} bus={self.bus} addr={self.address})"
77
+ )
78
+
79
+
80
+ def list_devices():
81
+ """Find all connected supercamera devices.
82
+
83
+ Returns:
84
+ list[CameraInfo]: Info about each detected device.
85
+ """
86
+ found = []
87
+ for vid, pid in KNOWN_DEVICES:
88
+ devs = usb.core.find(find_all=True, idVendor=vid, idProduct=pid)
89
+ for d in devs:
90
+ found.append(CameraInfo(d))
91
+ return found
92
+
93
+
94
+ class Camera:
95
+ """USB endoscope camera using the useeplus/supercamera protocol.
96
+
97
+ Works as a drop-in for cv2.VideoCapture:
98
+
99
+ from supercamera import Camera
100
+ cam = Camera()
101
+ ret, frame = cam.read() # returns numpy array (requires opencv-python)
102
+ cam.release()
103
+
104
+ Or use as a context manager:
105
+
106
+ with Camera() as cam:
107
+ ret, frame = cam.read()
108
+
109
+ For raw JPEG bytes without OpenCV dependency:
110
+
111
+ cam = Camera()
112
+ jpeg_bytes = cam.read_jpeg()
113
+ cam.release()
114
+
115
+ With multiple cameras, select by serial number or index:
116
+
117
+ cameras = supercamera.list_devices()
118
+ cam = Camera(serial="022018050100030")
119
+ cam = Camera(index=1) # second camera
120
+ """
121
+
122
+ def __init__(self, serial=None, index=0, timeout=5.0):
123
+ """Open a supercamera device.
124
+
125
+ Args:
126
+ serial: Serial number string to match a specific camera.
127
+ index: Which camera to open if multiple are found (0-based).
128
+ Ignored if serial is provided.
129
+ timeout: Seconds to wait for a frame before giving up.
130
+ """
131
+ self._dev = None
132
+ self._streaming = False
133
+ self._frames_read = 0
134
+ self._serial = serial
135
+ self._index = index
136
+ self._timeout = timeout
137
+ self._open()
138
+
139
+ def _find_device(self):
140
+ all_devs = []
141
+ for vid, pid in KNOWN_DEVICES:
142
+ devs = list(usb.core.find(find_all=True, idVendor=vid, idProduct=pid))
143
+ all_devs.extend(devs)
144
+
145
+ if not all_devs:
146
+ raise RuntimeError(
147
+ "No supercamera devices found. Is it plugged in?\n"
148
+ "Known USB IDs: " + ", ".join(f"{v:04x}:{p:04x}" for v, p in KNOWN_DEVICES)
149
+ )
150
+
151
+ if self._serial is not None:
152
+ for dev in all_devs:
153
+ try:
154
+ if dev.serial_number == self._serial:
155
+ return dev
156
+ except Exception:
157
+ continue
158
+ serials = []
159
+ for dev in all_devs:
160
+ try:
161
+ serials.append(dev.serial_number)
162
+ except Exception:
163
+ serials.append("???")
164
+ raise RuntimeError(
165
+ f"No camera with serial {self._serial!r} found. "
166
+ f"Available: {serials}"
167
+ )
168
+
169
+ if self._index >= len(all_devs):
170
+ raise RuntimeError(
171
+ f"Camera index {self._index} out of range. "
172
+ f"Found {len(all_devs)} device(s)."
173
+ )
174
+
175
+ return all_devs[self._index]
176
+
177
+ def _open(self):
178
+ dev = self._find_device()
179
+ self._dev = dev
180
+
181
+ # Detach kernel drivers
182
+ for intf in [0, 1]:
183
+ try:
184
+ if dev.is_kernel_driver_active(intf):
185
+ dev.detach_kernel_driver(intf)
186
+ except Exception:
187
+ pass
188
+
189
+ dev.set_configuration()
190
+ usb.util.claim_interface(dev, 0)
191
+ usb.util.claim_interface(dev, 1)
192
+
193
+ # Drain pending heartbeat data from iAP interface
194
+ for _ in range(30):
195
+ try:
196
+ dev.read(EP_IAP_IN, 512, timeout=100)
197
+ except usb.core.USBError:
198
+ break
199
+
200
+ # Activate bulk endpoints on interface 1
201
+ dev.set_interface_altsetting(interface=1, alternate_setting=1)
202
+ dev.clear_halt(EP_OUT)
203
+
204
+ # Send init sequence
205
+ dev.write(EP_IAP_OUT, MAGIC_INIT, timeout=1000)
206
+ dev.write(EP_OUT, CONNECT_CMD, timeout=1000)
207
+ time.sleep(0.3)
208
+
209
+ self._streaming = True
210
+ self._frames_read = 0
211
+
212
+ # Skip first frame (always partial/corrupt after connect)
213
+ self._read_jpeg_internal()
214
+
215
+ def _read_jpeg_internal(self):
216
+ """Read one complete JPEG frame from USB. Returns bytes or None."""
217
+ buf = bytearray()
218
+ in_frame = False
219
+ deadline = time.monotonic() + self._timeout
220
+
221
+ while time.monotonic() < deadline:
222
+ try:
223
+ data = bytes(self._dev.read(EP_IN, 65536, timeout=1000))
224
+ except usb.core.USBError:
225
+ continue
226
+
227
+ # Strip 12-byte protocol header
228
+ payload = data
229
+ if len(data) >= HEADER_SIZE and data[0] == 0xAA and data[1] == 0xBB:
230
+ payload = data[HEADER_SIZE:]
231
+
232
+ soi_pos = payload.find(JPEG_SOI)
233
+ if soi_pos >= 0 and not in_frame:
234
+ in_frame = True
235
+ buf = bytearray(payload[soi_pos:])
236
+ elif in_frame:
237
+ buf.extend(payload)
238
+
239
+ if in_frame:
240
+ eoi_pos = buf.find(JPEG_EOI)
241
+ if eoi_pos >= 0:
242
+ self._frames_read += 1
243
+ return bytes(buf[:eoi_pos + 2])
244
+
245
+ return None
246
+
247
+ def read_jpeg(self):
248
+ """Read one JPEG frame as raw bytes.
249
+
250
+ Returns:
251
+ bytes or None: JPEG data, or None if read failed.
252
+ """
253
+ if not self._streaming:
254
+ raise RuntimeError("Camera is not streaming. Call open() or use Camera().")
255
+ return self._read_jpeg_internal()
256
+
257
+ def read(self):
258
+ """Read one frame as a numpy array (BGR, like OpenCV).
259
+
260
+ Returns:
261
+ tuple: (success: bool, frame: numpy.ndarray or None)
262
+ """
263
+ try:
264
+ import cv2
265
+ import numpy as np
266
+ except ImportError:
267
+ raise ImportError(
268
+ "opencv-python and numpy are required for read(). "
269
+ "Install them with: pip install opencv-python numpy\n"
270
+ "Or use read_jpeg() for raw JPEG bytes without extra dependencies."
271
+ )
272
+
273
+ jpeg = self.read_jpeg()
274
+ if jpeg is None:
275
+ return False, None
276
+
277
+ arr = np.frombuffer(jpeg, dtype=np.uint8)
278
+ frame = cv2.imdecode(arr, cv2.IMREAD_COLOR)
279
+ if frame is None:
280
+ return False, None
281
+ return True, frame
282
+
283
+ def release(self):
284
+ """Stop streaming and release the USB device."""
285
+ if self._dev is None:
286
+ return
287
+
288
+ if self._streaming:
289
+ try:
290
+ self._dev.set_interface_altsetting(interface=1, alternate_setting=0)
291
+ except Exception:
292
+ pass
293
+ self._streaming = False
294
+
295
+ for intf in [1, 0]:
296
+ try:
297
+ usb.util.release_interface(self._dev, intf)
298
+ except Exception:
299
+ pass
300
+
301
+ try:
302
+ self._dev.reset()
303
+ except Exception:
304
+ pass
305
+
306
+ self._dev = None
307
+
308
+ @property
309
+ def serial_number(self):
310
+ if self._dev is None:
311
+ return None
312
+ try:
313
+ return self._dev.serial_number
314
+ except Exception:
315
+ return None
316
+
317
+ @property
318
+ def bus(self):
319
+ return self._dev.bus if self._dev else None
320
+
321
+ @property
322
+ def address(self):
323
+ return self._dev.address if self._dev else None
324
+
325
+ @property
326
+ def is_opened(self):
327
+ return self._streaming and self._dev is not None
328
+
329
+ @property
330
+ def resolution(self):
331
+ return (640, 480)
332
+
333
+ @property
334
+ def frames_read(self):
335
+ return self._frames_read
336
+
337
+ def __enter__(self):
338
+ return self
339
+
340
+ def __exit__(self, *args):
341
+ self.release()
342
+
343
+ def __del__(self):
344
+ self.release()
@@ -0,0 +1,120 @@
1
+ """CLI tool to capture frames from a supercamera USB endoscope."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+
7
+ def main():
8
+ parser = argparse.ArgumentParser(
9
+ description="Capture JPEG frames from a USB endoscope (supercamera/useeplus protocol)"
10
+ )
11
+ parser.add_argument(
12
+ "-n", "--num-frames", type=int, default=1,
13
+ help="Number of frames to capture (default: 1)"
14
+ )
15
+ parser.add_argument(
16
+ "-o", "--output", default="frame",
17
+ help="Output filename prefix (default: 'frame')"
18
+ )
19
+ parser.add_argument(
20
+ "-s", "--serial", default=None,
21
+ help="Serial number of the camera to use"
22
+ )
23
+ parser.add_argument(
24
+ "-i", "--index", type=int, default=0,
25
+ help="Camera index if multiple are connected (default: 0)"
26
+ )
27
+ parser.add_argument(
28
+ "-l", "--list", action="store_true",
29
+ help="List connected cameras and exit"
30
+ )
31
+ parser.add_argument(
32
+ "--show", action="store_true",
33
+ help="Display frames using OpenCV (requires opencv-python)"
34
+ )
35
+ args = parser.parse_args()
36
+
37
+ if args.list:
38
+ _list_cameras()
39
+ return
40
+
41
+ from supercamera import Camera
42
+
43
+ try:
44
+ cam = Camera(serial=args.serial, index=args.index)
45
+ except RuntimeError as e:
46
+ print(f"Error: {e}", file=sys.stderr)
47
+ sys.exit(1)
48
+
49
+ print(f"Connected: {cam.resolution[0]}x{cam.resolution[1]} (serial: {cam.serial_number})")
50
+
51
+ if args.show:
52
+ _live_view(cam)
53
+ else:
54
+ _capture(cam, args.num_frames, args.output)
55
+
56
+
57
+ def _list_cameras():
58
+ from supercamera import list_devices
59
+
60
+ cameras = list_devices()
61
+ if not cameras:
62
+ print("No supercamera devices found.")
63
+ sys.exit(1)
64
+
65
+ print(f"Found {len(cameras)} device(s):\n")
66
+ for i, cam in enumerate(cameras):
67
+ print(f" [{i}] {cam.vendor_id:04x}:{cam.product_id:04x}"
68
+ f" serial={cam.serial_number}"
69
+ f" ({cam.manufacturer} {cam.product})"
70
+ f" bus={cam.bus} addr={cam.address}")
71
+
72
+
73
+ def _capture(cam, num_frames, prefix):
74
+ try:
75
+ for i in range(1, num_frames + 1):
76
+ jpeg = cam.read_jpeg()
77
+ if jpeg is None:
78
+ print(f"Failed to read frame {i}", file=sys.stderr)
79
+ continue
80
+ if num_frames == 1:
81
+ fname = f"{prefix}.jpg"
82
+ else:
83
+ fname = f"{prefix}_{i:03d}.jpg"
84
+ with open(fname, "wb") as f:
85
+ f.write(jpeg)
86
+ print(f"Saved {fname} ({len(jpeg)} bytes)")
87
+ finally:
88
+ cam.release()
89
+
90
+
91
+ def _live_view(cam):
92
+ try:
93
+ import cv2
94
+ except ImportError:
95
+ print("Live view requires opencv-python: pip install opencv-python", file=sys.stderr)
96
+ sys.exit(1)
97
+
98
+ print("Live view — press 'q' to quit, 's' to save a frame")
99
+ saved = 0
100
+ try:
101
+ while True:
102
+ ret, frame = cam.read()
103
+ if not ret:
104
+ continue
105
+ cv2.imshow("supercamera", frame)
106
+ key = cv2.waitKey(1) & 0xFF
107
+ if key == ord("q"):
108
+ break
109
+ elif key == ord("s"):
110
+ saved += 1
111
+ fname = f"capture_{saved:03d}.jpg"
112
+ cv2.imwrite(fname, frame)
113
+ print(f"Saved {fname}")
114
+ finally:
115
+ cam.release()
116
+ cv2.destroyAllWindows()
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
@@ -0,0 +1,32 @@
1
+ """JPEG frame validation."""
2
+
3
+
4
+ def is_valid_jpeg(data):
5
+ """Check if JPEG data is structurally valid.
6
+
7
+ Checks SOI/EOI markers and attempts a full decode if Pillow is available.
8
+
9
+ Returns:
10
+ bool: True if the JPEG is valid.
11
+ """
12
+ if not data or len(data) < 4:
13
+ return False
14
+
15
+ # Must start with SOI and end with EOI
16
+ if data[0] != 0xFF or data[1] != 0xD8:
17
+ return False
18
+ if data[-2] != 0xFF or data[-1] != 0xD9:
19
+ return False
20
+
21
+ # Try full decode (catches truncation, corrupted huffman tables, etc.)
22
+ try:
23
+ from PIL import Image
24
+ import io
25
+ img = Image.open(io.BytesIO(data))
26
+ img.load()
27
+ except ImportError:
28
+ pass # No Pillow, markers-only check is the best we can do
29
+ except Exception:
30
+ return False
31
+
32
+ return True
@@ -0,0 +1,162 @@
1
+ """Interactive test script for supercamera devices."""
2
+
3
+ import time
4
+ import sys
5
+
6
+
7
+ def test_list():
8
+ """Test device listing."""
9
+ from supercamera import list_devices
10
+
11
+ print("=" * 60)
12
+ print("TEST: list_devices()")
13
+ print("=" * 60)
14
+
15
+ cameras = list_devices()
16
+ print(f"Found {len(cameras)} device(s)")
17
+ for i, info in enumerate(cameras):
18
+ print(f" [{i}] {info}")
19
+
20
+ if not cameras:
21
+ print("\n No cameras found! Plug one in and try again.")
22
+ return 0
23
+
24
+ print(" PASS")
25
+ return len(cameras)
26
+
27
+
28
+ def test_single_capture(serial=None, index=0, label=""):
29
+ """Test opening, capturing, and releasing one camera."""
30
+ from supercamera import Camera
31
+ from supercamera.validate import is_valid_jpeg
32
+
33
+ tag = f" ({label})" if label else ""
34
+ print(f"\nTEST: single capture{tag}")
35
+ print("-" * 40)
36
+
37
+ cam = Camera(serial=serial, index=index)
38
+ print(f" Opened: serial={cam.serial_number}")
39
+
40
+ jpeg = cam.read_jpeg()
41
+ assert jpeg is not None, "read_jpeg() returned None"
42
+ print(f" read_jpeg(): {len(jpeg)} bytes")
43
+
44
+ valid = is_valid_jpeg(jpeg)
45
+ print(f" Valid JPEG: {valid}")
46
+ assert valid, "Frame failed validation"
47
+
48
+ cam.release()
49
+ print(f" Released")
50
+ print(" PASS")
51
+ return jpeg
52
+
53
+
54
+ def test_repeated_connect(serial=None, index=0, rounds=3):
55
+ """Test connecting and disconnecting multiple times without replug."""
56
+ from supercamera import Camera
57
+ from supercamera.validate import is_valid_jpeg
58
+
59
+ print(f"\nTEST: {rounds}x connect/capture/release (no replug)")
60
+ print("-" * 40)
61
+
62
+ for i in range(1, rounds + 1):
63
+ cam = Camera(serial=serial, index=index)
64
+ jpeg = cam.read_jpeg()
65
+ valid = jpeg is not None and is_valid_jpeg(jpeg)
66
+ print(f" Round {i}: {len(jpeg) if jpeg else 0} bytes, valid={valid}, serial={cam.serial_number}")
67
+ cam.release()
68
+ assert valid, f"Round {i} failed"
69
+ if i < rounds:
70
+ time.sleep(1)
71
+
72
+ print(" PASS")
73
+
74
+
75
+ def test_opencv_read(serial=None, index=0):
76
+ """Test the OpenCV .read() path."""
77
+ try:
78
+ import cv2
79
+ import numpy as np
80
+ except ImportError:
81
+ print("\nTEST: opencv read — SKIPPED (opencv not installed)")
82
+ return
83
+
84
+ from supercamera import Camera
85
+
86
+ print(f"\nTEST: opencv .read()")
87
+ print("-" * 40)
88
+
89
+ with Camera(serial=serial, index=index) as cam:
90
+ ret, frame = cam.read()
91
+ print(f" ret={ret}, shape={frame.shape if ret else None}, dtype={frame.dtype if ret else None}")
92
+ assert ret, "read() returned False"
93
+ assert frame.shape == (480, 640, 3), f"Unexpected shape: {frame.shape}"
94
+ assert frame.dtype == np.uint8
95
+ cv2.imwrite("test_opencv.jpg", frame)
96
+ print(" Saved test_opencv.jpg")
97
+
98
+ print(" PASS")
99
+
100
+
101
+ def test_two_cameras():
102
+ """Test opening two cameras simultaneously."""
103
+ from supercamera import Camera, list_devices
104
+ from supercamera.validate import is_valid_jpeg
105
+
106
+ cameras = list_devices()
107
+ if len(cameras) < 2:
108
+ print(f"\nTEST: two cameras — SKIPPED (only {len(cameras)} device(s) found)")
109
+ return
110
+
111
+ print(f"\nTEST: two cameras simultaneously")
112
+ print("-" * 40)
113
+
114
+ cam0 = Camera(index=0)
115
+ print(f" Camera 0 opened: serial={cam0.serial_number}")
116
+
117
+ cam1 = Camera(index=1)
118
+ print(f" Camera 1 opened: serial={cam1.serial_number}")
119
+
120
+ jpeg0 = cam0.read_jpeg()
121
+ jpeg1 = cam1.read_jpeg()
122
+
123
+ valid0 = jpeg0 is not None and is_valid_jpeg(jpeg0)
124
+ valid1 = jpeg1 is not None and is_valid_jpeg(jpeg1)
125
+
126
+ print(f" Camera 0: {len(jpeg0) if jpeg0 else 0} bytes, valid={valid0}")
127
+ print(f" Camera 1: {len(jpeg1) if jpeg1 else 0} bytes, valid={valid1}")
128
+
129
+ cam0.release()
130
+ cam1.release()
131
+
132
+ assert valid0, "Camera 0 frame invalid"
133
+ assert valid1, "Camera 1 frame invalid"
134
+ print(" PASS")
135
+
136
+
137
+ def main():
138
+ print("supercamera test suite")
139
+ print("Plug in your camera(s) before running.\n")
140
+
141
+ num_cams = test_list()
142
+ if num_cams == 0:
143
+ sys.exit(1)
144
+
145
+ test_single_capture()
146
+ time.sleep(1)
147
+
148
+ test_repeated_connect(rounds=3)
149
+ time.sleep(1)
150
+
151
+ test_opencv_read()
152
+ time.sleep(1)
153
+
154
+ test_two_cameras()
155
+
156
+ print("\n" + "=" * 60)
157
+ print("ALL TESTS PASSED")
158
+ print("=" * 60)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ main()