Skip to content

Commit

Permalink
feat: Add basic USB-Camera support (#10)
Browse files Browse the repository at this point in the history
Signed-off-by: Patrick Gehrsitz <[email protected]>
  • Loading branch information
mryel00 authored Aug 14, 2024
1 parent 4a16250 commit 9c9c1ce
Show file tree
Hide file tree
Showing 13 changed files with 374 additions and 485 deletions.
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

0 comments on commit 9c9c1ce

Please sign in to comment.