UI 前沿技术

Windows Phone 7 上的视频源

Charles Petzold

下载代码示例

Charles Petzold
现代智能手机带有电子传感器元件,手机可以通过这些传感器获得有关外部世界的信息。其中包括手机广播本身、Wi-Fi、GPS、触摸屏、运动检测器等。

对于应用程序编程人员来说,这些传感器元件只有在关联 API 时才有用。如果缺少 API,硬件功能的价值将大大降低甚至毫无价值。

从应用程序编程人员的角度来看,Windows Phone 初始发行版中缺少的某项功能很吸引人。虽然 Windows Phone 中一直都有照相机,但初始发行版中唯一可用的 API 只有 Camera­CaptureTask。基本上,此类会生成一个子过程,让用户可以拍照然后将该图片返回到应用程序。就是这样。应用程序既不能控制此过程的任何部分,也不能获取通过镜头的实时视频源。

这一缺陷现在已通过两组编程接口更正。

一组 API 涉及 Camera 和 PhotoCamera 类。这两个类允许应用程序组合整个拍照 UI,包括闪光选项、实时预览视频源、快门按键和半按键以及焦点检测。我希望在未来的专栏中讨论此接口。

本专栏中将讨论的 API 继承自 Silverlight 4 网络摄像机接口。应用程序通过这些 API 从手机的照相机和麦克风获得实时视频和音频源。这些源可以提供给用户和保存到文件,而在这里更有趣的是,以某种方式进行处理或解释。

设备和来源

Windows Phone 7 的 Silverlight 4 网络摄像机接口稍有增强,包含大约十几个在 System.Windows.Media 命名空间中定义的类。您将总是从 CaptureDeviceConfiguration 静态类开始。如果手机支持多个照相机或麦克风,可以通过 Get­AvailableVideoCaptureDevices 和 GetAvailableAudioCapture­Devices 方法使用它们。您可能需要以列表形式将它们呈现给用户进行选择。您也可以仅仅调用 GetDefaultVideoCaptureDevice 和 GetDefaultAudioCaptureDevice 方法。

本文提到的这些方法可能会返回 null,这很可能表示手机不包含照相机。这不太可能,但至少是检查 null 的一种好方法。

这些 CaptureDeviceConfiguration 方法返回 VideoCaptureDevice 和 AudioCaptureDevice 的实例或这两个类的实例集合。这两个类提供设备的友好名称、SupportedFormats 集合和 DesiredFormat 属性。对于视频,格式涉及每帧视频的像素尺寸、颜色格式和每秒帧数。对于音频,格式会指定信道数、每样本的位数和波形格式(始终为脉冲编码调制 (PCM))。

Silverlight 4 应用程序必须调用 CaptureDevice­Configuration.RequestDeviceAccess 方法才能获取用户权限以访问网络摄像机。此调用必须是对用户输入(如按钮单击)的响应。但如果 CaptureDevice­Configur­­ation.AllowedDeviceAccess 属性为 true,则表示已将此访问权限授予用户,程序不需要再次调用 RequestDeviceAccess。

很显然,RequestDeviceAccess 方法旨在保护用户的隐私。但在这一点上,基于 Web 的 Silverlight 和 Silverlight for Windows Phone 7 似乎稍有不同。一想到网站偷偷访问您的网络摄像机就令人毛骨悚然,但手机程序很少出现这种情况。我的经验是,对于 Windows Phone 应用程序,AllowedDeviceAccess 始终返回 true。尽管如此,我还是在本专栏描述的所有程序中定义了一个 UI 来调用 RequestDeviceAccess。

应用程序还必须创建一个 CaptureSource 对象,用于将视频设备和音频设备组合到单个实时视频和音频流。CaptureSource 具有两个名为 VideoCaptureDevice 和 AudioCaptureDevice 的属性,您分别设置为从 CaptureDeviceConfiguration 获取的 VideoCaptureDevice 和 AudioCaptureDevice 实例。如果您只对视频或音频中的一项感兴趣,则无需同时设置这两个属性。在本专栏的示例程序中,我完全集中在视频上。

创建 CaptureSource 对象后,可以调用该对象的 Start 和 Stop 方法。在专用于获取视频或音频源的程序中,您很可能需要在 OnNavigated­To 重写中调用 Start,并在 OnNavigatedFrom 重写中调用 Stop。

另外,您也可以使用 CaptureSource 的 CaptureImageAsync 方法来获取 WriteableBitmap 对象形式的各个视频帧。我将不演示该功能。

一旦您有了 CaptureSource 对象,则有两个可行方向: 可以创建 VideoBrush 来显示实时视频源,也可以将 CaptureSource 连接到“sink”对象以获得对原始数据的访问权或者保存到独立存储的文件中。

VideoBrush

最简单的 CaptureSource 选项绝对是 VideoBrush。Silverlight 3 引入了具有 MediaElement 源的 VideoBrush,而 Silverlight 4 则为 VideoBrush 添加了 CaptureSource 替代项。与任何画笔一样,您可以使用它来绘制元素的背景色或前景色。

本专栏的可下载代码是一个名为 StraightVideo 的程序,该程序使用 VideoCaptureDevice、CaptureSource 和 VideoBrush 来显示通过默认照相机镜头的实时视频源。图 1 显示了 MainPage.xaml 文件的大量内容。请注意 Landscape 模式的用法(将用于视频源)、Grid 内容的 Background 属性上的 VideoBrush 定义以及用于获取用户权限以访问照相机的 Button。

图 1:StraightVideo 中的 MainPage.xaml 文件

<phone:PhoneApplicationPage
  x:Class="StraightVideo.MainPage"
  xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:phone="clr-namespace:Microsoft.Phone.Controls;assembly=Microsoft.Phone"
  xmlns:shell="clr-namespace:Microsoft.Phone.Shell;assembly=Microsoft.Phone"
  ...
SupportedOrientations="Landscape" Orientation="LandscapeLeft"
  shell:SystemTray.IsVisible="True">
  <Grid x:Name="LayoutRoot" Background="Transparent">
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="*"/>
    </Grid.RowDefinitions>
    <StackPanel x:Name="TitlePanel" Grid.Row="0" Margin="12,17,0,28">
      <TextBlock x:Name="ApplicationTitle" Text="STRAIGHT VIDEO"
        Style="{StaticResource PhoneTextNormalStyle}"/>
    </StackPanel>
    <Grid x:Name="ContentPanel" Grid.Row="1" Margin="12,0,12,0">
      <Grid.Background>
        <VideoBrush x:Name="videoBrush" />
      </Grid.Background>
      <Button Name="startButton"
        Content="start"
        HorizontalAlignment="Center"
        VerticalAlignment="Center"
        Click="OnStartButtonClick" />
    </Grid>
  </Grid>
</phone:PhoneApplicationPage>

图 2 显示了代码隐藏文件的大部分内容。 CaptureSource 对象是在页面构造函数中创建的,但在导航重写中启动和停止。 我还发现,需要对 OnNavigatedTo 中的 VideoBrush 调用 SetSource,否则在前一个 Stop 调用之后图像会丢失。

图 2:StraightVideo 中的 MainPage.xaml.cs 文件

public partial class MainPage : PhoneApplicationPage
{
  CaptureSource captureSource;
  public MainPage()
  {
    InitializeComponent();
    captureSource = new CaptureSource
    {
      VideoCaptureDevice =
        CaptureDeviceConfiguration.GetDefaultVideoCaptureDevice()
    };
  }
  protected override void OnNavigatedTo(NavigationEventArgs args)
  {
    if (captureSource !=
      null && CaptureDeviceConfiguration.AllowedDeviceAccess)
    {
      videoBrush.SetSource(captureSource);
      captureSource.Start();
      startButton.Visibility = Visibility.Collapsed;
    }
    base.OnNavigatedTo(args);
  }
  protected override void OnNavigatedFrom(NavigationEventArgs args)
  {
    if (captureSource != null && captureSource.State == CaptureState.Started)
    {
      captureSource.Stop();
      startButton.Visibility = Visibility.Visible;
    }
    base.OnNavigatedFrom(args);
  }
  void OnStartButtonClick(object sender, RoutedEventArgs args)
  {
    if (captureSource != null &&
        (CaptureDeviceConfiguration.AllowedDeviceAccess ||
        CaptureDeviceConfiguration.RequestDeviceAccess())
    {
      videoBrush.SetSource(captureSource);
      captureSource.Start();
      startButton.Visibility = Visibility.Collapsed;
    }
  }
}

可以在 Windows Phone 仿真程序上运行此程序,不过在真实设备上运行要有趣得多。 您将会注意到,视频源的呈现速度非常快。 视频源显然是直接转到视频硬件中。 (支持这种推测的更多证据是,原来通过将 PhoneApplicationFrame 对象呈现到 WriteableBitmap 以从手机中获取屏幕快照的方法对此程序不起作用。) 您还会注意到,由于视频是通过画笔呈现的,因此画笔将拉伸为 Grid 的内容尺寸并且图像会扭曲。

画笔的一个良好特征是可以在多个元素之间进行共享。 这就是 FlipXYVideo 所包含的原理。 此程序可动态地在 Grid 中创建一些平铺的 Rectangle 对象。 对每个对象使用同一个 VideoBrush,但每隔一个 Rectangle 会进行一次垂直和/或水平翻转,如图 3 所示。 可以使用 ApplicationBar 按钮增加或减少行数和列数。

图 3:在 FlipXYVideo 中共享 VideoBrush 对象

void CreateRowsAndColumns()
{
  videoPanel.Children.Clear();
  videoPanel.RowDefinitions.Clear();
  videoPanel.ColumnDefinitions.Clear();
  for (int row = 0; row < numRowsCols; row++)
    videoPanel.RowDefinitions.Add(new RowDefinition
    {
      Height = new GridLength(1, GridUnitType.Star)
    });
  for (int col = 0; col < numRowsCols; col++)
    videoPanel.ColumnDefinitions.Add(new ColumnDefinition
  {
    Width = new GridLength(1, GridUnitType.Star)
  });
  for (int row = 0; row < numRowsCols; row++)
    for (int col = 0; col < numRowsCols; col++)
    {
      Rectangle rect = new Rectangle
      {
        Fill = videoBrush,
        RenderTransformOrigin = new Point(0.5, 0.5),
        RenderTransform = new ScaleTransform
        {
          ScaleX = 1 - 2 * (col % 2),
          ScaleY = 1 - 2 * (row % 2),
        },
      };
      Grid.SetRow(rect, row);
      Grid.SetColumn(rect, col);
      videoPanel.Children.Add(rect);
    }
  fewerButton.IsEnabled = numRowsCols > 1;
}

此程序非常好玩,但不如 kaleidoscope 程序好玩,我将简短讨论一下后一个程序。

来源和 Sink

可以将 CaptureSource 对象连接到 AudioSink、VideoSink 或 FileSink 对象来代替使用 VideoBrush。 这些类名称中“sink”一词的用法表示“容器”,类似于该词在电子学或网络理论中的用法。 (或者,可以认为是“热源”和“散热器”。)

FileSink 类是用于将视频或音频流保存到应用程序的独立存储(无需您进行任何参与)的首选方法。 如果您需要实时访问实际的视频或音频位,将会使用 VideoSink 和 AudioSink。 这两个类为抽象类。 从这两个抽象类中的一个或两个派生一个类,并重写 OnCaptureStarted、OnCaptureStopped、OnFormatChange 和 OnSample 方法。

从 VideoSink 或 AudioSink 派生的类将始终在首次调用 OnSample 之前先调用 OnFormatChange。 随 OnFormatChange 提供的信息指示示例数据的解释方式。 对于 VideoSink 和 AudioSink,OnSample 调用会提供计时信息和字节数组。 对于 AudioSink,这些字节表示 PCM 数据。 对于 VideoSink,这些字节是每帧视频的像素行和列。 此数据始终为原始数据且未压缩。

辅助执行线程中将同时调用 OnFormatChange 和 OnSample,因此您需要对这两个方法内必须在 UI 线程中执行的任务使用 Dispatcher 对象。

StraightVideoSink 程序类似于 StraightVideo,只是视频数据将经过从 VideoSink 派生的类。 此派生类(如图 4 所示)名为 SimpleVideoSink,因为它仅采用 OnSample 字节数组并将其传送到 WriteableBitmap。

图 4:在 StraightVideoSink 中使用的 SimpleVideoSink 类

public class SimpleVideoSink : VideoSink
{
  VideoFormat videoFormat;
  WriteableBitmap writeableBitmap;
  Action<WriteableBitmap> action;
  public SimpleVideoSink(Action<WriteableBitmap> action)
  {
    this.action = action;
  }
  protected override void OnCaptureStarted() { }
  protected override void OnCaptureStopped() { }
  protected override void OnFormatChange(VideoFormat videoFormat)
  {
    this.videoFormat = videoFormat;
    System.Windows.Deployment.Current.Dispatcher.BeginInvoke(() =>
    {
      writeableBitmap = new WriteableBitmap(videoFormat.PixelWidth,
        videoFormat.PixelHeight);
      action(writeableBitmap);
    });
  }
  protected override void OnSample(long sampleTimeInHundredNanoseconds,
    long frameDurationInHundredNanoseconds, byte[] sampleData)
  {
    if (writeableBitmap == null)
      return;
    int baseIndex = 0;
    for (int row = 0; row < writeableBitmap.PixelHeight; row++)
    {
      for (int col = 0; col < writeableBitmap.PixelWidth; col++)
      {
        int pixel = 0;
        if (videoFormat.PixelFormat == PixelFormatType.Format8bppGrayscale)
        {
          byte grayShade = sampleData[baseIndex + col];
          pixel = (int)grayShade | (grayShade << 8) |
            (grayShade << 16) | (0xFF << 24);
        }
        else
        {
          int index = baseIndex + 4 * col;
          pixel = (int)sampleData[index + 0] | (sampleData[index + 1] << 8) |
            (sampleData[index + 2] << 16) | (sampleData[index + 3] << 24);
        }
        writeableBitmap.Pixels[row * writeableBitmap.PixelWidth + col] = pixel;
      }
      baseIndex += videoFormat.Stride;
    }
    writeableBitmap.Dispatcher.BeginInvoke(() =>
      {
        writeableBitmap.Invalidate();
      });
  }
}

MainPage 结合使用该 WriteableBitmap 和一个 Image 元素来显示生成的视频源。 (或者,它可以创建一个 ImageBrush 并将其设置为元素的背景或前景。)

现在问题出现了: 在 VideoSink 派生项中调用 OnFormatChange 方法之前,无法创建该 WriteableBitmap,因为该调用会指示视频帧的大小。 (我的手机通常采用 640x480 像素,但也可能采用其他像素。) 虽然 VideoSink 派生项会创建 WriteableBitmap,但是 MainPage 仍需要访问它。 这就是我为 SimpleVideoSink 定义构造函数的原因,该构造函数包含创建 WriteableBitmap 时要调用的 Action 参数。

请注意,必须在程序的 UI 线程中创建 WriteableBitmap,因此 SimpleVideoSink 使用 Dispatcher 对象排队等候创建 UI 线程。 这意味着在首次 OnSample 调用之前可能不会创建 WriteableBitmap。 请注意这一点! 尽管 OnSample 方法可以在辅助线程中访问 WriteableBitmap 的 Pixels 数组,但是必须在 UI 线程中对位图调用 Invalidate,因为该调用最终会通过 Image 元素影响位图的显示。

StraightVideoSink 的 MainPage 类包含一个 Application­Bar 按钮,用于在彩色和灰色阴影的视频源之间进行切换。 只有彩色和灰色阴影这两个选项,您可以通过设置 VideoCaptureDevice 对象的 DesiredFormat 属性切换为其中一个选项。 彩色源每像素有 4 个字节,采用绿、蓝、红和 Alpha(始终为 255)的顺序。 灰色阴影源每像素只有 1 个字节。 在任一情况下,WriteableBitmap 每像素始终有 4 个字节,其中每一像素由 32 位整数表示,最高的 8 位表示 Alpha 通道,后跟红、绿和蓝。 切换格式时必须停止并重新启动 CaptureSource 对象。

尽管 StraightVideo 和 StraightVideoSink 都显示实时视频源,但是您可能会注意到 StraightVideoSink 要迟缓得多,因为该程序执行的工作是将视频帧传送到 WriteableBitmap。

制作万花筒

如果您仅需要偶尔捕获帧的实时视频源,可以使用 Capture­Source 的 CaptureImageAsync 方法。 由于性能开销,您很可能会将 VideoSink 的使用限制到涉及像素位操作的更专业的应用程序。

让我们来编写这样一个“专业”程序,用于将视频源排列为万花筒模式。 这从概念上讲非常简单: VideoSink 派生项获取一个视频源,其中每一帧的大小可能为 640x480 像素,也可能为其他像素。 您需要从该帧中引用等边三角形图像数据,如图 5 所示。

我决定使用一个底边在顶部、顶点在底部的三角形以便更好地捕获面部。

The Source Triangle for a Kaleidoscope
图 5:万花筒的源三角形

然后,在 WriteableBitmap 上多次复制该三角形中的图像并进行一些旋转和翻转,以便这些图像平铺开并组成六边形而无任何间断,如图 6 所示。 我知道六边形看起来像美丽的花朵,但它们实际上只是我面部的许多图像(也许我面部的图像太多了)。

The Destination Bitmap for a Kaleidoscope
图 6:万花筒的目标位图

分隔各个三角形后,重复模式变得更显而易见,如图 7 所示。 目标 WriteableBitmap 在手机上呈现时,其高度将与手机的较小尺寸相同,即 480 像素。 因此,每个等边三角形的每一边均为 120 像素。 这意味着三角形的高度为 120 乘以 0.75 的平方根,即约为 104 像素。 在该程序中,我在数学上使用了 104,但在调整位图大小时使用了 105 以使循环更简单。 生成的整个图像为 630 像素宽。

The Destination Bitmap Showing the Source Triangles
图 7:显示源三角形的目标位图

我发现最方便的是将整个图像视为三个 210 像素宽的相同垂直带。 每个垂直带都围绕垂直中点反射对称,因此我将该图像减小为单个 105x480 像素的位图,重复了六次,一半有反射。 该垂直带只包含七个完整三角形和两个部分三角形。

尽管如此,我还是很紧张于组合该图像所需的计算。 然后,我意识到不必按照每秒 30 次的视频刷新速率执行这些计算。 当视频图像的大小在 OnFormatChange 重写中变得可用时,只需要执行一次这些计算。

生成的程序称为 Kaleidovideo。 (传统主义者会认为该名称是词源混用,因为“kaleido”来自于希腊词根,意思为“美丽形态”,而“video”则具有拉丁词根,当形成新词时,不应混合二者。)

KaleidoscopeVideoSink 类会重写 VideoSink。 OnFormatChange 方法负责计算名为 indexMap 的数组的成员。 该数组具有的成员数与 WriteableBitmap 中的像素数一样多(105 * 480,即 50,400),并将索引存储到视频图像的矩形区域。 通过使用该 indexMap 数组,将像素从视频图像传送到 OnSample 方法中的 WriteableBitmap 时,速度将快得不可思议。

该程序乐趣无穷。 世界上的一切事物在 Kaleidovideo 中看起来都要漂亮许多。 例如,图 8 显示了我的一个书架。 您甚至可以通过它看电视。

A Typical Kaleidovideo Screen
图 8:典型的 Kaleidovideo 屏幕

只是以防您在屏幕上看到想要保存的内容,我将一个“capture”按钮添加到了 ApplicationBar 中。 您将会在照片库的“已保存图片”文件夹中找到相应图像。

Charles Petzold 是《MSDN 杂志》的长期特约编辑。 他的新书《Programming Windows Phone 7》(Microsoft Press,2010 年)可从 bit.ly/cpebookpdf 免费下载获得。

衷心感谢以下技术专家对本文的审阅: Mark HopkinsMatt Stroshane