https://github.com/xieguigang/voyager-1

旅行者一号是一艘由NASA在1977年9月5日发射的宇宙飞船,其只比旅行者2号晚16天发射。旅行者一号除了担负着研究我们的太阳系的任务之外,在这艘飞船之上还搭载着一张我们尝试对外界介绍我们的文明的一张名片为“地球之音”的铜质镀金激光唱片,这张金唱片承载着人类与宇宙星系沟通的使命。

旅行者一号金唱片

在这张“地球之声”的金唱片内,存在有以模拟信号方式收录的115幅图像。其后收录了各种声音,适合以每分钟16⅔转的速度播放。当中包括了以55种语言说出的祝福句子。有6,000年前于苏美尔地区说的阿卡德语,还有中国的四种方言。

如果大家对于这张金唱片想要了解更加详细的信息,大家可以进入到NASA的官网学习:https://voyager.jpl.nasa.gov/golden-record/

在这篇文章中,我主要是想要向大家讲解的是如何进行模拟信号处理来从声音信号中解码出图像数据。

大家可以通过我的这个谷歌硬盘的文件分享链接来下载金唱片的原始数据文件:https://drive.google.com/file/d/1ZrVh9o_wdmyNdvxf8dsbtCs_yFNkWG2w/view?usp=sharing

图像信号处理算法原理

在我的这个开源项目之中,对以声音的形式存储于金唱片之中的图像数据的解码方法,基本上是依照着上面的视频中介绍的方法来完成的。就是我们将选中的一片区域内的声波的信号值取出来:按照声音文件的频率,按照竖行进行扫描信号的取出。最后将信号的高低映射为灰度数据就可以渲染出一幅黑白图片了。

为了可以取出对应的数据块之中的信号数据,我定义了一个图像数据块对象,大家可以阅读ImageChunk.vb这个源代码文件:

Voyager_golden_record_13_earth
Public Class ImageChunk

    Public Property channel As ChannelPositions
    Public Property start As Integer
    Public Property length As Integer
    ''' <summary>
    ''' it is always [364,540] pixels?
    ''' </summary>
    ''' <returns></returns>
    Public Property size As Size

    ''' <summary>
    ''' get wave data on left channel or right channel 
    ''' </summary>
    ''' <param name="samples"></param>
    ''' <returns></returns>
    Public Function GetSampleData(samples As IEnumerable(Of Sample)) As Single()
        If channel = ChannelPositions.None OrElse channel = ChannelPositions.Left Then
            Return samples.Select(Function(d) d.left).ToArray
        Else
            Return samples.Select(Function(d) d.right).ToArray
        End If
    End Function
End Class

因为在原始的声音文件是包含有左右两个声道的,所以我们首先在上面定义了一个声道属性。紧接着,我们通过一个start起始位置和一个length长度数据就可以定义出文件中的一个数据块了。利用上面所定义的数据块对象,我们可以从wav文件之中取出对应位置的声音信号数据用于图像解码:

Dim samples As Sample() = wav.data _
    .LoadSamples(chunk.start, chunk.length, scan0:=8) _
    .ToArray
Dim data As Single() = chunk.GetSampleData(samples).PreProcessing

基于上面的数据块,得到的data数组,我们下一步就是需要以竖列扫描的方式,从原始数据中取出信号扫描信息数据:

Dim align As Integer
Dim index As Integer = 0
Dim ncols As Integer = Math.Floor(data.Length / args.windowSize)
Dim buffer As Single()
Dim start, ends As Integer
Dim startOffset As Integer = args.windowSize * offsetLeft
Dim endOffset As Integer = (args.windowSize - args.windowSize * offsetRight)

For j As Integer = 0 To ncols - 1
    buffer = New Single(args.windowSize - 1) {}

    Call Array.ConstrainedCopy(data, index, buffer, Scan0, args.windowSize)

    start = Which.Max(buffer.Take(startOffset))
    ends = endOffset + Which.Min(buffer.Skip(endOffset))

    ' trim buffer
    buffer = buffer.Skip(start).Take(ends - start).ToArray
    align = Math.Floor((ends - start) / khzRate)
    index += ends
    aligns += align

    Yield buffer.pixels(align, khzRate)
Next

<Extension>
Private Function pixels(data As Single(), align As Integer, khzRate As Integer) As Single()
    Dim index As i32 = Scan0
    Dim sum As Single() = New Single(khzRate - 1) {}

    For j As Integer = 0 To sum.Length - 1
        For i As Integer = 0 To align - 1
            sum(j) += data(++index)
        Next
    Next

    Return sum
End Function

上面的代码中将声音模拟信号转换为像素信息的工作方式就是类似于调制信号解调器的工作原理。对于将每一个信号扫描如何从原始数据中取出来,如果我们仔细观察波形的话,会发现每一个信号扫描数据总是会出现在两个信号峰之间的。所以我们可以基于这两个信号峰,对每一个信号扫描数据做出正确的定位。完成了信号扫描数据从原始数据中的截取操作之后。我们就可以基于调制解调的方法进行模拟信号的解码操作过程了。

如果我们将选取的信号区域以波形的形式可视化绘制出来,可以发现,在图像的每一个竖列的信号扫描都会存在这一个高信号和低信号的区间。上面的图就是我们需要首先解码的第一个圆的图片。因为这个圆比较简单吗,所以我们可以直接从波形中就可以看出一个被压扁的圆在声音信号里面。而我们只需要按照波峰波谷矫正数据块,取出信号扫描,就可以取出这个圆的每一列像素的模拟信号数据了。我们将高信号水平的数据映射为白色,低信号水平的数据映射为黑色,就可以将每一列信号扫描数据解码为灰度数据,得到一张黑白的灰度图了。

Dim x As Integer = 0
Dim y As i32 = Scan0
Dim c As Color
Dim alignIndex As i32 = Scan0
Dim globalMax As Single = Aggregate col As Single()
                          In scans
                          Let colMax As Single = col.Max
                          Into Max(colMax)
Dim globalMin As Single = Aggregate col As Single()
                          In scans
                          Let colMin As Single = col.Min
                          Into Min(colMin)
Dim globalRange As New DoubleRange(globalMin, globalMax)
Dim alphaRange As DoubleRange = {0, 255}
Dim grayAlpha As Integer

Using img As BitmapBuffer = BitmapBuffer.FromBitmap(New Bitmap(width, khzRate, PixelFormat.Format32bppArgb))
    For Each columnScan As Single() In scans
        For i As Integer = 0 To columnScan.Length - 1
            grayAlpha = globalRange.ScaleMapping(columnScan(i), alphaRange)
            grayAlpha = 255 - grayAlpha

            c = Color.FromArgb(grayAlpha, 0, 0, 0)

            If y > img.Height - 1 Then
                y = 0
                x += 1
            End If

            If x > img.Width - 1 Then
                x = 0
            End If

            ' the data is a column scan
            Call img.SetPixel(x, ++y, c)
        Next
    Next

    Return img.GetImage
End Using

最后,将我们得到的列扫描数据,按照信号水平的高低映射为灰度数据就好了。整个映射的计算过程非常简单,将信号水平渲染为图像的过程也非常的简单。上面的图像解码过程的源代码,大家可以阅读ImageDecoder.vb这个源代码文件之中的函数代码。

还没有经过参数优化,最开始解码出来的圆,大概是长这样子的:

通过R#脚本解码金唱片

imports "goldenRecord" from "voyager";

我将上面的解码算法构建成为了一个名称为goldenRecord的R#脚本编程的程序包模块,以方便大家通过这个算法程序进行金唱片的解码操作。在这个程序包内仅包含有三个函数:

  • decode 函数就是用来接收一个wav文件对象,然后从指定的位置开始读取一段数据然后解码为像素数据
  • as.bitmap 函数就是将像素数据解码为最终的图像数据
  • chunk_size 是一个调试测试算法用的函数,大家可以不用关心这个函数

将声音解码为图像数据,在R#脚本之中,只需要非常简单的传递对应的参数就可以了。例如,我们可以通过下面的简单的R#脚本代码进行图像的解码操作:

bitmap(file = "/path/to/output.png") {
    wav
    |> decode(
        chunk       = chunkPos,
        decode      = decoder,
        offsetLeft  = 0.15,
        offsetRight = 0.1
    )
    |> as.bitmap()
    ;
}

从上面的示例代码,我们可以看到,解码一张图片出来,在R#脚本之中大致就是三个简单的步骤:

  • 选取好声音文件之中的数据块的位置
  • 进行wav声音文件的decode操作
  • 将数据块中的声音数据解码渲染为图像数据

解码出第一个圆

在金唱片的解码工作之中,第一张图是一个圆。这个圆的地位在我们的解码工作中起着非常的重要参数校验的作用:因为我们只有将这个圆正确的解码出来之后,这就表示着我们的图像解码算法和参数是正确的。那现在我们开始进行第一个圆的解码操作吧。

在下面,我贴出来了解码第一个圆的图像数据的完整的R#脚本代码,大家可以在Github上查看这个R#脚本文件

require(voyager1);

imports "goldenRecord" from "voyager";
imports "wav" from "signalKit";

const goldenRecord as string = "J:\GoogleDrive\Voyager\384kHzStereo.wav";

# A demo R# script for image decode from the goden record wave data
# this very first circle image on the goden record is used for 
# parameter calibration of the image decoder
using wav as read.wav(file = file(goldenRecord), lazy = TRUE) {
    # view of the raw file data summary;
    print(wav);

    # parameters of the first circle image
    # and wav decoder arguments
    const first_circle = new image.chunk(
        channel = "Left",
        start   = 6000000,
        length  = 1800000
    );
    const decoder = new decode(windowSize = 3400, offset = 384);

    print(first_circle);
    print("data size of this image chunk:");
    print(wav :> chunk_size(chunk = first_circle));

    # run decoder and save the
    # result image file
    bitmap(file = `${dirname(!script$dir)}/docs/circle.png`) {
        wav 
        |> decode(
            chunk       = first_circle,
            decode      = decoder,
            offsetLeft  = 0.15,
            offsetRight = 0.1
        )
        |> as.bitmap()
        ;
    }
}

  • 在这里,我们直接使用R#脚本之中的信号处理工具箱之中的wav文件模块进行金唱片声音文件的读取操作。读取wav文件非常的简单,只需要调用read.wav函数读取文件就好了。因为这个金唱片文件比较大,所以我们将lazy参数设为TRUE,这样子read.wav函数就会仅仅打开一个资源文件,而非将文件数据一次性的全部读入我们的计算机内存之中了。
  • 接着我们就可以构建出解码图像所需要的image.chunk数据块信息,以及decode参数信息。
  • 最后我们通过前面介绍的decode函数和as.bitmap函数就可以完整我们所设定的数据块的图像解码操作了。

我们通过R#解释器执行一下上面的脚本,输出的第一个圆的图像的效果还很不错。因为我们是从模拟信号中解析出来的图像,这个解析过程有点像旧的黑白电视机的调制解调器的工作过程。

因为从模拟信号中解码图像难免会有噪声和失真,所以我们解码出来的图像风格非常有一种我们小时候使用的旧的大脑袋电视机上的图像风格。

解码其他的图片

我自己尝试着测试了一些文件中的数据位置,得到了一些图片的大概位置。我将这些位置整理成了一个列表,如果大家有兴趣自己编写代码进行图像解码的话,放在这篇文章中供大家参考:

const data_chunks = list(
    "Calibration_circle"        = 6000208,
    "Solar_location_map"        = 8465560,
    "Mathematical_definitions"  = 10686671,
    "Physical_unit_definitions" = 13001000
);

首先就是第一张非常重要的用来校验我们的解码参数的圆,在文件大概6000208字节的位置

第二张图就是我们的太阳系的位置示意图,在文件大概8465560字节的位置

第三张图就是一些人类社会文明中基础的数学概念的定义,例如向外星人教授数字代表什么,数字间的四则运算法则等概念

第四张图就是一些基础的物理单位,例如年月日这些时间单位,克,千克这些质量单位

在金唱片里面的图片的位置我没有找完。如果大家有兴趣,可以自己尝试通过我的这个voyager-1开源项目,来亲自手动的将剩下的所有的图片都查找出来。

Latest posts by xie guigang (see all)

Attachments

No responses yet

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注