Python에서 이진파일 (Binary file) 입출력

Python에는 이진파일을 다루는 여러 방법이 존재한다. 여기서는 numpy, ctypes ,struct 모듈을 이용하는 방법을 소개한다.

아래 예제코드에서 다룰 이진파일의 구조는 다음과 같다.

// Litte endian
struct header
{
    int8_t val1 = -34;
    uint16_t val2 = 257;
    double val3 = 36.3948;
    float array[3][2] = 
    {
        {1.1, 8.8},
        {5.5, 9.9},
        {6.6, 7.7}
    };
    std::complex<double> carray[5] = 
    {
        {1, 2}, {3, 4}, {5, 6}, {7, 8}, {8, 9}
    };
};


1. Numpy module을 이용한 이진파일 처리

Numpy에서는 행렬 저장을 위한 Npy 파일 형태만이 아니라 C언어 형태의 이진파일을 읽고 쓸수 있다. 이 방식은 bit-field를 포함하는 이진파일이 아니라면 Numpy API로 이진 파일을 다룰수 있기 때문에 내가 가장 많이 쓰는 방법이다.

이진파일을 처리하기 위해서는 우선 Byte order와 자료형명이 정해져야 한다. Byte order와 자료형명은 다음과 같다.


1.1. Byte order[1]

Numpy에서 Byte order는 <, >를 이용하여 지정할 수 있다. 자료형명과 붙여서 사용하게 된다.

Character Byte order
= native
< little-endian
> big-endian
| not applicable


1.2. 자료형명

numpy.sctypeDict를 참조하면 다음과 같다.

Code C Type Alias Size
?, b1 _Bool bool, Bool, bool8 1
b, i1 signed char int8, Int8, byte 1
B, u1 unsigned char uint8, UInt8, ubyte 1
h, i2 short int16, Int16, short 2
H, u2 unsigned short uint16, UInt16, ushort 2
i, i4 int int32, Int32, intc 4
I, u4 unsigned int uint32, UInt32, uintc 4
l, i8 long int, int0, int64, Int64, intp, long 8
L, u8 unsigned long uint, uint0, uint64, Uint64, UInt64, uintp 8
q long long longlong 8
Q unsigned long long ulonglong 8
e, f2 half precision float16, Float16, half 2
f, f4 float float32, Float32, single 4
d, f8 double float, float64, Float64, double 8
g, f16 __float128 float128, Float128, longdouble, longfloat 16
F, c8 complex<float> Complex32, complex64, csingle, singlecomplex 8
D, c16 complex<double> cdouble, cfloat, complex, complex128, Complex64 16
G, c32 complex<__float128> clongdouble, clongfloat, Complex128, complex256, longcomplex 32

Numpy는 하나의 자료형에 많은 이름을 지원하고 있다. 이 부분은 Numpy의 장점이자 단점이기도 하다. 다양한 자료형명은 코딩에 편리함을 주지만 signed char가 ‘b’, ‘i1’, ‘int8’, ‘Int8’, ‘byte’, numpy.byte, numpy.int8 등 7가지로 표현될 수 있어 코드의 가독성을 저해하기도 한다. 특히 Complex32, complex32와 같이 대소문자의 차이만으로 다른 자료형이 달라지는 경우도 있기에 Numpy 자료형 사용시에는 대문자 자료형을 배제하는 편이 좋다.


1.3. 이진 파일 읽기

Numpy에서는 이진 파일 전체를 dict형태로 읽어오는 방법과 순차적으로 읽어오는 방법이 있다.


1.3.1. 파일을 dict에 저장

파일 전체를 dict에 저장하기 위해서는 읽어올 변수와 자료형을 np.dtype 형태로 선언해야 한다. 자료형은 문자형과 numpy built-in type으로 선언할 수 있다.

자료형을 문자형으로 선언할 시에는 “Byte-order” + “element 개수” + “자료형명”순으로 표현하여야 한다. Byte-order가 현재 장비와 동일할 경우 생략할 수 있고 element 개수도 1일 경우에 생략할 수 있다. ALIAS 자료형명을 사용할 경우에는 Byte-order와 함께 사용 할 수 없다.

import numpy as np
from numpy import fromfile

types = np.dtype([('val1', '<b'),
                  ('val2', '<u2'),
                  ('valf', np.float64),
                  ('array1', '<(3, 2)f'),
                  ('array2', '5complex128')])

print('----fromfile만 사용한 경우----')
data = fromfile('data_c.dat', types)
print(data['val1'])
print(data['val2'])
print(data['valf'])
print(data['array1'])
print(data['array2'])

# fromfile 뒤에 [0]을 붙이면 불필요한 Dimension을 줄일 수 있다.
print('\n----fromefile()[0]을 사용한 경우----')
data = fromfile('data_c.dat', types)[0]
print(data['val1'])
print(data['val2'])
print(data['valf'])
print(data['array1'])
print(data['array2'])

----fromfile만 사용한 경우----
[-34]
[257]
[36.3948]
[[[1.1 8.8]
  [5.5 9.9]
  [6.6 7.7]]]
[[1.+2.j 3.+4.j 5.+6.j 7.+8.j 8.+9.j]]

----fromefile()[0]을 사용한 경우----
-34
257
36.3948
[[1.1 8.8]
 [5.5 9.9]
 [6.6 7.7]]
[1.+2.j 3.+4.j 5.+6.j 7.+8.j 8.+9.j]


1.3.2. 순차적으로 읽어오기

fromfile의 기본형은 파일 끝까지 읽어오기 때문에 element 읽을 때는 count를 지정하여야 한다.


with open('data_c.dat' ,'rb') as fp:
    val1 = fromfile(fp, np.int8, 1)
    val2 = fromfile(fp, "int16", 1)
    valf = fromfile(fp, "<d", 1)
    array1 = fromfile(fp, "<(3, 2)f", 1)
    array2 = fromfile(fp, "c16", count=5)
    
print(val1)
print(val2)
print(valf)
print(array1)
print(array2)
[-34]
[257]
[36.3948]
[[[1.1 8.8]
  [5.5 9.9]
  [6.6 7.7]]]
[1.+2.j 3.+4.j 5.+6.j 7.+8.j 8.+9.j]


1.4. 이진 파일 쓰기

Numpy array는 tofile을 이용하여 자신의 값을 Binary로 저장할 수 있다. tofile 사용 시에는 자료형 선언을 위해서 format=을 명시하거나 자료형 선언 전에 ""을 추가해야만 한다.

with open('data_c_by_numpy.dat', 'wb') as fp:
    val1.tofile(fp, format='int8')
    val2.tofile(fp, format='i2')
    valf.tofile(fp, format='<f8')
    array1.tofile(fp, format='f4')
    array2.tofile(fp, "", 'c16')

from filecmp import cmp
print(f'File is same : {cmp("data_c.dat", "data_c_by_numpy.dat")}')
File is same : True


2. Ctypes module을 이용한 이진파일 처리

ctypes 모듈의 struct를 이용하면 bit-field 까지 쉽게 다룰수 있다. ctypes struct는 C언어의 구조체와 유사한 형태를 가지고 있어서 C언어에 익숙한 사람에게 유용하다.

ctypeslib2 를 이용하면 C언어 구조체를 ctypes struct로 쉽게 변환 할수도 있다. 현재 C 언어 프로그램과 병행하는 업무를 진행중이라면 추천할 만한 이진파일 처리 방법이다.


2.1. Ctypes struct

ctypes struct는 python의 class를 이용하고 class 선언시에 ctypes의 Structure를 상속받는다. Byte order는 LittelEndianStructure, BigEndianStructure를 이용하여 지정할 수 있다.

Structure의 attribute로 _pack_의 값을 설정하면 C 언어의 #pragma pack(1)과 동일한 역할을 하게 되고 변수명과 자료형은 _fields_에 선언하여야 한다.

from ctypes import (LittleEndianStructure, c_float, c_double,
                    c_int8, c_uint16)

class Header(LittleEndianStructure):
    _pack_ = 1
    _fields_ = [('val1', c_int8),
                ('val2', c_uint16),
                ('valf', c_double),
                ('array1', 6 * c_float),
                ('array2', 10 * c_double)]


2.2. 이진 파일 읽기

파일을 ‘rb’로 열고 readinto를 수행하면 이진 파일을 ctypes struct에 저장 할 수 있다.

from numpy import array

header = Header()
with open('data_c.dat', 'rb') as fp:
    fp.readinto(header)

print(header.val1)
print(header.val2)
print(header.valf)

array1 = array(header.array1)
print(array1)

array2 = array(header.array2[0::2]) + 1j * array(header.array2[1::2])
print(array2)
-34
257
36.3948
[1.1 8.8 5.5 9.9 6.6 7.7]
[1.+2.j 3.+4.j 5.+6.j 7.+8.j 8.+9.j]


2.3. 이진 파일 쓰기

with open('data_c_ctypes.dat', 'wb') as fp:
    fp.write(header)
    
from filecmp import cmp
print(f'File is same : {cmp("data_c.dat", "data_c_ctypes.dat")}')
File is same : True


3. Struct module을 이용한 이진파일 처리

Numpy가 없는 python에서 간단한 이진파일을 처리할때 사용한다.


3.1. Byte order [2]

Character Byte order Size Alignment
@ native native native
= native standard none
< little-endian standard none
> big-endian standard none
! network (= big-endian) standard none


3.2. Format Characters [2]

Format C Type Python type Standard size
x pad byte no value  
c char bytes of length 1 1
b signed char integer 1
B unsigned char integer 1
? _Bool bool 1
h short integer 2
H unsigned short integer 2
i int integer 4
I unsigned int integer 4
l long integer 4
L unsigned long integer 4
q long long integer 8
Q unsigned long long integer 8
n ssize_t integer  
N size_t integer  
e half precision float 2
f float float 4
d double float 8
s char[] bytes  
p char[] bytes  
P void * integer  


3.3. 이진 파일 읽기

from struct import unpack

with open('data_c.dat', 'rb') as fp:
    val1 = unpack('<1b', fp.read(1))
    val2 = unpack('H', fp.read(2))
    valf = unpack('<d', fp.read(8))
    array1 = unpack('<6f', fp.read(24))
    array2 = unpack('<10d', fp.read(80))

print(val1)
print(val2)
print(valf)
print(array1)
print(array2)
(-34,)
(257,)
(36.3948,)
(1.100000023841858, 8.800000190734863, 5.5, 9.899999618530273, 6.599999904632568, 7.699999809265137)
(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 8.0, 9.0)


3.4. 이진 파일 쓰기

from struct import pack

with open('data_c_struct.dat', 'wb') as fp:
    fp.write(pack('<1b', *val1))
    fp.write(pack('H', *val2))
    fp.write(pack('<d', *valf))
    fp.write(pack('<6f', *array1))
    fp.write(pack('<10d', *array2))
    
from filecmp import cmp
print(f'File is same : {cmp("data_c.dat", "data_c_struct.dat")}')
File is same : True


4. 참고 문헌

[1] https://docs.scipy.org/doc/numpy/reference/generated/numpy.dtype.byteorder.html
[2] https://docs.python.org/3/library/struct.html