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.
- supercamera-0.1.0/.github/workflows/publish.yml +18 -0
- supercamera-0.1.0/.gitignore +220 -0
- supercamera-0.1.0/PKG-INFO +159 -0
- supercamera-0.1.0/README.md +128 -0
- supercamera-0.1.0/pyproject.toml +41 -0
- supercamera-0.1.0/supercamera/__init__.py +6 -0
- supercamera-0.1.0/supercamera/camera.py +344 -0
- supercamera-0.1.0/supercamera/cli.py +120 -0
- supercamera-0.1.0/supercamera/validate.py +32 -0
- supercamera-0.1.0/test_camera.py +162 -0
|
@@ -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,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()
|