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

Add a cross-platform API for camera access #2266

Merged
merged 22 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
87cb78c
Add a public API for camera access.
freakboy3742 Dec 8, 2023
5ca29de
Add an example app that uses the camera API.
freakboy3742 Dec 8, 2023
b828545
Added a prototype iOS implementation of the Camera API.
freakboy3742 Dec 8, 2023
253b87e
Add changenote.
freakboy3742 Dec 8, 2023
ec4078a
Add core Camera API tests.
freakboy3742 Dec 13, 2023
7c76277
Add docs for Camera API.
freakboy3742 Dec 13, 2023
324f3a0
Correct iOS implementation.
freakboy3742 Dec 13, 2023
95d1e8e
Remove sync option from example app.
freakboy3742 Dec 14, 2023
0f61bd9
Add testbed tests for hardware.
freakboy3742 Dec 16, 2023
fcea11b
Add macOS implementation of camera.
freakboy3742 Dec 31, 2023
8d16588
Add test coverage for macOS camera API.
freakboy3742 Jan 8, 2024
e0892fe
Correct macOS permssions and mocking.
freakboy3742 Jan 9, 2024
5ba04d1
Correct docstring
freakboy3742 Jan 10, 2024
99254f8
Added a full impl layer for camera devices.
freakboy3742 Jan 10, 2024
5bc16e6
Rename Device to CameraDevice to avoid future ambiguity.
freakboy3742 Jan 10, 2024
c474869
Clarified permission docstring.
freakboy3742 Jan 11, 2024
35881c3
Simplified naming of API for permissions.
freakboy3742 Jan 11, 2024
9acb8c3
Use an icon for the shutter button on macOS.
freakboy3742 Jan 11, 2024
5c66a44
Correct doc references to old permission names.
freakboy3742 Jan 11, 2024
ee06657
Clarify that the arguments to take_photo are initial values.
freakboy3742 Jan 11, 2024
7633a1c
Add camera permission request to testbed.
freakboy3742 Jan 12, 2024
c891f0c
Mock the entire of the iOS camera API.
freakboy3742 Jan 17, 2024
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: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ jobs:

- name: Test App
working-directory: testbed
timeout-minutes: 15
run: ${{ matrix.briefcase-run-prefix }} briefcase run ${{ matrix.backend }} --test ${{ matrix.briefcase-run-args }}

- name: Upload logs
Expand All @@ -254,7 +255,6 @@ jobs:
# only occur in CI, and can't be reproduced locally. When it runs, it will
# open an SSH server (URL reported in the logs) so you can ssh into the CI
# machine.
# - uses: actions/checkout@v3
# - name: Setup tmate session
# uses: mxschmitt/action-tmate@v3
# if: failure()
1 change: 1 addition & 0 deletions changes/2266.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A cross-platform API for camera access was added.
5 changes: 3 additions & 2 deletions cocoa/src/toga_cocoa/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
from .app import App, DocumentApp, MainWindow
from .command import Command
from .documents import Document

# Resources
from .fonts import Font
from .hardware.camera import Camera
from .icons import Icon
from .images import Image
from .paths import Paths
Expand Down Expand Up @@ -52,6 +51,8 @@ def not_implemented(feature):
"Image",
"Paths",
"dialogs",
# Hardware
"Camera",
# Widgets
"ActivityIndicator",
"Box",
Expand Down
301 changes: 301 additions & 0 deletions cocoa/src/toga_cocoa/hardware/camera.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
from __future__ import annotations

from threading import Thread

from rubicon.objc import Block, objc_method

import toga
from toga.colors import BLACK, RED
from toga.constants import FlashMode
from toga.style import Pack
from toga.style.pack import COLUMN

# for classes that need to be monkeypatched for testing
from toga_cocoa import libs as cocoa
from toga_cocoa.images import nsdata_to_bytes
from toga_cocoa.libs import (
AVAuthorizationStatus,
AVCaptureFlashMode,
AVCapturePhotoOutput,
AVCaptureSession,
AVCaptureSessionPresetPhoto,
AVCaptureVideoPreviewLayer,
AVLayerVideoGravityResizeAspectFill,
AVMediaTypeVideo,
)


def native_flash_mode(flash):
return {
FlashMode.ON: AVCaptureFlashMode.On,
FlashMode.OFF: AVCaptureFlashMode.Off,
}.get(flash, AVCaptureFlashMode.Auto)


class CameraDevice:
def __init__(self, native):
self.native = native

def id(self):
return str(self.native.uniqueID)

def name(self):
return str(self.native.localizedName)

def has_flash(self):
return self.native.isFlashAvailable()


# This is the native delegate, but we can't force the delegate to be invoked because we
# can't create a mock Photo; so we push all logic to the window, and mark this class no
# cover
class TogaCameraCaptureSession(AVCaptureSession): # pragma: no cover
@objc_method
def captureOutput_didFinishProcessingPhoto_error_(
self, output, photo, error
) -> None:
# A photo has been taken.
self.window.photo_taken(photo)


class TogaCameraWindow(toga.Window):
def __init__(self, camera, device, flash, result):
super().__init__(
title="Camera",
on_close=self.close_window,
resizable=False,
# This size is too small by design; it will be expanded by the layout rules.
size=(640, 360),
)
self.camera = camera
self.result = result

self.create_preview_window()
self.create_camera_session(device, flash)

def create_preview_window(self):
# A preview window, fixed 16:9 aspect ratio
self.preview = toga.Box(style=Pack(width=640, height=360))

# Set an initially empty list of devices. This will be populated once the window
# is shown, so that getting the list of devices doesn't slow down showing the
# capture window.
self.device_select = toga.Selection(
items=[],
on_change=self.change_camera,
style=Pack(width=200),
)

# The shutter button. Initially disabled until we know we have a camera available
self.shutter_button = toga.Button(
icon=toga.Icon("camera", system=True),
on_press=self.take_photo,
style=Pack(background_color=RED),
enabled=False,
)

# The flash mode. Initially disable the flash.
self.flash_mode = toga.Selection(
items=[],
style=Pack(width=75),
)

# Construct the overall layout
self.content = toga.Box(
children=[
# The preview box will have its layer replaced by the the video preview.
# Put the preview box inside another box so that we have a surface that
# can show a black background while the camera is initializing.
toga.Box(
children=[self.preview],
style=Pack(background_color=BLACK),
),
toga.Box(
# Put the controls in a ROW box; the shutter button is
# in the middle, non-flexible, so that it is centered.
children=[
toga.Box(
children=[self.device_select],
style=Pack(flex=1),
),
self.shutter_button,
toga.Box(
children=[
toga.Box(style=Pack(flex=1)),
toga.Label("Flash:"),
self.flash_mode,
],
style=Pack(flex=1),
),
],
style=Pack(padding=10),
),
],
style=Pack(direction=COLUMN),
)

# This is the method that creates the native camera session. Mocking these methods
# is extremely difficult (impossible?); plus we can't know what cameras the test
# machine will have. So - we mock this entire method, and mark it no-cover.
def create_camera_session(self, device, flash): # pragma: no cover
self.camera_session = TogaCameraCaptureSession.alloc().init()
self.camera_session.window = self
self.camera_session.beginConfiguration()

# Create a preview layer, rendering into the preview box
preview_layer = AVCaptureVideoPreviewLayer.layerWithSession(self.camera_session)
preview_layer.setVideoGravity(AVLayerVideoGravityResizeAspectFill)
preview_layer.frame = self.preview._impl.native.bounds
self.preview._impl.native.setLayer(preview_layer)

# Specify that we want photo output.
output = AVCapturePhotoOutput.alloc().init()
output.setHighResolutionCaptureEnabled(True)
self.camera_session.addOutput(output)
self.camera_session.setSessionPreset(AVCaptureSessionPresetPhoto)

# Set a sentinel for the camera input; this won't be set until the user has
# selected a camera (either explicitly or implicitly)
self.camera_input = None

# Apply the configuration
self.camera_session.commitConfiguration()

# Polling camera devices and starting the camera session is a blocking activity.
# Start a background thread to populate the list of camera devices and start the
# camera session.
Thread(
target=self._enable_camera,
kwargs={"device": device, "flash": flash},
).start()

def _enable_camera(self, device, flash):
self.camera_session.startRunning()

# The GUI can only be modified from inside the GUI thread. Add a background task
# to apply the new device list.
self.camera.interface.app.loop.create_task(
self._update_camera_list(toga.App.app.camera.devices, device, flash)
)

async def _update_camera_list(self, devices, device, flash):
self.device_select.items = devices
if device:
self.device_select.value = device

self._update_flash_mode(flash)

def _update_flash_mode(self, flash=FlashMode.AUTO):
if device := self.device_select.value:
if device.has_flash:
self.flash_mode.items = [FlashMode.AUTO, FlashMode.OFF, FlashMode.ON]
self.flash_mode.value = flash
else:
self.flash_mode.items = [FlashMode.OFF]
else:
self.flash_mode.items = []

def change_camera(self, widget=None, **kwargs):
# Remove the existing camera input (if it exists)
for input in self.camera_session.inputs:
self.camera_session.removeInput(input)

if device := self.device_select.value:
input = cocoa.AVCaptureDeviceInput.deviceInputWithDevice(
device._impl.native, error=None
)
self.camera_session.addInput(input)
self.shutter_button.enabled = True
else:
self.shutter_button.enabled = False

self._update_flash_mode()

def close_window(self, widget, **kwargs):
# Stop the camera session
self.camera_session.stopRunning()

# Set the "no result" result
self.result.set_result(None)

# Clear the reference to the preview window, and allow the window to close
self.camera.preview_windows.remove(self)
return True

def take_photo(self, widget, **kwargs):
settings = cocoa.AVCapturePhotoSettings.photoSettings()
settings.flashMode = native_flash_mode(self.flash_mode.value)

self.camera_session.outputs[0].capturePhotoWithSettings(
settings,
delegate=self.camera_session,
)
self.close()

def photo_taken(self, photo):
# Create the result image.
image = toga.Image(nsdata_to_bytes(photo.fileDataRepresentation()))
self.result.set_result(image)

# Stop the camera session
self.camera_session.stopRunning()

# Clear the reference to the preview window.
self.camera.preview_windows.remove(self)


class Camera:
def __init__(self, interface):
self.interface = interface
self.preview_windows = []

def has_permission(self, allow_unknown=False):
# To reset permissions to "factory" status, run:
# tccutil reset Camera
#
# To reset a single app:
# tccutil reset Camera <bundleID>
#
# e.g.
# tccutil reset Camera org.beeware.appname # for a bundled app
# tccutil reset Camera com.microsoft.VSCode # for code running in Visual Studio
# tccutil reset Camera com.apple.Terminal # for code running in the Apple terminal

if allow_unknown:
valid_values = {
AVAuthorizationStatus.Authorized.value,
AVAuthorizationStatus.NotDetermined.value,
}
else:
valid_values = {AVAuthorizationStatus.Authorized.value}

return (
cocoa.AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo)
in valid_values
)

def request_permission(self, future):
# This block is invoked when the permission is granted; however, permission is
# granted from a different (inaccessible) thread, so it isn't picked up by
# coverage.
def permission_complete(result) -> None: # pragma: no cover
future.set_result(result)

cocoa.AVCaptureDevice.requestAccessForMediaType(
AVMediaTypeVideo,
completionHandler=Block(permission_complete, None, bool),
)

def get_devices(self):
return [
CameraDevice(device)
for device in cocoa.AVCaptureDevice.devicesWithMediaType(AVMediaTypeVideo)
]

def take_photo(self, result, device, flash):
if self.has_permission(allow_unknown=True):
window = TogaCameraWindow(self, device, flash, result)
self.preview_windows.append(window)
window.show()
else:
raise PermissionError("App does not have permission to take photos")
1 change: 1 addition & 0 deletions cocoa/src/toga_cocoa/libs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
)

from .appkit import * # noqa: F401, F403
from .av_foundation import * # noqa: F401, F403
from .core_graphics import * # noqa: F401, F403
from .core_text import * # noqa: F401, F403
from .foundation import * # noqa: F401, F403
Expand Down
Loading