Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add basic USB-Camera support #10

Merged
merged 21 commits into from
Aug 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,5 @@ cover/
.idea/

venv
.venv
.vscode
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,12 @@ On startup the following arguments are supported:
| `--list-controls` | List all available libcamera controls onto the console. Those can be used with `--controls` | |
| `-tf`, `--tuning_filter` | Set a tuning filter file name. | |
| `-tfd`, `--tuning_filter_dir` | Set the directory to look for tuning filters. | |
| `-n`, `--camera_num` | Camera number to be used. All cameras with their number can be shown with `libcamera-hello`. | `0` |

Starting the server without any argument is the same as

```shell
./run.py -b 0.0.0.0 -p 8080 -r 640x480 -f 15 -st '/stream' -sn '/snapshot' -af continuous -l 0.0 -s normal
./run.py -b 0.0.0.0 -p 8080 -r 640x480 -f 15 -st '/stream' -sn '/snapshot' -af continuous -l 0.0 -s normal -n 0
```

The stream can then be accessed at `http://<IP of the server>:8080/stream`
Expand Down
3 changes: 3 additions & 0 deletions resources/spyglass.conf
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
#### NOTE: Values has to be surrounded by double quotes! ("value")
#### NOTE: If commented out or includes typos it will use hardcoded defaults!

#### Libcamera camera to use (INTEGER)[default: 0]
CAMERA_NUM="0"

#### Running Spyglass with proxy or Standalone (BOOL)[default: true]
NO_PROXY="true"

Expand Down
1 change: 1 addition & 0 deletions scripts/spyglass
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ run_spyglass() {
fi

"${PY_BIN}" "$(dirname "${BASE_SPY_PATH}")/run.py" \
--camera_num "${CAMERA_NUM:-0}" \
--bindaddress "${bind_adress}" \
--port "${HTTP_PORT:-8080}" \
--resolution "${RESOLUTION:-640x480}" \
Expand Down
45 changes: 0 additions & 45 deletions spyglass/camera.py

This file was deleted.

25 changes: 25 additions & 0 deletions spyglass/camera/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from picamera2 import Picamera2

from spyglass.camera.camera import Camera
from spyglass.camera.csi import CSI
from spyglass.camera.usb import USB

def init_camera(
camera_num: int,
tuning_filter=None,
tuning_filter_dir=None
) -> Camera:
tuning = None

if tuning_filter:
params = {'tuning_file': tuning_filter}
if tuning_filter_dir:
params['dir'] = tuning_filter_dir
tuning = Picamera2.load_tuning_file(**params)

picam2 = Picamera2(camera_num, tuning=tuning)
if picam2._is_rpi_camera():
cam = CSI(picam2)
else:
cam = USB(picam2)
return cam
94 changes: 94 additions & 0 deletions spyglass/camera/camera.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import libcamera

from abc import ABC, abstractmethod
from picamera2 import Picamera2

from spyglass import logger
from spyglass.exif import create_exif_header
from spyglass.camera_options import process_controls
from spyglass.server import StreamingServer, StreamingHandler

class Camera(ABC):
def __init__(self, picam2: Picamera2):
self.picam2 = picam2

def create_controls(self, fps: int, autofocus: str, lens_position: float, autofocus_speed: str):
controls = {}

if 'FrameRate' in self.picam2.camera_controls:
controls['FrameRate'] = fps

if 'AfMode' in self.picam2.camera_controls:
controls['AfMode'] = autofocus
controls['AfSpeed'] = autofocus_speed
if autofocus == libcamera.controls.AfModeEnum.Manual:
controls['LensPosition'] = lens_position
else:
logger.warning('Attached camera does not support autofocus')

return controls

def configure(self,
width: int,
height: int,
fps: int,
autofocus: str,
lens_position: float,
autofocus_speed: str,
control_list: list[list[str]]=[],
upsidedown=False,
flip_horizontal=False,
flip_vertical=False):
controls = self.create_controls(fps, autofocus, lens_position, autofocus_speed)
c = process_controls(self.picam2, [tuple(ctrl) for ctrl in control_list])
controls.update(c)

transform = libcamera.Transform(
hflip=int(flip_horizontal or upsidedown),
vflip=int(flip_vertical or upsidedown)
)

self.picam2.configure(
self.picam2.create_video_configuration(
main={'size': (width, height)},
controls=controls,
transform=transform
)
)

def _run_server(self,
bind_address,
port,
streaming_handler: StreamingHandler,
get_frame,
stream_url='/stream',
snapshot_url='/snapshot',
orientation_exif=0):
logger.info('Server listening on %s:%d', bind_address, port)
logger.info('Streaming endpoint: %s', stream_url)
logger.info('Snapshot endpoint: %s', snapshot_url)
logger.info('Controls endpoint: %s', '/controls')
address = (bind_address, port)
streaming_handler.picam2 = self.picam2
streaming_handler.get_frame = get_frame
streaming_handler.stream_url = stream_url
streaming_handler.snapshot_url = snapshot_url
if orientation_exif > 0:
streaming_handler.exif_header = create_exif_header(orientation_exif)
else:
streaming_handler.exif_header = None
current_server = StreamingServer(address, streaming_handler)
current_server.serve_forever()

@abstractmethod
def start_and_run_server(self,
bind_address,
port,
stream_url='/stream',
snapshot_url='/snapshot',
orientation_exif=0):
pass

@abstractmethod
def stop(self):
pass
46 changes: 46 additions & 0 deletions spyglass/camera/csi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import io

from picamera2.encoders import MJPEGEncoder
from picamera2.outputs import FileOutput
from threading import Condition

from spyglass import camera
from spyglass.server import StreamingHandler

class CSI(camera.Camera):
def start_and_run_server(self,
bind_address,
port,
stream_url='/stream',
snapshot_url='/snapshot',
orientation_exif=0):

class StreamingOutput(io.BufferedIOBase):
def __init__(self):
self.frame = None
self.condition = Condition()

def write(self, buf):
with self.condition:
self.frame = buf
self.condition.notify_all()
output = StreamingOutput()
def get_frame(inner_self):
with output.condition:
output.condition.wait()
return output.frame

self.picam2.start_recording(MJPEGEncoder(), FileOutput(output))

self._run_server(
bind_address,
port,
StreamingHandler,
get_frame,
stream_url=stream_url,
snapshot_url=snapshot_url,
orientation_exif=orientation_exif
)

def stop(self):
self.picam2.stop_recording()
28 changes: 28 additions & 0 deletions spyglass/camera/usb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from spyglass import camera
from spyglass.server import StreamingHandler

class USB(camera.Camera):
def start_and_run_server(self,
bind_address,
port,
stream_url='/stream',
snapshot_url='/snapshot',
orientation_exif=0):
def get_frame(inner_self):
#TODO: Cuts framerate in 1/n with n streams open, add some kind of buffer
return self.picam2.capture_buffer()

self.picam2.start()

self._run_server(
bind_address,
port,
StreamingHandler,
get_frame,
stream_url=stream_url,
snapshot_url=snapshot_url,
orientation_exif=orientation_exif
)

def stop(self):
self.picam2.stop()
8 changes: 6 additions & 2 deletions spyglass/camera_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,16 @@ def parse_from_string(input_string: str) -> any:
def get_type_str(obj) -> str:
return str(type(obj)).split('\'')[1]

def get_libcamera_controls_string(camera_path: str) -> str:
def get_libcamera_controls_string(camera_num: str) -> str:
ctrls_str = ""
libcam_cm = libcamera.CameraManager.singleton()
cam = libcam_cm.cameras[0]
if camera_num > len(libcam_cm.cameras) - 1:
return ctrls_str
cam = libcam_cm.cameras[camera_num]

def rectangle_to_tuple(rectangle):
return (rectangle.x, rectangle.y, rectangle.width, rectangle.height)

for k, v in cam.controls.items():
if isinstance(v.min, libcamera.Rectangle):
min = rectangle_to_tuple(v.min)
Expand Down
Loading
Loading