Pythonでバイナリデータを扱ってみる

バイナリデータをPythonで処理する機会があったのですが、初めてバイナリデータを触ったこともあり色々調べないとわかないこともあったので、備忘録として残しておきたいと思います！

今回は「**.sl2**」という拡張子ファイルのデータを扱ったので、.sl2データを例に書いていきます!

# バイナリデータのことについて
バイナリデータは各フォーマットによってデータの構成が決まっています。
今回、.sl2という初めて見るファイルを扱うことになったのですが、その場合はまず「.sl2ファイルの構成」を何らかの方法で知る必要があります。これがわからないと、処理できません！

私の場合は以下のページを参考にしましたので、この参考ページをもとに説明していきます。
（**結果的にはこのページは間違っていましたが...**）
https://wiki.openstreetmap.org/wiki/SL2

## ヘッダー
ほとんどのバイナリデータにはヘッダーというものが付いているようです。
これは初めの数バイトに固定の値が入っているもので、バージョン情報などデータフォーマットについての説明が入っています。

今回であれば参考ページの「**Basic Structure**」の欄に以下の表と説明が記載されています。
.sl2ファイルの中にもいくつか種類があるようですが、とりあえずヘッダーは10バイトと考えていいようです。

> The files show up with a **10 byte header**. First 2 bytes describe the format version (01=SLG, 02=SL2).
>Bytes 5,4 provide the block size. It varies depending on the sensor: 0x07b2 for HDI=Primary/Secondary+DSI and 0x0c80 for Sidescan). Seen values are
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/160512/dc740678-a744-2055-91c5-b14ee1dd843a.png)

## バイトオーダー
非常に大まかに書くとデータの「並べ方」や「並び順」のことで、データをメモリに書き込む際にどういう順番で格納するか、を定めたものです。
※詳しい内容は各種解説サイトをご参照ください！
[バイトオーダー：Endian(エンディアン)ってなに？](https://www.uquest.co.jp/embedded/learning/lecture05.html)

調べる限り、バイトオーダーはほとんどの場合「ビッグエンディアン」か「リトルエンディアン」に属するので、どちらなのかを調べる必要があります。

今回の場合、参考ページの「**Basic Structure**」欄に以下の記載があるため、リトルエンディアンであるとわかりました。
>The file is a binary file **with little endian format.** Float values are stored as IEEE 754 floating-point "single format".

## バイトブロック
いよいよヘッダー以降のデータを読んでいきます。
参考ページでは以下表（一部抜粋）のように、各ブロックのデータ型や長さが定義されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/160512/ac8db828-57c2-aecf-be79-3e06c9c63a09.png)

ここでまず､最右列のデータ説明欄を見て､抽出したいデータを選択します｡
抽出するデータが決まったら､データ型(variable type)とオフセットの値を確認します｡

オフセットとは基準点からの相対的な位置の情報で､データの住所を表しています｡
今回のデータはひと組が144バイトあるので､その中で何バイト目にそのデータが書き込まれているかを表しているということです｡
>　This should close **the 144 byte frame.**


# Pythonのstructモジュール
上でバイナリデータについて色々整理しました｡
このバイナリデータを扱うために**struct**というモジュールを使用します｡
[公式ドキュメント](https://docs.python.org/ja/3/library/struct.html)

## バイトオーダーについて

公式ドキュメントに以下の表があり、バイトオーダーを表す文字が決まっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/160512/4b2efc12-9a35-997a-575d-cd8a03e47995.png)

## データフォーマットについて
[バイトブロック](#バイトブロック)の章で確認したように、各データにはそれぞれデータ型（valiable type）が決まっています。
データ型によって処理の仕方を変える必要がありますが、`struct`ではデータ型の情報さえ渡してあげれば
あとは型に合わせてよしなにやってくれる、といった感じになっています。

ただし、公式ドキュメントとフォーマットの書きぶりが違う場合があるため、そこは適宜読み変える必要があります。
今回であれば以下のようになります。

```
short int → unsigned short(H)
int       → unsigned long(L)
byte(int) → unsigned char(B)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/160512/485313da-d467-251a-09ae-aef5b2e6f6dd.png)

## バイナリデータの読み込み
ファイルをopenする際のオプションを`rb(read binary)`とすることで読み込むことができます。

``` python
with open(file_name, 'br') as f:
   data = f.read()
```

## バイナリデータの解釈
`struct.unpack_from()`関数を使うことで、読み込んだバイナリデータを変換することができます。
基本的なフォーマットは`struct.unpack_from(データ型, データ, オフセット)`です。
データ型もオフセットも既に分かっているので後は指定するだけですね！

以下が全体像になります。
思ったより長く見えますが、基本的には最初にヘッダー調整してあとは項目の数だけひたすらオフセット分ずらしてunpackする作業を繰り返しています。

``` python
import sys
import struct

OLAR_EARTH_RADIUS = 6356752.3142
# PI = Math: : PI
MAX_UINT4 = 4294967295
FT2M = 1/3.2808399  # factor for feet to meter conversions
KN2KM = 1/1.852     # factor for knots to km conversions

args = sys.argv

if args[1] == '':
    print('Usage:  python sl2decoder.py your_file.sl2')

block_offset = 0

# 10Byteのヘッダー分ずらす 
block_offset += 10   

# Datatypes:
# ===================================================================================================
# Type    Definition                                          Directive for Python's String#unpack
# ---------------------------------------------------------------------------------------------------
# byte 	  UInt8                                               B
# short   UInt16LE                                            H
# int 	  UInt32LE                                            L
# float   FloatLE (32 bits IEEE 754 floating point number)    f
# flags   UInt16LE                                            H
# ---------------------------------------------------------------------------------------------------

# 各項目のオフセットとデータ型を定義
block_def = {
    'blockSize'         : {'offset': 26, 'type': '<H'},
    #  'lastBlockSize': {'offset': 28, 'type': '<H'},
    'channel'           : {'offset': 30, 'type': '<H'},
    'packetSize'        : {'offset': 32, 'type': '<H'},
    'frameIndex'        : {'offset': 34, 'type': '<L'},
    'upperLimit'        : {'offset': 38, 'type': '<f'},
    'lowerLimit'        : {'offset': 42, 'type': '<f'},
    'frequency'         : {'offset': 51, 'type': '<B'},
    #  'time1': {'offset': 58, 'type': '<H'}          # unknown resolution, unknown epoche
    'waterDepthFt'      : {'offset': 62, 'type': '<f'},  # in feet
    'keelDepthFt'       : {'offset': 66, 'type': '<f'},  # in feet
    'speedGpsKnots'     : {'offset': 98, 'type': '<f'},  # in knots
    'temperature'       : {'offset': 102, 'type': '<f'}, # in °C
    'lowrance_longitude': {'offset': 106, 'type': '<L'}, # Lowrance encoding (easting)
    'lowrance_latitude' : {'offset': 110, 'type': '<L'}, # Lowrance encoding (northing)
    'speedWaterKnots'   : {'offset': 114, 'type': '<f'}, # from "water wheel sensor" if present, else GPS value(?)
    'courseOverGround'  : {'offset': 118, 'type': '<f'}, # ourseOverGround in radians
    'altitudeFt'        : {'offset': 122, 'type': '<f'}, # in feet
    'heading'           : {'offset': 126, 'type': '<f'}, # in radians
    'flags'             : {'offset': 130, 'type': '<H'},
    #  'time': {'offset': 138, 'type': '<H', 'len': 4}          # unknown resolution, unknown epoche
}

with open('%s_output_py.csv' % args[0], 'w') as f_raw:
    title = ','.join(['Channel', 'Frequency', 'UpperLimit[ft]', 'LowerLimit[ft]', 'Depth[ft]', 'WaterTemp[C]', 'WaterSpeed[kn]',
 'PositionX', 'PositionY', 'Speed[kn]', 'Track[rad]','Altitude[ft]', 'Heading[rad]']) + '\n'
    f_raw.write(title)

    alive_counter = 0

    with open(args[1], 'br') as f:
        data = f.read()
        sl2_file_size = len(data)

        while block_offset < sl2_file_size:
            h = {}
            if alive_counter % 100 == 0:
                print('%d done...' % round(100.0*block_offset/sl2_file_size))

            for k, v in block_def.items():
                t_offset = block_offset + v['offset']
                h[k] = struct.unpack_from(v['type'], data, t_offset)

            print(h['blockSize'])
            block_offset += h['blockSize'][0]

            # 1行のデータにまとめる
            csv_line = ','.join([str(h['channel'][0]), str(h['frequency'][0]), 
                                 str(h['upperLimit'][0]), str(h['lowerLimit'][0]), 
                                 str(h['waterDepthFt'][0]), str(h['temperature'][0]), 
                                 str(h['speedWaterKnots'][0]), str(h['lowrance_longitude'][0]), 
                                 str(h['lowrance_latitude'][0]), str(h['speedGpsKnots'][0]), 
                                 str(h['courseOverGround'][0]), str(h['altitudeFt'][0]), 
                                 str(h['heading'][0])]) + '\n'

            f_raw.write(csv_line)

print('Read up to block_offset %d' % block_offset)


```