Files
L'électron rare a69d096d15 fix(camera): strict built-in filter both sides
Both the Python AVCapture path and the Swift CameraPreviewLayer
now enforce the same two-step filter :
 1. deviceType == .builtInWideAngleCamera
 2. localizedName must NOT contain 'iPhone', 'GSM', 'Desk View',
    or 'Continuity'

The second clause matters because under some macOS configurations
(Continuity Camera promoted to widescreen, iPhone mirroring),
externally tethered phone cameras can advertise themselves with
the same primary deviceType.

Swift side also logs via NSLog so the picked device shows up in
Console.app even when stdout is buffered.
2026-05-13 12:01:14 +02:00

239 lines
8.0 KiB
Python

"""Capture webcam via AVFoundation natif (pyobjc), sans cv2.
Resout le mismatch d'indices entre `cv2.VideoCapture(N)` et l'ordre
des devices retournes par AVCaptureDeviceDiscoverySession : on
selectionne le device par `uniqueID` ou par type
(BuiltInWideAngleCamera) au lieu d'un index opaque.
Pattern :
- AVCaptureSession + AVCaptureVideoDataOutput
- Delegate avec @objc.python_method pour la copie numpy
- Global dispatch queue (libdispatch via ctypes) — pas de main queue
pour ne pas bloquer NSApp
- Frame BGRA -> BGR HxWx3 uint8 partagee sous lock
- API .read() compatible cv2 : (ok, frame_bgr)
"""
from __future__ import annotations
import ctypes
import logging
import threading
import time
from typing import Optional
import numpy as np
import objc
import AVFoundation as AVF
import CoreMedia as CM
import Quartz
from Foundation import NSObject
LOG = logging.getLogger("av_capture")
_DEVICE_TYPES = [
"AVCaptureDeviceTypeBuiltInWideAngleCamera",
"AVCaptureDeviceTypeContinuityCamera",
"AVCaptureDeviceTypeExternal",
"AVCaptureDeviceTypeDeskViewCamera",
]
def _get_global_queue() -> object:
"""Retourne une dispatch_queue_t globale wrappee en pyobjc object."""
libdispatch = ctypes.CDLL("/usr/lib/system/libdispatch.dylib")
libdispatch.dispatch_get_global_queue.restype = ctypes.c_void_p
libdispatch.dispatch_get_global_queue.argtypes = [
ctypes.c_long, ctypes.c_ulong]
# 0 = DISPATCH_QUEUE_PRIORITY_DEFAULT, 0 = flags
q_ptr = libdispatch.dispatch_get_global_queue(0, 0)
return objc.objc_object(c_void_p=q_ptr)
def enumerate_devices() -> list[dict]:
"""Retourne la liste des devices video disponibles avec metadata
: uniqueID, localizedName, deviceType."""
session = (AVF.AVCaptureDeviceDiscoverySession
.discoverySessionWithDeviceTypes_mediaType_position_(
_DEVICE_TYPES, "vide", 0))
devices = list(session.devices() or [])
out = []
for d in devices:
out.append({
"uniqueID": str(d.uniqueID()),
"name": str(d.localizedName()),
"type": (str(d.deviceType()) if hasattr(d, "deviceType")
else "").split(".")[-1],
"_device": d,
})
return out
_BANNED_NAME_TOKENS = ("iphone", "gsm", "desk view", "continuity")
def find_builtin_device() -> Optional[dict]:
"""Selectionne la webcam Mac integree :
- deviceType doit etre BuiltInWideAngleCamera
- le nom ne doit contenir aucun de _BANNED_NAME_TOKENS
Evite les pieges Continuity/iPhone/Desk View qui peuvent matcher
BuiltInWideAngleCamera dans certaines configs."""
for info in enumerate_devices():
if "BuiltInWideAngleCamera" not in info["type"]:
continue
name_l = info["name"].lower()
if any(tok in name_l for tok in _BANNED_NAME_TOKENS):
continue
return info
return None
class _FrameDelegate(NSObject):
"""Delegate AVCaptureVideoDataOutput. Convertit BGRA -> BGR numpy
et stocke la derniere frame sous lock."""
def init(self):
self = objc.super(_FrameDelegate, self).init()
if self is None:
return None
self._buf = None
self._lock = threading.Lock()
self._frame_count = 0
return self
@objc.python_method
def get_latest(self):
with self._lock:
if self._buf is None:
return False, None, self._frame_count
return True, self._buf.copy(), self._frame_count
def captureOutput_didOutputSampleBuffer_fromConnection_(
self, output, sample_buffer, connection):
try:
self._handle_sample_buffer(sample_buffer)
except Exception as e: # noqa: BLE001
LOG.warning("frame conv failed: %s", e)
@objc.python_method
def _handle_sample_buffer(self, sample_buffer):
pixel_buffer = CM.CMSampleBufferGetImageBuffer(sample_buffer)
if pixel_buffer is None:
return
w = Quartz.CVPixelBufferGetWidth(pixel_buffer)
h = Quartz.CVPixelBufferGetHeight(pixel_buffer)
Quartz.CVPixelBufferLockBaseAddress(pixel_buffer, 1) # read-only
try:
base_addr = Quartz.CVPixelBufferGetBaseAddress(pixel_buffer)
stride = Quartz.CVPixelBufferGetBytesPerRow(pixel_buffer)
# base_addr est un objc.varlist (void* wrapper). On va via
# as_buffer(N) qui renvoie un memoryview de N octets.
if base_addr is None:
return
buf = base_addr.as_buffer(h * stride)
arr = np.frombuffer(buf, dtype=np.uint8).reshape(
(h, stride // 4, 4))[:, :w, :3] # BGRA -> BGR
with self._lock:
self._buf = np.ascontiguousarray(arr)
self._frame_count += 1
finally:
Quartz.CVPixelBufferUnlockBaseAddress(pixel_buffer, 1)
class AVCapture:
"""API minimale cv2-like sur AVFoundation. Compatible drop-in pour
les workers qui font `cap.read()` en boucle.
Usage :
cap = AVCapture(device_info) # ou AVCapture.builtin()
cap.start()
ok, frame_bgr = cap.read()
cap.stop()
"""
def __init__(self, device_info: dict) -> None:
self._info = device_info
self._session = None
self._delegate = None
self._queue = None
self._last_count = 0
@classmethod
def builtin(cls) -> Optional["AVCapture"]:
info = find_builtin_device()
return cls(info) if info is not None else None
def start(self) -> bool:
device = self._info["_device"]
session = AVF.AVCaptureSession.alloc().init()
try:
input_, err = (
AVF.AVCaptureDeviceInput
.deviceInputWithDevice_error_(device, None))
except Exception as e: # noqa: BLE001
LOG.error("AVCaptureDeviceInput failed: %s", e)
return False
if input_ is None:
LOG.error("input is None (err=%s)", err)
return False
if not session.canAddInput_(input_):
LOG.error("session refused input")
return False
session.addInput_(input_)
output = AVF.AVCaptureVideoDataOutput.alloc().init()
settings = {
Quartz.kCVPixelBufferPixelFormatTypeKey:
Quartz.kCVPixelFormatType_32BGRA,
}
output.setVideoSettings_(settings)
output.setAlwaysDiscardsLateVideoFrames_(True)
delegate = _FrameDelegate.alloc().init()
queue = _get_global_queue()
output.setSampleBufferDelegate_queue_(delegate, queue)
if not session.canAddOutput_(output):
LOG.error("session refused output")
return False
session.addOutput_(output)
session.startRunning()
self._session = session
self._delegate = delegate
self._queue = queue
LOG.info("AV session running on '%s' (%s)",
self._info["name"], self._info["type"])
return True
def read(self, timeout_s: float = 0.5) -> tuple[bool, Optional[np.ndarray]]:
"""Bloque jusqu'a recevoir une frame NOUVELLE (frame_count
different du dernier read), ou timeout. Retourne (ok, BGR
HxWx3 uint8)."""
if self._delegate is None:
return False, None
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
ok, frame, count = self._delegate.get_latest()
if ok and count != self._last_count:
self._last_count = count
return True, frame
time.sleep(0.01)
# Au timeout, on renvoie quand meme la derniere frame si elle
# existe (pour les cas FPS < target)
ok, frame, count = self._delegate.get_latest()
if ok:
self._last_count = count
return True, frame
return False, None
def stop(self) -> None:
if self._session is not None:
try:
self._session.stopRunning()
except Exception:
pass
self._session = None
self._delegate = None
self._queue = None