Loupe

[Windows Phone 8] Comment inclure la vidéo de l’appareil photo dans une page

Ce billet va couvrir différentes façons d’utiliser l’appareil photo d’un Windows Phone, que ce soit de la façon la plus simple en utilisant l’application Photo via une Task, ou bien en captant le flux de la caméra afin de l’afficher directement dans une page.

Prendre une photo simplement

De façon classique, sous Windows Phone, lorsque nous voulons intégrer une image ou une photo dans nos applications, nous utilisons la classe PhotoChooserTask de la façon suivante :

PhotoChooserTask t = new PhotoChooserTask();
t.Completed += (sender, result) =>
{
    Stream photoStream = result.ChosenPhoto;
    BitmapImage img = new BitmapImage();
    img.SetSource(photoStream);
    ImageControl.Source = img;
};
t.Show();

Ce code fait son job, il ouvre l’application photo en mettant en suspend la nôtre et lui rend la main lorsque l’utilisateur a pris une photo (ou a utilisé le bouton back).

Afficher le rendu de la caméra dans l’application

Maintenant si l’on veut changer l’expérience utilisateur en intégrant la prise de photo directement à l’intérieur de l’application, la tache se complique. Encore que, si l’on ne veut que visualiser le rendu de la caméra sans y faire de modification profonde, on peut très bien se contenter de quelque chose d’approchant :

<TextBlock>
    <TextBlock.Foreground>
        <VideoBrush x:Name="VideoBrush"/>
    </TextBlock.Foreground>
</TextBlock>
PhotoCamera camera = new PhotoCamera(CameraType.Primary);
VideoBrush.SetSource(camera);

Ensuite, la méthode suivante permettra d’obtenir une photo, en extrayant l’image courante du VideoBrush :

camera.CaptureImage();

Ici, aucune configuration nécessaire, l’image est automatiquement enregistrée dans la résolution la plus grande de l’appareil photo.

Et si on en veut plus ? En effet, on pourrait vouloir appliquer une série d’effets sur notre image avant de l’enregistrer, et de montrer le rendu directement sur le stream de la caméra. Par exemple, on pourrait imaginer que notre application veuille prendre des photos en noir et blanc, tout en permettant à l’utilisateur de se donner un aperçu de ce que donnera son cliché. On pourrait aussi vouloir prendre des photos de moins grandes qualités. Pour cela, il faut aller un peu plus loin que précédemment.

Utiliser PhotoCaptureDevice et MediaStreamSource

Manipuler le rendu de la caméra

Tout d’abords, nous allons rendre notre stream dans un MediaElement. Libre à vous de l’utiliser en tant que source d’un VideoBrush (auquel cas pensez à définir l’opacité du MediaElement à 0) ou de l’afficher tel quel.

Pour ma part, je fais le choix d’afficher directement le MediaElement dans un Canvas (et pas une Grid car nous verrons plus tard que le contenu du Canvas supporte mieux certaines transformations). Je pars donc de la base suivante affichée en full screen sur ma page :

<Canvas x:Name="CameraMediaElementContainer"
    <MediaElement x:Name="CameraMediaElement"
                  Loaded="MediaElement_OnLoaded"
                  BufferingTime="0:0:0"
                  Stretch="UniformToFill" />
</Canvas>

Dans un premier temps, pour effectuer mes tests et mieux comprendre le rendu effectué, j’ai utilisé la valeur None sur la propriété Stretch du MediaElement afin de visualiser le stream sans modification.

Il faut ensuite assigner une source au MediaElement (cela peut être l’uri d’une vidéo, où comme dans notre cas une source personnalisée) :

mediaElement.SetSource(_source);

Ici, l’objet _source est une instance d’une classe CameraStreamSource héritant de MediaStreamSource. C’est dans la classe CameraStreamSource que nous allons récupérer le flux vidéo de la camera du smartphone et le rendre consommable pour le MediaElement, en en profitant pour tripatouiller le flux et appliquer quelques effets si on le veut !

Hériter de la classe MediaStreamSource implique de définir les méthodes suivantes (tout en protected) :

void OpenMediaAsync() 
void SeekAsync(long seekToTime)
void GetSampleAsync(MediaStreamType mediaStreamType)
void SwitchMediaStreamAsync(MediaStreamDescription mediaStreamDescription)
void GetDiagnosticAsync(MediaStreamSourceDiagnosticKind diagnosticKind)
void CloseMedia()

Nous n’avons pas besoin des méthodes GetDiagnosticAsync et SwitchMediaStreamAsync, qui lèveront donc une exception. La classe du Framework qui sera notre point d’entrée vers le flux de la caméra est PhotoCaptureDevice. Nous définirons donc un champ de ce type que nous disposerons dans la méthode CloseMedia (dans la pratique, je n’ai jamais vue cette méthode appelée, même en appelant la méthode Close du MediaElement, je recommande donc de créer une méthode publique permettant de disposer l’objet PhotoCaptureDevice) :

private PhotoCaptureDevice _camera;
protected override void CloseMedia()
{
    _camera.Dispose();
}

Nous aurons besoin des membres suivants :

// Nombre de byte compris dans un pixel sous sa représentation entière
private const int FramePixelSize = 4;
private PhotoCaptureDevice _camera;
// la taille du tableau de byte correspondant à une image du flux vidéo
private int _frameBufferSize;
// le nombre de pixel dans une de nos image (frame)
private int _framePixelsSize;
// les pixels de la frame courante
private int[] _framePixels;
// le tableau dans lequel on transposera les pixels de la frame avant de les afficher
// on pourra aussi y faire des opérations pour y appliquer des filtres
private byte[] _frameBuffer;

// des informations pour le MediaElement, taille de la vidéo, encodage, etc
private MediaStreamDescription _videoStreamDescription;

private readonly Dictionary<MediaSampleAttributeKeys, string> _emptySampleDict =
    new Dictionary<MediaSampleAttributeKeys, string>();

// l'orientation du device afin de gérer l'encodage de la capture
private PageOrientation _orientation;

Nous avons quasiment tout ce qu’il nous faut pour réaliser notre source. Pour commencer nous devrons préparer nos éléments et le flux en implémentant la méthode OpenMediaAsync. Dans cette méthode, nous devons spécifier les résolutions à adopter lors de la prise de photo et lors du rendu du flux vidéo. En général, on choisira une résolution forte dans le premier cas pour avoir un cliché de qualité et une résolution plus faible dans le second afin de ne pas surcharger le processeur et de rendre les éventuelles manipulations (application de filtres et autres) plus rapides.

CameraSensorLocation csl;
if (PhotoCaptureDevice.AvailableSensorLocations.Contains(CameraSensorLocation.Back))
    csl = CameraSensorLocation.Back;
else if (PhotoCaptureDevice.AvailableSensorLocations.Contains(CameraSensorLocation.Front))
    csl = CameraSensorLocation.Front;
else throw new Exception("No detected camera device");

// il faut définir une résolution pour la preview (le flux que l'on va streamer)
// et définir une résolution pour la capture de la photo
// ces deux résolutions doivent avoir le même ratio
// pour optimiser les performances, on va choisir la résolution la plus faible possible pour la préview
// et choisir la plus haute avec le même ratio pour la capture.

var previewResolutions = PhotoCaptureDevice.GetAvailablePreviewResolutions(csl);
var minWidth = previewResolutions.Min(r => r.Width);
var selectedPreviewResolution = previewResolutions
    .Where(r => Math.Abs(r.Width - minWidth) < 0.1)
    .OrderBy(r => r.Height)
    .First();

var ratio = selectedPreviewResolution.Width/selectedPreviewResolution.Height;

var captureResolutions = PhotoCaptureDevice.GetAvailableCaptureResolutions(csl);
var maxWidth = captureResolutions
    .Where(r => Math.Abs(r.Width / r.Height - ratio) < 0.1)
    .Max(r => r.Width);
var selectedCaptureResolution = captureResolutions
    .Where(r => Math.Abs(r.Width / r.Height - ratio) < 0.1)
    .Where(r => Math.Abs(r.Width - maxWidth) < 0.1)
    .OrderBy(r => r.Height)
    .First();

_camera = await PhotoCaptureDevice.OpenAsync(csl, selectedCaptureResolution);
await _camera.SetPreviewResolutionAsync(selectedPreviewResolution);

var mediaSourceAttributes = new Dictionary<MediaSourceAttributesKeys, string>();
var mediaStreamAttributes = new Dictionary<MediaStreamAttributeKeys, string>();
var mediaStreamDescriptions = new List<MediaStreamDescription>();

// Définition des informations de la vidéo
mediaStreamAttributes[MediaStreamAttributeKeys.VideoFourCC] = "RGBA";
mediaStreamAttributes[MediaStreamAttributeKeys.Width] = selectedPreviewResolution.Width.ToString(CultureInfo.InvariantCulture);
mediaStreamAttributes[MediaStreamAttributeKeys.Height] = selectedPreviewResolution.Height.ToString(CultureInfo.InvariantCulture);
// infinie
mediaSourceAttributes[MediaSourceAttributesKeys.Duration] = TimeSpan.FromSeconds(0).Ticks.ToString(CultureInfo.InvariantCulture);
mediaSourceAttributes[MediaSourceAttributesKeys.CanSeek] = false.ToString();

_videoStreamDescription = new MediaStreamDescription(MediaStreamType.Video, mediaStreamAttributes);
mediaStreamDescriptions.Add(_videoStreamDescription);

var frameWidth = (int)selectedPreviewResolution.Width;
var frameHeight = (int)selectedPreviewResolution.Height;
_framePixelsSize = frameWidth * frameHeight;
_frameBufferSize = _framePixelsSize * FramePixelSize;
_frameBuffer = new byte[_frameBufferSize];
_framePixels = new int[_framePixelsSize];

ReportOpenMediaCompleted(mediaSourceAttributes, mediaStreamDescriptions);

La méthode ReportOpenMediaCompleted doit être appelée lorsque le flux vidéo est prêt à être utilisé.

SeekAsync sera appelée par le MediaElement avant chaque demande de nouvelles frames. Le paramètre passé ici permet de mesurer l’avancement (ou le point sur la timeline) dans le média sur lequel se placer pour renvoyer la frame suivante. Dans notre cas, ce n’est pas important car nous ne contrôlons pas cela. Nous implémenterons la méthode comme suit :

protected override void SeekAsync(long seekToTime)
{
    ReportSeekCompleted(seekToTime);
}

L’appel de ReportSeekCompleted permet de signifier que nous sommes prêts à renvoyer la frame suivante.

Enfin, pour renvoyer la dite frame, il convient d’implémenter la méthode GetSampleAsync et d’y appeler ReportGetSampleCompleted avec un MediaStreamSample. Ce dernier sera construit à partir de l’objet de description du stream (celui créé dans la méthode Initialize par exemple) et le stream lui-même.

Afin de produire ce stream, nous devons appeler la méthode GetPreviewBufferArgb qui va remplir un tableau de int, que nous convertirons en tableau de byte (notre buffer) que nous « écrirons » dans le stream.

if (_camera == null) return;
// recopie - 1
_camera.GetPreviewBufferArgb(_framePixels);
// recopie - 2
Buffer.BlockCopy(_framePixels, 0, _frameBuffer, 0, _frameBufferSize);
var msSample = new MediaStreamSample(
    _videoStreamDescription, new MemoryStream(_frameBuffer),
    0, _frameBufferSize, 0, _emptySampleDict);
ReportGetSampleCompleted(msSample);

Il faut garder en tête que cette méthode est appelée un grand nombre de fois par seconde, jusque 60 fois par exemple dans le cas d’un affichage à 60 FPS. Bien entendu, libre à vous d’implémenter un système de bufferisation ou bien de littéralement sauter un certain nombre de frames si cela convient mieux à votre scénario. Néanmoins, dans mon cas, cet exemple est tout à fait satisfaisant d’un point de vue performance, quel que soit le device testé : Lumia 520, 620, 920, 1020, HTC 8X.

Gérer l’orientation du device

Le dernier point à gérer concerne l’orientation du device et ses répercussions sur l’affichage de la preview, mais aussi sur la capture de la photo. Dans les quelques exemples que j’ai pu voir, notamment celui de Nokia, les pages affichant un tel MediaElement sont bloquées en mode paysage. On peut néanmoins autoriser toutes les orientations à conditions de signifier à l’objet PhotoCaptureDevice qu’une rotation a été effectuée. Cela va permettre de prendre une photo dans le « bon sens » afin que l’image finale ne soit pas produite en paysage alors que votre device était en portrait.

public int GetOrientationInDegrees()
{
    if (_camera == null) return 0;
    int encodedOrientation = 0;
    var sensorOrientation = (int)_camera.SensorRotationInDegrees;
    switch (Orientation)
    {
        case PageOrientation.LandscapeLeft:
            encodedOrientation = -90 + sensorOrientation;
            break;
        case PageOrientation.LandscapeRight:
            encodedOrientation = 90 + sensorOrientation;
            break;
        case PageOrientation.PortraitUp:
            encodedOrientation = 0 + sensorOrientation;
            break;
    }
    return encodedOrientation;
} 
private void OnOrientationChanged()
{
    if (_camera == null) return;
    var encodedOrientation = GetOrientationInDegrees();
    _camera.SetProperty(KnownCameraGeneralProperties.EncodeWithOrientation, encodedOrientation);
}

Néanmoins, cela ne règle pas le cas de la preview. De prime abords, je pensais pouvoir modifier des propriétés de notre objet _emptySampleDict, contenant les propriétés des MediaStreamSample générés, ou à defaut de l’objet _videoStreamDescription, sans résultat concluant.

De dépit, j’ai fait le choix de placer mon MediaElement dans un Canvas et de lui appliquer les transformations nécessaires en fonction de l’orientation de la page (redimensionnement et rotation) :

<Canvas x:Name="CameraMediaElementContainer"
        VerticalAlignment="Center"
        HorizontalAlignment="Center"
        RenderTransformOrigin="0.5,0.5">
    <Canvas.RenderTransform>
        <CompositeTransform x:Name="CameraMediaElementTransform" />
    </Canvas.RenderTransform>
    <MediaElement x:Name="CameraMediaElement"
                  Loaded="MediaElement_OnLoaded"
                  BufferingTime="0:0:0"
                  VerticalAlignment="Stretch"
                  HorizontalAlignment="Stretch"
                  Stretch="UniformToFill" />
</Canvas>
private void SetCameraMediaElementRotation()
{
    var pw = Math.Max(ActualWidth, ActualHeight);
    var ph = Math.Min(ActualWidth, ActualHeight);
    CameraMediaElementContainer.Width = pw;
    CameraMediaElementContainer.Height = ph;
    CameraMediaElement.Width = pw;
    CameraMediaElement.Height = ph;
    _source.Orientation = Orientation;
    var orientationInDegree = _source.GetOrientationInDegrees();
    switch (Orientation)
    {
        case PageOrientation.LandscapeLeft:
            CameraMediaElementTransform.Rotation = orientationInDegree;
            CameraMediaElement.SetValue(Canvas.TopProperty, (ph - CameraMediaElement.ActualHeight) / 2);
            CameraMediaElement.SetValue(Canvas.LeftProperty, (pw - CameraMediaElement.ActualWidth) / 2);
            break;
        case PageOrientation.PortraitUp:
            CameraMediaElementTransform.Rotation = orientationInDegree;
            CameraMediaElement.SetValue(Canvas.TopProperty, (ph - CameraMediaElement.ActualHeight) / 2);
            CameraMediaElement.SetValue(Canvas.LeftProperty, (pw - CameraMediaElement.ActualWidth) / 2);
            break;
        case PageOrientation.LandscapeRight:
            CameraMediaElementTransform.Rotation = orientationInDegree;
            CameraMediaElement.SetValue(Canvas.TopProperty, (ph - CameraMediaElement.ActualHeight) / 2);
            CameraMediaElement.SetValue(Canvas.LeftProperty, (pw - CameraMediaElement.ActualWidth) / 2);
            break;
        default:
            throw new ArgumentOutOfRangeException();
    }
}

On ne peut pas dire que ce soit élégant, mais ça à l’avantage de fonctionner, et ce sans effets visibles durant les changements d’orientations.

Appliquer un filtre sur le rendu

Dans la méthode GetSampleAsync, nous obtenons la collection de pixels de notre frame que nous transformons en collection de byte afin de rendre exploitable cette information. Entre ces deux opérations nous pouvons faire des manipulations afin d’appliquer un filtre. Etant donné le fort besoin d’optimisation de cette méthode, je me permets de donner quelques conseils :

  • Eviter un maximum les copies inutiles, ici nous copions déjà 2 fois la frame :
    • Appel de la méthode GetPreviewBufferArgb qui la copie dans le tableau de int
    • Appel de la méthode Buffer.BlockCopy qui recopie le premier tableau dans notre buffer (tableau de byte)
    • La création du MemoryStream ne compte pas, car il n’y a pas de copie en mémoire, il se contente de pointer sur le début du buffer
  • Eviter de déclencher des appels inutiles au garbage collector :
    • Nous réutilisons les mêmes tableaux de int et de byte afin de ne pas définir de nouvelles variables
    • Nous allouons un nouveau MemoryStream à chaque passage car cela permet de pointer directement sur le buffer sans devoir l’écrire explicitement dans le stream, la perte entrainé par l’allocation d’une simple variable de ce type au moment de la libération de la mémoire est négligeable par rapport au gain de performance qu’entraine une copie de moins du buffer (de plus, il semble que ce ne soit pas le MemoryStream en lui-même qui prenne de la place, mais bien le buffer sur lequel il pointe)
    • Nous créons un nouveau MediaStreamSample car la réutilisation de celui-ci ne semble pas fonctionner
  • Il est surement possible d’utiliser un pool de MemoryStream dont on disposera les membres lorsqu’ils ne seront plus utilisés par le MediaElement avant de les remplacer par une nouvelle référence (pas de réécriture)
  • Privilégier le code natif : il est tout à fait possible de réaliser ceci en WinPRT (C++), au tout du moins l’application d’un filtre

Dans un cas idéal, notre filtre serait dont un composant natif prenant en paramètre notre collection de pixels et notre buffer, réalisant la copie de l’un vers l’autre en appliquant en même temps le filtre voulu.

Si nous réalisons cela en C# pour plus de lisibilité, nous allons donc réécrire la méthode Buffer.BlockCopy :

//Buffer.BlockCopy(_framePixels, 0, buffer, 0, _frameBufferSize);
for (var i = 0; i < _framePixelsSize; i++)
{
    var argb = _framePixels[i];
    var index = i * FramePixelSize;

    var b = (argb) & 0xFF;
    var g = (argb >> 8) & 0xFF;
    var r = (argb >> 16) & 0xFF;
    var a = (argb >> 24) & 0xFF;

    // gray filter
    //r = g = b = (r + g + b)/3;

    // schtroumpf filter
    //var tmp = r;
    //r = b;
    //b = tmp;

    // red filter
    //g = b = (r + g + b) / 3;
    //r = Math.Max(b, r);
    //r = Math.Min(r * 2, 255);

    buffer[index + 0] = (byte)b; // blue
    buffer[index + 1] = (byte)g; // green
    buffer[index + 2] = (byte)r; // red
    buffer[index + 3] = (byte)a; // alpha
}

Capturer une photo

Afin d’enregistrer une photo, il suffit d’appeler un code similaire :

// le nombre de capture doit être à 1
CameraCaptureSequence cs = _camera.CreateCaptureSequence(1);
// activation du flash
_camera.SetProperty(KnownCameraPhotoProperties.FlashMode, FlashState.On);
// activation du son 
_camera.SetProperty(KnownCameraGeneralProperties.PlayShutterSoundOnCapture, true);
// sélection du type de focus
_camera.SetProperty(KnownCameraGeneralProperties.AutoFocusRange, AutoFocusRange.Normal);

var captureStream = new MemoryStream();
var frame = cs.Frames[0];
frame.CaptureStream = captureStream.AsOutputStream();
await _camera.PrepareCaptureSequenceAsync(cs);
await cs.StartCaptureAsync();
captureStream.Seek(0, SeekOrigin.Begin);
var library = new MediaLibrary();
using (var library = new MediaLibrary())
{
    return library.SavePictureToCameraRoll("cameralivestream", captureStream);
}

Ici la prise de photo ne tient pas compte du focus à appliquer, ni même du zoom. Bref, vous pouvez jouer sur ces points via des propriétés du PhotoCaptureDevice (avec SetProperty) et de la séquence de capture, mais il faut recréer une interface similaire à l’application photo de base de Windows Phone afin de permettre à l’utilisateur de les régler. Enfin, il faut bien entendu réappliquer les éventuels filtres définies pour la preview.

Pour finir

Ma volonté première en écrivant cet article était de faciliter l’intégration de l’appareil photo au sein d’une application, mais aussi et surtout d’appliquer un filtre sur le flux streamé. Mon prochain article reprendra les bases de celui-ci en utilisant le SDK Nokia Imaging. En effet, ici, nous ne sommes dépendant d’aucune librairie externe, mais le code est managé et n’est certainement pas aussi optimisé qu’il le pourrait. Avec le SDK Nokia Imaging, nous aurons accès à toute une bibliothèque permettant de décoder et manipuler des images. Entre le moment où j’ai commencé cet article et le moment où j’écris ces dernières lignes est sorti la release du SDK. Il offre tout d’abords un objet permettant de récupérer la frame de la preview du PhotoCaptureDevice, dans un format adapté à l’application de filtres. De plus, ce SDK offre une palette de plus de 50 filtres, que ce soit pour cropper, redimensionner, retourner votre image, ou bien pour la passer en noir et blanc, superposer une autre image, etc. Tout cela est réalisé dans un composant WinPRT, et donc en code natif, ce qui assure des performances bien supérieures à ce que nous avons là !

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus