Интерпретация битовых данных в Python с помощью структур и ctypes: невыровненные результаты

У меня есть некоторые данные, хранящиеся в битовом формате, которые я пытаюсь извлечь с помощью Python. В частности, это старые растровые шрифты, где:

  • каждая строка глифа представляет собой группу из n бит (где n — ширина символа).
  • только первая строка гарантированно начинается на границе байта.
  • данные растрового изображения для каждого глифа упакованы в последовательность байтов с обратным порядком байтов.

Например, вот шестнадцатеричные данные с прямым порядком байтов для одного глифа шириной 9 бит:

1F 1F DB 79 3D BF FE F7 86 CE 3E

Если мы разобьем это вручную, мы увидим, что этот битовый шаблон представляет собой маленький смайлик:

8-bit bytes:        9-bit chunks:   character (0='.', 1='$'):
                                         
0x1F = 00011111     000111110       ...$$$$$. 
0x1F = 00011111     001111111       ..$$$$$$$ 
0xDB = 11011011     011011011       .$$.$$.$$ 
0x79 = 01111001     110010011       $$..$..$$ 
0x3D = 00111101     110110111       $$.$$.$$$ 
0xBF = 10111111     111111111       $$$$$$$$$ 
0xFE = 11111110     101111011       $.$$$$.$$ 
0xF7 = 11110111     110000110       $$....$$. 
0x86 = 10000110     110011100       $$..$$$.. 
0xCE = 11001110     011111000       .$$$$$... 
0x3E = 00111110                               

Поэтому я попытался сделать то же самое в Python, но не смог правильно выровнять данные при упаковке.

Вот что у меня есть на данный момент — всего лишь небольшой тестовый код, чтобы проверить, смогу ли я получить ожидаемый результат из этого конкретного фрагмента данных. Обратите внимание на выбор field_type, поскольку, похоже, это корень моих проблем:

import struct
import ctypes

field_type = ctypes.c_ulonglong

class PackedBitmap(ctypes.BigEndianStructure):
    _fields_ = [ ('line00', field_type, 9),
                 ('line01', field_type, 9),
                 ('line02', field_type, 9),
                 ('line03', field_type, 9),
                 ('line04', field_type, 9),
                 ('line05', field_type, 9),
                 ('line06', field_type, 9),
                 ('line07', field_type, 9),
                 ('line08', field_type, 9),
                 ('line09', field_type, 9) ]

bm = PackedBitmap()

struct.pack_into('>11s', bm, 0, 
                 b'\x1F\x1F\xDB\x79\x3D\xBF\xFE\xF7\x86\xCE\x3E')

for field in bm._fields_:
    bin_str = f'{getattr(bm, field[0]):09b}'
    print(bin_str + '     ' + bin_str.replace('0','.').replace('1','$'))

Но независимо от того, какой тип C я выбираю для полей своего PackedBitmap, я не могу получить правильный результат. Ошибки есть всегда, и размер типа данных, похоже, определяет, где произойдет первая ошибка.

field_type = ctypes.c_ulonglong:

000111110     ...$$$$$.
001111111     ..$$$$$$$
011011011     .$$.$$.$$
110010011     $$..$..$$
110110111     $$.$$.$$$
111111111     $$$$$$$$$
101111011     $.$$$$.$$
100001101     $....$$.$   # <- first error
100111000     $..$$$...
111110000     $$$$$....

field_type = ctypes.c_uint32:

000111110     ...$$$$$.
001111111     ..$$$$$$$
011011011     .$$.$$.$$
001111011     ..$$$$.$$   # <- first error
011111111     .$$$$$$$$
111110111     $$$$$.$$$
100001101     $....$$.$
100111000     $..$$$...
111110000     $$$$$....
000000000     .........

field_type = ctypes.c_uint16:

000111110     ...$$$$$.
110110110     $$.$$.$$.   # <- first error
001111011     ..$$$$.$$
111111101     $$$$$$$.$
100001101     $....$$.$
001111100     ..$$$$$..
000000000     .........
000000000     .........
000000000     .........
000000000     .........

Я не уверен, что здесь происходит: длина в 9 бит удобно вписывается во все эти типы полей (64, 32 и 16 бит соответственно), поэтому не следует ли это упаковать так, как ожидалось? Чего мне не хватает и как это исправить?

Ctypes, очевидно, не позволяет битовому полю типа X охватывать более одного элемента типа X; например, с 64-битным X вы получаете семь 9-битных полей общей длиной 63 бита, затем один бит пропускается, так что следующее поле начинается в новом элементе ulonglong. Вы не сможете таким образом анализировать растровые данные. Вместо этого я бы предложил пакет bitarray: в нем есть метод .frombytes(), который может напрямую читать ваши данные, а затем вы можете извлекать строки растрового изображения произвольного размера просто с помощью нарезки.

jasonharper 19.07.2024 15:29

Вы можете увидеть это, если распечатаете PackedBitmap.lineXY. Он покажет вам смещение для байта и бита. В зависимости от field_type вы можете увидеть, где что-то идет не так. Возможно, об этом стоит сообщить сопровождающим CPython. Но учтите, что в C это тоже не работает.

Wombatz 19.07.2024 16:17

@jasonharper Спасибо, я смотрел на bitstring, но bitarray может быть даже лучшим инструментом для этой работы.

CugelTC 19.07.2024 18:30
Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
0
3
75
5
Перейти к ответу Данный вопрос помечен как решенный

Ответы 5

Ответ принят как подходящий

Похоже, у ctypes.Strucuture возникают проблемы при пересечении границ байтов (в зависимости от типа поля).

Мы можем напечатать смещение, которое используют поля:

print(PackedBitmap.line00)
...
print(PackedBitmap.line09)

Что дает нам

<Field type=c_ulonglong_be, ofs=0:55, bits=9> # 1
<Field type=c_ulonglong_be, ofs=0:46, bits=9> # 2
<Field type=c_ulonglong_be, ofs=0:37, bits=9> # 3
<Field type=c_ulonglong_be, ofs=0:28, bits=9> # 4
<Field type=c_ulonglong_be, ofs=0:19, bits=9> # 5
<Field type=c_ulonglong_be, ofs=0:10, bits=9> # 6
<Field type=c_ulonglong_be, ofs=0:1, bits=9>  # 7
<Field type=c_ulonglong_be, ofs=8:55, bits=9> # 8
<Field type=c_ulonglong_be, ofs=8:46, bits=9> # 9
<Field type=c_ulonglong_be, ofs=8:37, bits=9> # 10
  • Первое поле идет от битов 55 к 63. Это отлично
  • Седьмой идет от битов 1 к 9. Это тоже нормально
  • Восьмерки идут от битов 55 к 63 во втором байте. Итак, нам не хватает бита 0 байта 0.

Это может быть ошибка или, по крайней мере, неожиданная. Когда вы переключитесь на другие типы данных, вы увидите, что ошибка возникает первой при переключении на следующий байт.

Возможно, стоит сообщить об этом разработчикам CPython. На мой взгляд, это должно быть упомянуто как минимум в документации. Я не нашел такой подсказки.

Однако вот реализация, которая не использует ни ctypes, ни struct:

  1. он объединяет байты как 1 и 0 в одну большую строку
  2. затем он разбивает их на части длиной 9
from collections.abc import Iterator
from itertools import batched

raw = b'\x1F\x1F\xDB\x79\x3D\xBF\xFE\xF7\x86\xCE\x3E'


def regroup_bits(buffer: bytes, bits: int) -> Iterator[str]:
    binary = ''.join(f'{byte:08b}' for byte in buffer)

    return (''.join(batch) for batch in batched(binary, bits))


for i in regroup_bits(raw, 9):
    print(i.replace('0', '.').replace('1', '$'))

Это можно оптимизировать, чтобы лучше использовать память, не создавая одну большую строку, а вместо этого используя столько бит, сколько вам нужно, а затем yield 9-битное значение.

Также обратите внимание, что эта реализация не дополняет последнее значение.

Битовые поля определяются реализацией в C. Обычно (я никогда не видел, чтобы это было реализовано по-другому) дополняется следующий элемент, если битовое поле не полностью помещается в элемент. ctypes следует конвенции.

Mark Tolonen 19.07.2024 16:56

Спасибо за понимание границ элементов... да, я вижу, что там происходит. Подход с конкатенацией строк достаточно элегантен и, похоже, работает нормально! Я мог бы попробовать что-нибудь и с bitarray, но на данный момент это кажется наиболее гибким решением для поддержки различной ширины.

CugelTC 19.07.2024 18:56

Есть несколько способов решить эту проблему. Вот более удобный для разработчиков Python вариант, основанный на строковых операциях. Из-за этого он намного медленнее (и я бы сказал менее элегантен), чем немного манипулирующий аналог.

код00.py:

#!/usr/bin/env python

import string
import struct
import sys


def glyph(data, bit_width=9, display_chars = ".$"):
    binary = "".join(f"{e:08b}" for e in data)  # Convert to binary string
    tbl = binary.maketrans("01", display_chars)
    text = binary.translate(tbl)  # Replace 0s and 1s by the given chars
    rows, last = divmod(len(data) * 8, bit_width)
    if last:
        text += display_chars[0] * (bit_width - last)  # Pad the string with 0s correspondent (if neccessary)
        rows += 1
    ret = []
    for row_idx in range(rows):
        ret.append(text[bit_width * row_idx: bit_width * (row_idx + 1)])  # Split the string in chunks with the required length
    return tuple(ret)


def main(*argv):
    data = b"\x1F\x1F\xDB\x79\x3D\xBF\xFE\xF7\x86\xCE\x3E"
    print(data)
    lines = glyph(data)

    print("Glyph:")
    for line in lines:
        print(line)


if __name__ == "__main__":
    print(
        "Python {:s} {:03d}bit on {:s}\n".format(
            " ".join(elem.strip() for elem in sys.version.split("\n")),
            64 if sys.maxsize > 0x100000000 else 32,
            sys.platform,
        )
    )
    rc = main(*sys.argv[1:])
    print("\nDone.\n")
    sys.exit(rc)

Выход:

(qaic-env) [cfati@cfati-5510-0:/mnt/e/Work/Dev/StackExchange/StackOverflow/q078769490]> python ./code00.py 
Python 3.8.19 (default, Apr  6 2024, 17:58:10) [GCC 11.4.0] 064bit on linux

b'\x1f\x1f\xdby=\xbf\xfe\xf7\x86\xce>'
Glyph:
...$$$$$.
..$$$$$$$
.$$.$$.$$
$$..$..$$
$$.$$.$$$
$$$$$$$$$
$.$$$$.$$
$$....$$.
$$..$$$..
.$$$$$...

Done.

Вам лучше обрабатывать биты самостоятельно, в коде Python, чем полагаться на ctypes - в любом случае ctypes не будет намного быстрее для манипуляций с битами в своих структурах, и он будет «думать о типе данных контейнера» - это то есть ваши битовые поля должны содержаться в типе данных C, например int64 - вот почему вы получаете несовпадения при пересечении границы первых 8 байтов (64 бита) и почему это меняется при изменении типа контейнера на другой, чем ulong.

Оказывается, проще создать итератор, который будет выбирать каждый байт в последовательности и по очереди выдавать биты, а затем обернуть его другим итератором, который группирует эти биты в строки глифов.

class GlyphRender:
    def __init__(self, byte_seq, width=9, ones = "#", zeros = " "):
        self.byte_seq = byte_seq
        self.width = width
        self.ones = ones
        self.zeros = zeros
    def bit_iter(self):
        for byte in self.byte_seq:
            for power in range(7, -1, -1):
                yield bool(byte & 2 ** power)
    def __iter__(self):
        line = ""
        for bit in self.bit_iter():
            line += self.ones if bit else self.zeros
            if len(line) == self.width:
                yield line
                line = ""
        if line:
            yield line
    def __str__(self):
        return "\n".join(self)

И запускаем это на REPL:

In [49]: print(GlyphRender(b'\x1F\x1F\xDB\x79\x3D\xBF\xFE\xF7\x86\xCE\x3E'))
   #####
  #######
 ## ## ##
##  #  ##
## ## ###
#########
# #### ##
##    ##
##  ###
 #####

Не то чтобы я рекомендовал поступать именно так, но вот способ сделать это в ctypes. Используйте свойства для создания 9-битных полей. Вы могли бы еще более изощренно использовать собственные дескрипторы, чтобы избавиться от повторений, но я не собираюсь вдаваться в подробности. Обратите внимание, что другая реализация Python (это Python для 64-разрядной версии Windows) может не работать из-за того, что битовые поля определяются реализацией.

import ctypes as ct

class PackedBitmap(ct.BigEndianStructure):
    _fields_ = (('_line01', ct.c_ubyte, 8),
                ('_line02', ct.c_ubyte, 1),
                ('_line11', ct.c_ubyte, 7),
                ('_line12', ct.c_ubyte, 2),
                ('_line21', ct.c_ubyte, 6),
                ('_line22', ct.c_ubyte, 3),
                ('_line31', ct.c_ubyte, 5),
                ('_line32', ct.c_ubyte, 4),
                ('_line41', ct.c_ubyte, 4),
                ('_line42', ct.c_ubyte, 5),
                ('_line51', ct.c_ubyte, 3),
                ('_line52', ct.c_ubyte, 6),
                ('_line61', ct.c_ubyte, 2),
                ('_line62', ct.c_ubyte, 7),
                ('_line71', ct.c_ubyte, 1),
                ('_line72', ct.c_ubyte, 8),
                ('_line81', ct.c_ubyte, 8),
                ('_line82', ct.c_ubyte, 1),
                ('_line91', ct.c_ubyte, 7))

    @property
    def line00(self):
        return self._line01 << 1 | self._line02
    @line00.setter
    def line00(self, value):
        self._line01 = value >> 1
        self._line02 = value & 0x01

    @property
    def line01(self):
        return self._line11 << 2 | self._line12
    @line01.setter
    def line01(self, value):
        self._line11 = value >> 2
        self._line12 = value & 0x03

    @property
    def line02(self):
        return self._line21 << 3 | self._line22
    @line02.setter
    def line02(self, value):
        self._line21 = value >> 3
        self._line22 = value & 0x07

    @property
    def line03(self):
        return self._line31 << 4 | self._line32
    @line03.setter
    def line03(self, value):
        self._line31 = value >> 4
        self._line32 = value & 0x0F

    @property
    def line04(self):
        return self._line41 << 5 | self._line42
    @line04.setter
    def line04(self, value):
        self._line41 = value >> 5
        self._line42 = value & 0x1F

    @property
    def line05(self):
        return self._line51 << 6 | self._line52
    @line05.setter
    def line05(self, value):
        self._line51 = value >> 6
        self._line52 = value & 0x3F

    @property
    def line06(self):
        return self._line61 << 7 | self._line62
    @line06.setter
    def line06(self, value):
        self._line61 = value >> 7
        self._line62 = value & 0xEF

    @property
    def line07(self):
        return self._line71 << 8 | self._line72
    @line07.setter
    def line07(self, value):
        self._line71 = value >> 8
        self._line72 = value

    @property
    def line08(self):
        return self._line81 << 1 | self._line82
    @line08.setter
    def line08(self, value):
        self._line81 = value >> 1
        self._line82 = value & 0x01

    @property
    def line09(self):
        return self._line91 << 2
    @line09.setter
    def line09(self, value):
        self._line91 = value >> 2
        self._line92 = 0

    def __repr__(self):
        return (f'PackedBitmap(0b{self.line00:09b},\n'
                f'             0b{self.line01:09b},\n'
                f'             0b{self.line02:09b},\n'
                f'             0b{self.line03:09b},\n'
                f'             0b{self.line04:09b},\n'
                f'             0b{self.line05:09b},\n'
                f'             0b{self.line06:09b},\n'
                f'             0b{self.line07:09b},\n'
                f'             0b{self.line08:09b},\n'
                f'             0b{self.line09:09b})\n')

    def __str__(self):
        return (f'{self.line00:09b}\n'
                f'{self.line01:09b}\n'
                f'{self.line02:09b}\n'
                f'{self.line03:09b}\n'
                f'{self.line04:09b}\n'
                f'{self.line05:09b}\n'
                f'{self.line06:09b}\n'
                f'{self.line07:09b}\n'
                f'{self.line08:09b}\n'
                f'{self.line09:09b}\n').translate(str.maketrans('01', ' \N{FULL BLOCK}'))

bm = PackedBitmap.from_buffer_copy(bytes.fromhex('1F 1F DB 79 3D BF FE F7 86 CE 3E'))
print(bytes(bm).hex(' '))
print(repr(bm))
print(bm)
bm.line00 = 0b100000000
bm.line01 = 0b010000000
bm.line02 = 0b001000000
bm.line03 = 0b000100000
bm.line04 = 0b000010000
bm.line05 = 0b000001000
bm.line06 = 0b000000100
bm.line07 = 0b000000010
bm.line08 = 0b000000001
bm.line09 = 0b111111111

# Structure is 88 bits (11 bytes), but 10 lines of 9 bits is 90.
# Last two bits of line09 are forced zero.
print(bytes(bm).hex(' '))
print(repr(bm))
print(bm)

Выход:

1f 1f db 79 3d bf fe f7 86 ce 3e
PackedBitmap(0b000111110,
             0b001111111,
             0b011011011,
             0b110010011,
             0b110110111,
             0b111111111,
             0b101111011,
             0b110000110,
             0b110011100,
             0b011111000)

   █████ 
  ███████
 ██ ██ ██
██  █  ██
██ ██ ███
█████████
█ ████ ██
██    ██ 
██  ███  
 █████   

80 20 08 02 00 80 20 08 02 00 ff
PackedBitmap(0b100000000,
             0b010000000,
             0b001000000,
             0b000100000,
             0b000010000,
             0b000001000,
             0b000000100,
             0b000000010,
             0b000000001,
             0b111111100)

█        
 █       
  █      
   █     
    █    
     █   
      █  
       █ 
        █
███████  

вы, вероятно, могли бы добиться большего, просто отказавшись от ctypes и поработав с «bytearray», а затем использовать класс с методами __getitem__ и __setitem__, если вы хотите иметь возможность изменять содержимое структуры. Это позволяет избежать всех жестко запрограммированных свойств и даже работать для данных с различной разрядностью.

jsbueno 19.07.2024 17:58

@jsbueno Как я уже сказал, я не рекомендую это, просто показываю способ реализации ОП с помощью ctypes. Это было полезно для меня, когда у меня была странная структура C, поле которой пересекало естественную границу в реальной ситуации.

Mark Tolonen 19.07.2024 18:23

Да, я определенно не привязан к ctypes (кстати, мне не нужна возможность изменять содержимое - только для экспорта данных в более удобный формат).

CugelTC 19.07.2024 19:05

В комментарии было упомянуто, что ОП хочет «...только экспортировать данные в более удобный формат».

Вот реализация класса для этого:

import ctypes as ct

class PackedBitmap:
    def __init__(self, byte_data):
        # store bytes in a Python int with two trailing pad bits
        self._data = int.from_bytes(byte_data, 'big') << 2

    def __getitem__(self, index):
        # Support only integer indices (no slices) for 10 9-bit elements.
        if index < 0 or index > 9:
            raise IndexError('list index out of range')
        # Shift and mask the 9 bits represented by the index
        return self._data >> (81 - index * 9) & 0x1FF

    def __str__(self):
        '''Pretty display.'''
        xlat = str.maketrans('01', ' \N{FULL BLOCK}')
        return '\n'.join([f'{n:09b}'.translate(xlat) for n in self])

    def __repr__(self):
        '''Debug display.'''
        return f'PackedBitmap({(bm._data >> 2).to_bytes(11)!r})'

    def __iter__(self):
        # Make object iterable
        for i in range(len(self)):
            yield self[i]

    def __len__(self):
        # Make object fixed length
        return 10

bm = PackedBitmap(bytes([0x1f,0x1f,0xdb,0x79,0x3d,0xbf,0xfe,0xf7,0x86,0xce,0x3e]))
print(bm)        # graphic display
print(repr(bm))  # debug display
print(list(bm))  # convert 9-bit data to a Python list
for n in bm:     # iterate and custom display
    print(f'0x{n:03X} {n:09b}')

Выход:

   █████ 
  ███████
 ██ ██ ██
██  █  ██
██ ██ ███
█████████
█ ████ ██
██    ██ 
██  ███  
 █████   
PackedBitmap(b'\x1f\x1f\xdby=\xbf\xfe\xf7\x86\xce>')
[62, 127, 219, 403, 439, 511, 379, 390, 412, 248]
0x03E 000111110
0x07F 001111111
0x0DB 011011011
0x193 110010011
0x1B7 110110111
0x1FF 111111111
0x17B 101111011
0x186 110000110
0x19C 110011100
0x0F8 011111000

Спасибо! Если немного уточнить, этот «более удобный формат» будет выровнен по байтам, а ширина символа может быть произвольной (в разумных пределах, скажем, <= 32); 9 был примером конкретного файла. Я выбрал вариант идеи Вомбаца, основанной на строках, который было тривиально адаптировать к этим потребностям, хотя в противном случае ваше решение, вероятно, было бы более эффективным и расширяемым.

CugelTC 19.07.2024 22:16

Другие вопросы по теме