diff --git a/README.md b/README.md index 3ac1b9b..9bc4035 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ -# tiffexplore +# TIFF Explorer Explore tiff structures, tags, and frames in a GUI. diff --git a/bin/tiffexplore b/bin/tiffexplore new file mode 100644 index 0000000..ea6a50c --- /dev/null +++ b/bin/tiffexplore @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +from tiffexplore import main + +if __name__ == '__main__': + main() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..da390ab --- /dev/null +++ b/setup.py @@ -0,0 +1,23 @@ +import setuptools + +with open("README.md", "r") as fh: + long_description = fh.read() + +setuptools.setup( + name="tiffexplore", + packages=["tiffexplore"], + version="2021.06.0", + author="Wim Pomp", + author_email="wimpomp@gmail.com", + description="Explore a tiff structure.", + long_description=long_description, + long_description_content_type="text/markdown", + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: OS Independent", + ], + python_requires='>=3.7', + install_requires=['numpy', 'tifffile', 'PyQt5'], + scripts=['bin/tiffexplore'], +) diff --git a/tiffexplore/__init__.py b/tiffexplore/__init__.py new file mode 100644 index 0000000..9a70d65 --- /dev/null +++ b/tiffexplore/__init__.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 + +from PyQt5 import QtCore, QtWidgets, QtGui +from os.path import isfile, basename +from sys import argv + +if __package__ is None: + import tiffread +else: + from . import tiffread + + +class UiMainWindow(object): + def setupUi(self, MainWindow): + MainWindow.resize(800, 600) + self.centralwidget = QtWidgets.QWidget(MainWindow) + self.centrallayout = QtWidgets.QHBoxLayout(self.centralwidget) + self.scrollArea = QtWidgets.QScrollArea(self.centralwidget) + self.scrollArea.setFixedWidth(150) + self.scrollArea.setMinimumHeight(200) + self.scrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) + self.scrollArea.setWidgetResizable(True) + self.leftcolumnWidget = QtWidgets.QWidget() + self.leftcolumnWidget.setFixedWidth(130) + self.leftcolumnWidget.setMinimumHeight(200) + self.verticalLayoutWidget = QtWidgets.QWidget(self.leftcolumnWidget) + self.verticalLayoutWidget.setFixedWidth(130) + self.verticalLayoutWidget.setMinimumHeight(200) + self.leftcolumn = QtWidgets.QVBoxLayout(self.verticalLayoutWidget) + self.leftcolumn.setContentsMargins(0, 0, 0, 0) + self.scrollArea.setWidget(self.leftcolumnWidget) + self.middlecolumnWidget = QtWidgets.QWidget() + self.middlecolumn = QtWidgets.QVBoxLayout(self.middlecolumnWidget) + self.middlecolumn.setContentsMargins(0, 0, 0, 0) + self.properties = QtWidgets.QTextEdit(self.centralwidget) + self.middlecolumn.addWidget(self.properties) + self.rightcolumnWidget = QtWidgets.QWidget() + self.rightcolumn = QtWidgets.QVBoxLayout(self.rightcolumnWidget) + self.rightcolumn.setContentsMargins(0, 0, 0, 0) + self.binary = QtWidgets.QTextEdit(self.rightcolumnWidget) + self.rightcolumn.addWidget(self.binary) + self.image = QtWidgets.QLabel(self.rightcolumnWidget) + self.image.setMinimumWidth(200) + self.image.setMinimumHeight(200) + self.rightcolumn.addWidget(self.image) + MainWindow.setCentralWidget(self.centralwidget) + self.menubar = QtWidgets.QMenuBar(MainWindow) + self.menubar.setGeometry(QtCore.QRect(0, 0, 800, 22)) + self.menubar.setObjectName("menubar") + self.menuFile = QtWidgets.QMenu(self.menubar) + self.menuFile.setObjectName("menuFile") + MainWindow.setMenuBar(self.menubar) + self.statusbar = QtWidgets.QStatusBar(MainWindow) + self.statusbar.setObjectName("statusbar") + MainWindow.setStatusBar(self.statusbar) + self.actionOpen = QtWidgets.QAction(MainWindow) + self.actionOpen.setObjectName("actionOpen") + self.menuFile.addAction(self.actionOpen) + self.menubar.addAction(self.menuFile.menuAction()) + self.centrallayout.addWidget(self.scrollArea) + self.centrallayout.addWidget(self.middlecolumnWidget) + self.centrallayout.addWidget(self.rightcolumnWidget) + self.centralwidget.setLayout(self.centrallayout) + self.retranslateUi(MainWindow) + QtCore.QMetaObject.connectSlotsByName(MainWindow) + + def retranslateUi(self, MainWindow): + _translate = QtCore.QCoreApplication.translate + MainWindow.setWindowTitle(_translate("MainWindow", "TiffExp")) + self.menuFile.setTitle(_translate("MainWindow", "File")) + self.actionOpen.setText(_translate("MainWindow", "Open")) + self.actionOpen.setShortcut(_translate("MainWindow", "Ctrl+O")) + + +class PaintBox(QtWidgets.QWidget): + def drawText(self, qp, rect, text): + qp.setPen(QtGui.QColor('black')) + qp.setFont(QtGui.QFont('Decorative', 10 if rect[3] > 20 else rect[3] // 2)) + qp.drawText(QtCore.QRect(*rect), QtCore.Qt.AlignCenter, text) + + def drawRectangle(self, qp, rect, color): + color = QtGui.QColor(color) if isinstance(color, str) else QtGui.QColor(*color) + qp.setPen(QtGui.QColor('gray')) + qp.setBrush(color) + qp.drawRect(*rect) + +class Legend(PaintBox): + def __init__(self, parent): + self.parent = parent + self.color = {'header': 'red', 'ifd': 'cyan', 'tagdata': 'lightgreen', 'image': 'yellow', 'empty': 'white', + 'shared tagdata': 'green', 'shared image': 'orange', 'unknown': 'gray'} + super().__init__() + self.setFixedHeight(120) + self.show() + + def paintEvent(self, *args, **kwargs): + qp = QtGui.QPainter() + qp.begin(self) + for i, (key, value) in enumerate(self.color.items()): + self.drawRectangle(qp, (0, 15*i, 125, 15), value) + self.drawText(qp, (0, 15*i, 125, 15), key) + qp.end() + + +class Bar(PaintBox): + def __init__(self, parent): + self.parent = parent + self.color = {'header': 'red', 'ifd': 'cyan', 'tagdata': 'lightgreen', 'image': 'yellow', 'empty': 'white', + 'HEADER': 'red', 'IFD': 'blue', 'TAGDATA': 'green', 'IMAGE': 'orange', 'EMPTY': 'white'} + self.tiff = None + self.bar = tiffread.assignments() + super().__init__() + self.setFixedWidth(150) + self.show() + + def new_file(self): + self.tiff = self.parent.tiff + self.bar = tiffread.assignments() if self.tiff is None else self.get_bar() + self.parent.leftcolumnWidget.setFixedHeight(self.bar.max_addr) + self.parent.verticalLayoutWidget.setFixedHeight(self.bar.max_addr) + + def paintEvent(self, *args, **kwargs): + qp = QtGui.QPainter() + qp.begin(self) + self.parent.leftcolumnWidget.setFixedHeight(self.bar.max_addr) + self.parent.verticalLayoutWidget.setFixedHeight(self.bar.max_addr) + for key, value in self.bar.items(): + self.drawRectangle(qp, (0, value[0], 125, value[1]), self.color.get(key[0], "gray")) + self.drawText(qp, (0, value[0], 125, value[1]), ('_'.join(('{}',) * len(key))).format(*key).lower()) + qp.end() + + def get_bar(self): + min_size = 10 + scale = 100 + bar = tiffread.assignments() + pos = 0 + for item in self.tiff.addresses.get_assignments(): + key, value = item[0] + size = value[1] // scale if value[1] // scale >= min_size else min_size + if not (key[0].lower() == 'empty' and value[1] == 1): + if key[0].lower() == 'empty': + bar[('empty', value[0] + value[1] // 2)] = (pos, size) + else: + if len(item) > 1: + bar[(key[0].upper(),) + key[1:]] = (pos, size) + else: + bar[key] = (pos, size) + pos += size + + bar.max_addr = pos + return bar + + def mousePressEvent(self, event): + keys, vals = zip(*self.bar.get_assignment(event.localPos().y())) + key, val = keys[0], vals[0] + if key[0].lower() == 'empty': + addr = key[1] + else: + addr = self.tiff.addresses[(key[0].lower(),) + key[1:]] + addr = addr[0] + addr[1] // 2 + keys, addrs = zip(*self.parent.tiff.addresses.get_assignment(addr)) + addr = addrs[0] + + text = [('_'.join(('{}',) * len(key))).format(*key) for key in keys] + text.append('') + text.append(f'Adresses: {addr[0]} - {sum(addr)}') + text.append(f'Length: {addr[1]}') + text.append('') + if key[0].lower() == 'header': + text.append(f'File size: {len(self.tiff)}') + text.append(f'Byte order: {self.tiff.byteorder}') + text.append(f'Big tiff: {self.tiff.bigtiff}') + text.append(f'Tag size: {self.tiff.tagsize}') + text.append(f'Tag number format: {self.tiff.tagnoformat}') + text.append(f'Offset size: {self.tiff.offsetsize}') + text.append(f'Offset format: {self.tiff.offsetformat}') + text.append(f'First ifd offset: {self.tiff.offset}') + if key[0].lower() == 'ifd': + text.extend([f'{k}: {v}' for k, v in self.tiff.tags[key[1]].items()]) + if key[1] < self.tiff.nifds-1: + text.append(f'Next ifd offset: {self.tiff.addresses[("ifd", key[1] + 1)][0]}') + if key[0].lower() == 'tagdata': + text.append(f'{key[2]}: {self.tiff.tags[key[1]][key[2]]}') + if key[0].lower() == 'image': + self.parent.setImage(key[1], key[2]) + else: + self.parent.setImage() + self.parent.properties.setText('\n'.join(text)) + self.parent.binary.setText(''.join([chr(i) for i in self.tiff[addr[0]:addr[0]+addr[1]]])) + + +class App(QtWidgets.QMainWindow, UiMainWindow): + def __init__(self, tiff=None): + super().__init__() + self.tiff = None + self.setupUi(self) + self.bar = Bar(self) + self.leftcolumn.addWidget(self.bar) + self.legend = Legend(self) + self.middlecolumn.addWidget(self.legend) + self.actionOpen.triggered.connect(self.openDialog) + self.open(tiff) + self.show() + + def setImage(self, *args): + if len(args): + im = self.tiff.asarray(*args) + if im.ndim == 3: + im = im[0] + if im.max() - im.min() > 0: + im = (255 * ((im - im.min()) / (im.max() - im.min()))).astype('uint8') + shape = im.shape + im = QtGui.QImage(im, im.shape[1], im.shape[0], QtGui.QImage.Format_Grayscale8) + f = min([a / b for a, b in zip((self.image.height(), self.image.width()), shape)]) + pix = QtGui.QPixmap(im).scaled(f * shape[1], f * shape[0]) + else: + pix = QtGui.QPixmap() + self.image.setPixmap(pix) + + def openDialog(self): + file, _ = QtWidgets.QFileDialog.getOpenFileName(self, + "Open config file", "", "TIFF Files (*.tif *.tiff);;All Files (*)", + options=(QtWidgets.QFileDialog.Options() | QtWidgets.QFileDialog.DontUseNativeDialog)) + self.open(file) + + def open(self, file): + if file is not None and isfile(file): + if self.tiff is not None: + self.tiff.close() + self.tiff = tiffread.tiff(file) + self.bar.new_file() + self.setWindowTitle(f'TiffExp: {basename(self.tiff.file)}') + + def closeEvent(self, *args, **kwargs): + if self.tiff is not None: + self.tiff.close() + + +def main(): + app = QtWidgets.QApplication([]) + w = App(argv[1]) if len(argv) > 1 else App() + exit(app.exec()) + + +if __name__ == '__main__': + main() diff --git a/tiffexplore/tiffread.py b/tiffexplore/tiffread.py new file mode 100644 index 0000000..85f17a7 --- /dev/null +++ b/tiffexplore/tiffread.py @@ -0,0 +1,158 @@ +import struct +import tifffile +import numpy as np + + +class tiff(): + def __init__(self, file): + self.file = file + self.fh = open(file, 'rb') + self.tiff = tifffile.TiffFile(self.file) + self.tags = [] + self.get_file_len() + self.addresses = assignments(len(self)) + nifd = self.read_header() + i = 0 + while nifd: + nifd = self.read_ifd(nifd, i) + i += 1 + self.nifds = i + for i, tags in enumerate(self.tags): + if 273 in tags and 279 in tags: + for j, a in enumerate(zip(tags[273][-1], tags[279][-1])): + self.addresses[('image', i, j)] = a + + def get_file_len(self): + self.fh.seek(0, 2) + self.len = self.fh.tell() + + def __len__(self): + return self.len + + def asarray(self, page, segment): + return [d for d in zip(self.tiff.pages[page].segments(), range(segment + 1))][-1][0][0].squeeze() + + def read_header(self): + self.fh.seek(0) + self.byteorder = {b'II': '<', b'MM': '>'}[self.fh.read(2)] + self.bigtiff = {42: False, 43: True}[struct.unpack(self.byteorder + 'H', self.fh.read(2))[0]] + if self.bigtiff: + self.tagsize = 20 + self.tagnoformat = 'Q' + self.offsetsize = struct.unpack(self.byteorder + 'H', self.fh.read(2))[0] + self.offsetformat = {8: 'Q', 16: '2Q'}[self.offsetsize] + assert struct.unpack(self.byteorder + 'H', self.fh.read(2))[0] == 0, 'Not a TIFF-file' + self.offset = struct.unpack(self.byteorder + self.offsetformat, self.fh.read(self.offsetsize))[0] + else: + self.tagsize = 12 + self.tagnoformat = 'H' + self.offsetformat = 'I' + self.offsetsize = 4 + self.offset = struct.unpack(self.byteorder + self.offsetformat, self.fh.read(self.offsetsize))[0] + self.addresses[('header',)] = (0, 4 + self.bigtiff * 4 + self.offsetsize) + return self.offset + + def read_ifd(self, offset, idx): + """ Reads an IFD of the tiff file + wp@tl20200214 + """ + self.fh.seek(offset) + nTags = struct.unpack(self.byteorder + self.tagnoformat, self.fh.read(struct.calcsize(self.tagnoformat)))[0] + # print('Found {} tags'.format(nTags)) + assert nTags < 4096, 'Too many tags' + + length = 8 if self.bigtiff else 2 + length += nTags * self.tagsize + self.offsetsize + + tags = {} + for i in range(nTags): + pos = offset + struct.calcsize(self.tagnoformat) + self.tagsize * i + self.fh.seek(pos) + + code, ttype = struct.unpack(self.byteorder + 'HH', self.fh.read(4)) + count = struct.unpack(self.byteorder + self.offsetformat, self.fh.read(self.offsetsize))[0] + dtype = tifffile.TIFF.DATA_FORMATS[ttype] + dtypelen = struct.calcsize(dtype) + + toolong = struct.calcsize(dtype) * count > self.offsetsize + if toolong: + caddr = struct.unpack(self.byteorder + self.offsetformat, self.fh.read(self.offsetsize))[0] + self.addresses[('tagdata', idx, code)] = (caddr, dtypelen * count) + cp = self.fh.tell() + self.fh.seek(caddr) + else: + caddr = self.fh.tell() + + if ttype == 1: + value = self.fh.read(count) + elif ttype == 2: + value = self.fh.read(count).decode('ascii').rstrip('\x00') + elif ttype == 5: + value = [struct.unpack(self.byteorder + dtype, self.fh.read(dtypelen)) for _ in range(count)] + else: + value = [struct.unpack(self.byteorder + dtype, self.fh.read(dtypelen))[0] for _ in range(count)] + + if toolong: + self.fh.seek(cp) + + tags[code] = (ttype, caddr, dtypelen*count, value) + + nifd = struct.unpack(self.byteorder + self.tagnoformat, self.fh.read(struct.calcsize(self.tagnoformat)))[0] + self.fh.seek(offset) + self.addresses[('ifd', idx)] = (offset, 2 * struct.calcsize(self.tagnoformat) + nTags * self.tagsize) + self.tags.append(tags) + return nifd + + def __getitem__(self, item): + if not isinstance(item, slice): + item = slice(item, item+1) + start = 0 if item.start is None or item.start < 0 else item.start + step = item.step or 1 + stop = len(self) if item.stop is None or item.stop > len(self) else item.stop + self.fh.seek(start) + return self.fh.read(stop - start)[::step] + + def __enter__(self): + return self + + def __exit__(self, *args, **kwargs): + self.tiff.close() + self.close() + + def close(self, *args, **kwargs): + self.fh.close() + + def get_bytes(self, part): + offset, length = self.addresses[part] + self.fh.seek(offset) + return self.fh.read(length) + + +class assignments(dict): + def __init__(self, max_addr=0, *args, **kwargs): + self.max_addr = max_addr + super().__init__(*args, **kwargs) + + def get_assignment(self, addr): + keys, values = zip(*self.items()) + offsets, lengths = zip(*values) + offset_arr = np.array(offsets) + offset_len = offset_arr + lengths + nearest_addr = np.max(offset_arr[offset_arr <= addr]) + idxs = np.where(offset_arr == nearest_addr)[0] + if addr < nearest_addr + lengths[idxs[0]]: + return [(keys[idx], self[keys[idx]]) for idx in idxs] + else: + previous_addr = np.max(offset_len[offset_len <= addr]) + next_addr = offset_arr[offset_arr > addr] + length = np.min(next_addr) - previous_addr if len(next_addr) else self.max_addr - previous_addr + return [(('empty',), (previous_addr, length))] + + def get_assignments(self, start=0, end=-1): + addr = start + if end == -1: + end = self.max_addr + while addr < end: + item = self.get_assignment(addr) + addr = sum(item[0][1]) + yield item