Python: バイナリデータを入出力したい (struct)

お仕事でバイナリのセンサデータをPythonで入出力する必要がありましたので、標準ライブラリ struct について整理します。

struct

structは、Pythonの値 (int, double, charなど) とCの構造体 (Pythonではbytesオブジェクト) を相互変換するためのモジュールです。

docs.python.org

structの特徴は、値のレイアウトをフォーマット文字列で表現できる点です。

  • 例えば "<cLd" の場合、"(char, unsigned long, double) がリトルエンディアンで表現されている" と解釈されます

そして、それらをイテレーションとして入出力できるので、実装が簡単というメリットがあります。

実装

例として、架空のGPSファイルを想定します。以下のフォーマットとなっており、合計24バイト (リトルエンディアン) となっています。

  • タイムスタンプ (timestamp): 4バイト非負整数 (L)
  • 緯度 (lattitude): 8バイト浮動小数点 (d)
  • 経度 (longitude): 8バイト浮動小数点 (d)
  • 各種フラグ: 1バイト (c)
    • 0x00: 途中 (始端でも終端でも無い)
    • 0x01: 始端
    • 0x02: 終端
  • 予備 (パディング): 3バイト (xxx, パディング1バイトはxで表現される)

CSV文字列で表現すると↓のようなデータとなります。

1580569820, 35.709335, 139.769807, 1
1580569840, 35.709605, 139.771330, 0
1580569858, 35.709814, 139.772344, 2

structとバイナリデータ格納用にarrayをインポートしておきます。

import struct
import array

Pythonのバージョンは3.7.4を使っています。

値 -> バイナリ (1レコード)

最初に値からバイナリデータへの変換です。

packにフォーマット文字列と対応する値を順番に渡すと、バイナリ (bytesオブジェクト) が返されます。

  • calcsize関数を使うと、フォーマット文字列変換後のサイズが計算できます
  • パディングは引数内で明示する必要はありません
format = "<Lddcxxx"
record_size = struct.calcsize(format)
print(record_size)  # 24

b = struct.pack(format, 1580569820, 35.709335, 139.769807, (1).to_bytes(1, "little"))

print(type(b))  # <class 'bytes'>
print(b.hex())
# dc94355e3f74417dcbda41406b274a42a278614001000000

余談ですが、今回はリトルエンディアンを指定 (<) してますが、デフォルトではCの構造体のメモリレイアウトに従います。アライメントのルールも含まれますので、値のサイズの合計とメモリレイアウトで確保されたサイズは厳密に一致しなくなります。
リトルエンディアンではない点を除いて、上と同じフォーマットですが、サイズは28バイトとなってます。intとdoubleの間に4バイトのスペースが開けられており、厳密にアライメントが適用されていることがわかります。

format = "Lddcxxx"  # アライメント: native
record_size = struct.calcsize(format)
print(record_size)  # 28

b = struct.pack(format, 1580569820, 35.709335, 139.769807, (1).to_bytes(1, "little"))
print(b.hex())
# dc94355e000000003f74417dcbda41406b274a42a278614001000000

バイナリ -> 値 (1レコード)

次にバイナリから値への変換です。

packとは逆で、unpackにフォーマット文字列とバイナリを渡すと、タプルで値が得られます。

record = struct.unpack(format, b)

print(type(record))  # <class 'tuple'>
print(record)  # (1580569820, 35.709335, 139.769807, b'\x01')
print(type(record[0]), type(record[1]), type(record[2]), type(record[3]))
# <class 'int'> <class 'float'> <class 'float'> <class 'bytes'>

複数レコードをまとめて変換

GPSデータのような複数レコードからなるデータをまとめて相互変換する場合の関数もあります。

pack_intoを使うと、バッファ (2番目の引数) 上のオフセット (3番目の引数) からバイナリを書き込みます。

unpack_fromはその逆で、バッファ上のオフセットからバイナリを読み込みます。
iter_unpackはバイナリレコードをイテレーションで取り出すことができます。

buffer = array.array("b", b'\00' * record_size * 3)

struct.pack_into(format, buffer, record_size*0, 1580569820, 35.709335, 139.769807, (1).to_bytes(1, "little"))
struct.pack_into(format, buffer, record_size*1, 1580569840, 35.709605, 139.771330, (0).to_bytes(1, "little"))
struct.pack_into(format, buffer, record_size*2, 1580569858, 35.709814, 139.772344, (2).to_bytes(1, "little"))
print(buffer)
# array('b', [-36, -108, 53, 94, 63, ... ]

for i in range(3):
    record = struct.unpack_from(format, buffer, record_size*i)
    print(record)
    
for record in struct.iter_unpack(format, buffer):
    print(record)
    
# (1580569820, 35.709335, 139.769807, b'\x01')
# (1580569840, 35.709605, 139.77133, b'\x00')
# (1580569858, 35.709814, 139.772344, b'\x02')

プリコンパイル

Structクラスにフォーマット文字列を渡すと、これまで説明した関数をメソッド形式で使うことができます。
フォーマット文字列がプリコンパイルされて使い回すので、複雑なレコードをたくさん変換する場合は、こちらのメソッド形式のほうが効率的です。

compiled = struct.Struct(format)

b = compiled.pack(1580569820, 35.709335, 139.769807, (1).to_bytes(1, "little"))
print(b.hex())
# dc94355e3f74417dcbda41406b274a42a278614001000000

record = compiled.unpack(b)
print(record)
# (1580569820, 35.709335, 139.769807, b'\x01')

buffer = array.array("b", b'\00' * record_size * 3)
compiled.pack_into(buffer, record_size*0, 1580569820, 35.709335, 139.769807, (1).to_bytes(1, "little"))
compiled.pack_into(buffer, record_size*1, 1580569840, 35.709605, 139.771330, (0).to_bytes(1, "little"))
compiled.pack_into(buffer, record_size*2, 1580569858, 35.709814, 139.772344, (2).to_bytes(1, "little"))
print(buffer)
# array('b', [-36, -108, 53, 94, 63, ... ]

for i in range(3):
    record = compiled.unpack_from(buffer, record_size*i)
    print(record)
    
for record in compiled.iter_unpack(buffer):
    print(record)
    
# (1580569820, 35.709335, 139.769807, b'\x01')
# (1580569840, 35.709605, 139.77133, b'\x00')
# (1580569858, 35.709814, 139.772344, b'\x02')

まとめ

今回はPython標準ライブラリ struct を使ったバイナリデータの相互変換についてまとめました。