Минималистичная библиотека для чтения подмножества бинарного формата хранения данных numpy "npz" с помощью чистого javascript
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

498 lines
21 KiB

/**
* Чтение NPZ файлов в браузере без внешних библиотек
* NPZ файл - это ZIP архив, содержащий .npy файлы
*/
// Проверка поддержки форматов сжатия
const SUPPORTED_DEFLATE_FORMATS = [];
if (typeof DecompressionStream !== 'undefined') {
// Проверяем поддержку форматов
// Примечание: создание DecompressionStream может не выбрасывать ошибку сразу,
// поэтому проверяем при использовании
// Приоритет: 'deflate-raw' для ZIP (raw deflate), затем 'deflate' (zlib формат)
SUPPORTED_DEFLATE_FORMATS.push('deflate-raw', 'deflate');
}
class NPZReader {
/**
* Читает NPZ файл из ArrayBuffer
* @param {ArrayBuffer} buffer - ArrayBuffer с содержимым NPZ файла
* @returns {Promise<Object>} Объект с ключами - именами массивов, значениями - массивами данных
*/
static async readNPZ(buffer) {
const zipFiles = await this.parseZIP(buffer);
console.log(`Найдено файлов в ZIP: ${Object.keys(zipFiles).length}`);
console.log('Файлы:', Object.keys(zipFiles));
const result = {};
for (const [filename, fileData] of Object.entries(zipFiles)) {
if (filename.endsWith('.npy')) {
const arrayName = filename.replace('.npy', '');
console.log(`Парсинг NPY файла: ${filename} -> ${arrayName}`);
try {
result[arrayName] = this.parseNPY(fileData);
console.log(`NPY файл ${filename} успешно распарсен`);
} catch (err) {
console.error(`Ошибка парсинга NPY файла ${filename}:`, err);
throw err;
}
} else {
console.log(`Пропущен файл (не .npy): ${filename}`);
}
}
console.log(`Итого распарсено массивов: ${Object.keys(result).length}`);
return result;
}
/**
* Парсит ZIP архив (упрощённая реализация для NPZ файлов)
* @param {ArrayBuffer} buffer - ArrayBuffer с ZIP архивом
* @returns {Promise<Object>} Объект с именами файлов и их содержимым
*/
static async parseZIP(buffer) {
const view = new DataView(buffer);
const files = {};
let offset = 0;
// Ищем центральный каталог в конце файла
// ZIP файл имеет структуру: [локальные файлы] [центральный каталог] [конец центрального каталога]
// Ищем сигнатуру конца центрального каталога (0x06054b50)
let eocdOffset = -1;
for (let i = buffer.byteLength - 22; i >= 0; i--) {
if (view.getUint32(i, true) === 0x06054b50) {
eocdOffset = i;
break;
}
}
if (eocdOffset === -1) {
throw new Error('Неверный формат ZIP файла');
}
// Читаем информацию о центральном каталоге
const cdOffset = view.getUint32(eocdOffset + 16, true);
const cdSize = view.getUint32(eocdOffset + 12, true);
const totalEntries = view.getUint16(eocdOffset + 10, true);
// Читаем записи центрального каталога
let currentOffset = cdOffset;
for (let i = 0; i < totalEntries; i++) {
// Сигнатура центрального файла (0x02014b50)
if (view.getUint32(currentOffset, true) !== 0x02014b50) {
throw new Error('Ошибка чтения центрального каталога');
}
// Флаги сжатия
const flags = view.getUint16(currentOffset + 8, true);
const method = view.getUint16(currentOffset + 10, true);
// Размеры
const compressedSize = view.getUint32(currentOffset + 20, true);
const uncompressedSize = view.getUint32(currentOffset + 24, true);
// Длина имени файла
const filenameLength = view.getUint16(currentOffset + 28, true);
const extraLength = view.getUint16(currentOffset + 30, true);
const commentLength = view.getUint16(currentOffset + 32, true);
// Смещение локального заголовка
const localHeaderOffset = view.getUint32(currentOffset + 42, true);
// Читаем имя файла
const filenameBytes = new Uint8Array(buffer, currentOffset + 46, filenameLength);
const filename = new TextDecoder('utf-8').decode(filenameBytes);
// Отладочная информация
console.log(`Файл: ${filename}, метод сжатия: ${method}, размер: ${compressedSize}/${uncompressedSize}`);
// Переходим к следующей записи
currentOffset += 46 + filenameLength + extraLength + commentLength;
// Читаем локальный заголовок файла
if (view.getUint32(localHeaderOffset, true) !== 0x04034b50) {
throw new Error('Ошибка чтения локального заголовка');
}
const localFilenameLength = view.getUint16(localHeaderOffset + 26, true);
const localExtraLength = view.getUint16(localHeaderOffset + 28, true);
const dataOffset = localHeaderOffset + 30 + localFilenameLength + localExtraLength;
// Читаем данные файла
if (method === 0) {
// Без сжатия (store)
console.log(`Чтение несжатого файла ${filename} (размер: ${(uncompressedSize / 1024).toFixed(2)} КБ)...`);
const fileData = new Uint8Array(buffer, dataOffset, uncompressedSize);
// Создаём копию данных, чтобы иметь независимый ArrayBuffer
const fileDataCopy = new Uint8Array(fileData);
files[filename] = fileDataCopy.buffer;
console.log(`Файл ${filename} прочитан (${fileDataCopy.length} байт)`);
} else if (method === 8) {
// Deflate сжатие - используем встроенный API браузера
// ZIP использует "raw deflate" (без zlib заголовка)
console.log(`Начало распаковки файла ${filename} (${(compressedSize / 1024 / 1024).toFixed(2)} МБ -> ${(uncompressedSize / 1024 / 1024).toFixed(2)} МБ)...`);
const compressedData = new Uint8Array(buffer, dataOffset, compressedSize);
try {
const startTime = Date.now();
const decompressedData = await this.inflate(compressedData);
const endTime = Date.now();
console.log(`Файл ${filename} распакован за ${((endTime - startTime) / 1000).toFixed(2)} секунд`);
files[filename] = decompressedData.buffer;
} catch (err) {
throw new Error(`Ошибка распаковки файла ${filename}: ${err.message}`);
}
} else {
throw new Error(`Неподдерживаемый метод сжатия: ${method} для файла ${filename}`);
}
}
return files;
}
/**
* Распаковывает данные, сжатые методом Deflate
* @param {Uint8Array} data - Сжатые данные
* @returns {Promise<Uint8Array>} Распакованные данные
*/
static async inflate(data) {
// Используем встроенный API DecompressionStream (доступен в современных браузерах)
if (typeof DecompressionStream === 'undefined') {
throw new Error('DecompressionStream не поддерживается. Используйте современный браузер (Chrome 80+, Firefox 113+, Safari 16.4+).');
}
// ZIP использует "raw deflate" (без zlib заголовка и контрольной суммы)
// Используем поддерживаемые форматы
const formats = SUPPORTED_DEFLATE_FORMATS.length > 0
? SUPPORTED_DEFLATE_FORMATS
: ['deflate-raw', 'deflate']; // Fallback на стандартные форматы
let lastError = null;
for (const format of formats) {
try {
console.log(`Попытка распаковки с форматом: ${format}`);
// Создаём новый stream для каждой попытки
const stream = new DecompressionStream(format);
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
// Запускаем чтение параллельно с записью
const readPromise = (async () => {
const chunks = [];
let done = false;
let totalRead = 0;
while (!done) {
const { done: readerDone, value } = await reader.read();
done = readerDone;
if (value) {
chunks.push(value);
totalRead += value.length;
// Логируем прогресс каждые 10 МБ
if (totalRead % (10 * 1024 * 1024) < value.length) {
console.log(`Распаковано: ${(totalRead / 1024 / 1024).toFixed(2)} МБ`);
}
}
}
console.log(`Распаковка завершена, всего: ${(totalRead / 1024 / 1024).toFixed(2)} МБ`);
return { chunks, totalRead };
})();
// Записываем данные частями, чтобы не блокировать поток
console.log('Запись данных в stream...');
const chunkSize = 1024 * 1024; // 1 МБ за раз
let written = 0;
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, Math.min(i + chunkSize, data.length));
await writer.write(chunk);
written += chunk.length;
if (written % (1024 * 1024 * 5) < chunkSize) { // Логируем каждые 5 МБ
console.log(`Записано: ${(written / 1024 / 1024).toFixed(2)} МБ из ${(data.length / 1024 / 1024).toFixed(2)} МБ`);
}
}
await writer.close();
console.log('Данные записаны, ожидание завершения распаковки...');
// Ждём завершения чтения
const { chunks, totalRead } = await readPromise;
// Объединяем все чанки в один массив
if (chunks.length === 0) {
throw new Error('Нет данных после распаковки');
}
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
} catch (err) {
lastError = err;
// Пробуем следующий формат
console.log(`Формат ${format} не сработал:`, err.message);
continue;
}
}
// Если ни один формат не сработал
throw new Error(`Ошибка распаковки: ${lastError?.message || 'неизвестная ошибка'}. Попробованы форматы: ${formats.join(', ')}. ZIP использует raw deflate, который может не поддерживаться в вашем браузере.`);
}
/**
* Парсит NPY файл (формат NumPy)
* @param {ArrayBuffer} buffer - ArrayBuffer с содержимым NPY файла
* @returns {Object} Объект с данными массива {data, shape, dtype}
*/
static parseNPY(buffer) {
const view = new DataView(buffer);
let offset = 0;
// Проверяем магический заголовок (0x93 'NUMPY')
if (view.getUint8(offset) !== 0x93) {
throw new Error('Неверный формат NPY файла: неверный магический байт');
}
offset++;
const magic = String.fromCharCode(
view.getUint8(offset++),
view.getUint8(offset++),
view.getUint8(offset++),
view.getUint8(offset++),
view.getUint8(offset++)
);
if (magic !== 'NUMPY') {
throw new Error('Неверный формат NPY файла: неверная магическая строка');
}
// Читаем версию формата
const majorVersion = view.getUint8(offset++);
const minorVersion = view.getUint8(offset++);
// Читаем длину заголовка
let headerLength;
if (majorVersion === 1) {
headerLength = view.getUint16(offset, true);
offset += 2;
} else if (majorVersion === 2 || majorVersion === 3) {
headerLength = view.getUint32(offset, true);
offset += 4;
} else {
throw new Error(`Неподдерживаемая версия NPY формата: ${majorVersion}.${minorVersion}`);
}
// Читаем заголовок (Python dict в виде строки)
const headerBytes = new Uint8Array(buffer, offset, headerLength);
const headerStr = new TextDecoder('utf-8').decode(headerBytes);
offset += headerLength;
// Парсим заголовок (упрощённый парсинг Python dict)
console.log('Заголовок NPY:', headerStr);
const header = this.parseHeader(headerStr);
console.log('Распарсенный заголовок:', header);
// Читаем данные
const dataOffset = offset;
const dataLength = buffer.byteLength - dataOffset;
const data = new Uint8Array(buffer, dataOffset, dataLength);
console.log(`Данные NPY: offset=${dataOffset}, length=${dataLength}, shape=${JSON.stringify(header.shape)}, dtype=${header.descr}`);
// Проверяем, что shape и descr найдены
if (!header.descr) {
throw new Error('Не найден dtype (descr) в заголовке NPY');
}
if (!header.shape || header.shape.length === 0) {
throw new Error('Не найден shape в заголовке NPY');
}
// Конвертируем данные в нужный тип
const typedArray = this.convertToTypedArray(data, header.descr, header.shape);
console.log(`Конвертировано: ${typedArray.length} элементов`);
return {
data: typedArray,
shape: header.shape,
dtype: header.descr,
fortran_order: header.fortran_order || false
};
}
/**
* Парсит заголовок NPY файла (Python dict)
* @param {string} headerStr - Строка заголовка
* @returns {Object} Распарсенный заголовок
*/
static parseHeader(headerStr) {
// Упрощённый парсер Python dict
// Формат: {'descr': '<f8', 'fortran_order': False, 'shape': (2, 3), }
const result = {};
// Извлекаем dtype (descr)
const descrMatch = headerStr.match(/descr['"]\s*:\s*['"]([^'"]+)['"]/);
if (descrMatch) {
result.descr = descrMatch[1];
} else {
console.warn('Не найден descr в заголовке:', headerStr);
}
// Извлекаем fortran_order
const fortranMatch = headerStr.match(/fortran_order['"]\s*:\s*(True|False)/);
if (fortranMatch) {
result.fortran_order = fortranMatch[1] === 'True';
}
// Извлекаем shape
// Формат может быть: 'shape': (3,) или 'shape': (3, 4) или 'shape': (3,)
const shapeMatch = headerStr.match(/shape['"]\s*:\s*\(([^)]*)\)/);
if (shapeMatch) {
const shapeStr = shapeMatch[1].trim();
if (shapeStr) {
// Разбиваем по запятой и парсим числа
result.shape = shapeStr.split(',').map(s => {
const trimmed = s.trim();
return trimmed ? parseInt(trimmed, 10) : null;
}).filter(x => x !== null); // Убираем null значения
} else {
result.shape = []; // Скаляр
}
} else {
console.warn('Не найден shape в заголовке:', headerStr);
result.shape = [];
}
return result;
}
/**
* Конвертирует байты в типизированный массив
* @param {Uint8Array} data - Сырые данные
* @param {string} dtype - Описание типа данных (например, '<f8', '<i4')
* @param {Array<number>} shape - Форма массива
* @returns {TypedArray} Типизированный массив
*/
static convertToTypedArray(data, dtype, shape) {
// Парсим dtype: '<f8' -> little-endian, float64
// Формат: [endian][type][size]
// endian: '<' (little), '>' (big), '=' (native), '|' (not applicable, single byte)
// type: 'f' (float), 'i' (int), 'u' (uint), 'c' (complex), 'b' (bool)
// size: количество байт (1, 2, 4, 8)
console.log(`convertToTypedArray: dtype=${dtype}, shape=${JSON.stringify(shape)}, data.length=${data.length}`);
const endian = dtype[0];
const type = dtype[1];
const size = parseInt(dtype.substring(2), 10);
// Проверяем, что size валидный
if (isNaN(size) || size <= 0) {
throw new Error(`Неверный размер dtype: ${dtype}`);
}
const isLittleEndian = endian === '<' || (endian === '=' && this.isLittleEndian());
// Вычисляем общее количество элементов
const totalElements = shape.length === 0 ? 1 : shape.reduce((a, b) => a * b, 1);
let typedArray;
switch (type) {
case 'f': // float
if (size === 4) {
typedArray = new Float32Array(data.buffer, data.byteOffset, totalElements);
} else if (size === 8) {
typedArray = new Float64Array(data.buffer, data.byteOffset, totalElements);
} else {
throw new Error(`Неподдерживаемый размер float: ${size}`);
}
break;
case 'i': // int (signed)
if (size === 1) {
typedArray = new Int8Array(data.buffer, data.byteOffset, totalElements);
} else if (size === 2) {
typedArray = new Int16Array(data.buffer, data.byteOffset, totalElements);
} else if (size === 4) {
typedArray = new Int32Array(data.buffer, data.byteOffset, totalElements);
} else if (size === 8) {
// Int64Array не существует, используем BigInt64Array
typedArray = new BigInt64Array(data.buffer, data.byteOffset, totalElements);
} else {
throw new Error(`Неподдерживаемый размер int: ${size}`);
}
break;
case 'u': // uint (unsigned)
if (size === 1) {
typedArray = new Uint8Array(data.buffer, data.byteOffset, totalElements);
} else if (size === 2) {
typedArray = new Uint16Array(data.buffer, data.byteOffset, totalElements);
} else if (size === 4) {
typedArray = new Uint32Array(data.buffer, data.byteOffset, totalElements);
} else if (size === 8) {
// Uint64Array не существует, используем BigUint64Array
typedArray = new BigUint64Array(data.buffer, data.byteOffset, totalElements);
} else {
throw new Error(`Неподдерживаемый размер uint: ${size}`);
}
break;
case 'b': // bool
typedArray = new Uint8Array(data.buffer, data.byteOffset, totalElements);
break;
case 'c': // complex
// Комплексные числа занимают в 2 раза больше места
if (size === 8) {
// complex64 = 2 * float32
typedArray = new Float32Array(data.buffer, data.byteOffset, totalElements * 2);
} else if (size === 16) {
// complex128 = 2 * float64
typedArray = new Float64Array(data.buffer, data.byteOffset, totalElements * 2);
} else {
throw new Error(`Неподдерживаемый размер complex: ${size}`);
}
break;
default:
throw new Error(`Неподдерживаемый тип данных: ${type}`);
}
// Если порядок байт не совпадает, нужно переставить байты
if (!isLittleEndian && size > 1) {
const view = new DataView(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength);
for (let i = 0; i < typedArray.length; i++) {
const offset = i * size;
// Переставляем байты для каждого элемента
const bytes = new Uint8Array(size);
for (let j = 0; j < size; j++) {
bytes[j] = view.getUint8(offset + j);
}
bytes.reverse();
for (let j = 0; j < size; j++) {
view.setUint8(offset + j, bytes[j]);
}
}
}
return typedArray;
}
/**
* Проверяет, является ли система little-endian
* @returns {boolean}
*/
static isLittleEndian() {
const buffer = new ArrayBuffer(2);
const view = new DataView(buffer);
view.setUint16(0, 0x0001, true);
return view.getUint8(0) === 1;
}
}