Skip to content

Instantly share code, notes, and snippets.

@alexllc
Last active June 8, 2025 21:02
Show Gist options
  • Save alexllc/4e09f64b03c757bd3092e0e2fadbe556 to your computer and use it in GitHub Desktop.
Save alexllc/4e09f64b03c757bd3092e0e2fadbe556 to your computer and use it in GitHub Desktop.
Processing images from Perkin Elmer Vectra scanner (.qptiff) into OpenSlide compatible .tiff files
Display the source blob
Display the rendered blob
Raw
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Processing images from Perkin Elmer Vectra scanner (.qptiff) into OpenSlide compatible .tiff files\n",
"\n",
"This notebook documents how I imported `.qptiff` images and output it as Aperio-like `.tiff` files.\n",
"I will be uising the `tifffile`, `vips`, and `tifftools` to complete this process. `tifffile` and `tifftools` can be installed through `pip`, while `vips` can be installed with Linux package manager. On RHEL8, the commands are:\n",
"```bash\n",
"sudo yum install http://rpms.remirepo.net/enterprise/remi-release-8.rpm\n",
"sudo yum install vips vips-devel vips-tools\n",
"```"
]
},
{
"cell_type": "code",
"execution_count": 13,
"metadata": {},
"outputs": [],
"source": [
"from tifffile import *\n",
"import os\n",
"import numpy as np"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Import qptiff image and examine its metadata\n",
".qptiff has its metadata organized in an XML file, and in order to spoof an Aperio-like .tiff file, we need to at least have pixel per micron and magnification information, which we will extract in this section."
]
},
{
"cell_type": "code",
"execution_count": 11,
"metadata": {},
"outputs": [],
"source": [
"# import qptiff and write as openslide compatible tiff\n",
"# change your directory here\n",
"dat_path = '/home/alau/dev/tool/dat/cca_qptiff/237568.qptiff'\n",
"out_path = '/home/alau/dev/tool/dat/cca_tiff'\n",
"\n",
"# read qptiff with tifffile.imread\n",
"image_stack = imread(dat_path)\n",
"tif_img_path = os.path.join(out_path, \"test\" + \".tif\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"We can see that our image_stack is a numpy array with dat type uint8."
]
},
{
"cell_type": "code",
"execution_count": 14,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"array([[[245, 236, 237],\n",
" [237, 236, 237],\n",
" [239, 236, 237],\n",
" ...,\n",
" [251, 228, 237],\n",
" [252, 228, 237],\n",
" [253, 228, 237]],\n",
"\n",
" [[248, 236, 237],\n",
" [240, 236, 237],\n",
" [242, 236, 237],\n",
" ...,\n",
" [251, 228, 237],\n",
" [252, 228, 237],\n",
" [253, 228, 237]],\n",
"\n",
" [[251, 236, 237],\n",
" [243, 236, 237],\n",
" [244, 236, 237],\n",
" ...,\n",
" [251, 228, 237],\n",
" [252, 228, 237],\n",
" [253, 228, 237]],\n",
"\n",
" ...,\n",
"\n",
" [[248, 202, 223],\n",
" [244, 203, 223],\n",
" [242, 204, 223],\n",
" ...,\n",
" [247, 238, 242],\n",
" [247, 238, 242],\n",
" [246, 238, 242]],\n",
"\n",
" [[249, 202, 223],\n",
" [245, 203, 223],\n",
" [243, 204, 223],\n",
" ...,\n",
" [246, 238, 243],\n",
" [245, 238, 243],\n",
" [245, 238, 243]],\n",
"\n",
" [[251, 202, 223],\n",
" [246, 202, 223],\n",
" [243, 204, 223],\n",
" ...,\n",
" [245, 238, 243],\n",
" [244, 238, 243],\n",
" [244, 238, 243]]], dtype=uint8)"
]
},
"execution_count": 14,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"image_stack"
]
},
{
"cell_type": "code",
"execution_count": 17,
"metadata": {},
"outputs": [],
"source": [
"# This function extracts XML metadata into a dictionary so it's easier for us to view\n",
"from collections import defaultdict\n",
"\n",
"def etree_to_dict(t):\n",
" d = {t.tag: {} if t.attrib else None}\n",
" children = list(t)\n",
" if children:\n",
" dd = defaultdict(list)\n",
" for dc in map(etree_to_dict, children):\n",
" for k, v in dc.items():\n",
" dd[k].append(v)\n",
" d = {t.tag: {k:v[0] if len(v) == 1 else v for k, v in dd.items()}}\n",
" if t.attrib:\n",
" d[t.tag].update(('@' + k, v) for k, v in t.attrib.items())\n",
" if t.text:\n",
" text = t.text.strip()\n",
" if children or t.attrib:\n",
" if text:\n",
" d[t.tag]['#text'] = text\n",
" else:\n",
" d[t.tag] = text\n",
" return d\n"
]
},
{
"cell_type": "code",
"execution_count": 18,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'PerkinElmer-QPI-ImageDescription': {'AcquisitionSoftware': 'PhenoImagerHT '\n",
" '2.0.0',\n",
" 'Barcode': None,\n",
" 'CameraName': 'C11440-50U-53',\n",
" 'CameraSettings': {'Binning': '1',\n",
" 'BitDepth': '12',\n",
" 'Gain': '1',\n",
" 'OffsetCounts': '0',\n",
" 'Orientation': 'Normal',\n",
" 'ROI': {'Height': '1440',\n",
" 'Width': '1920',\n",
" 'X': '0',\n",
" 'Y': '0'}},\n",
" 'CameraType': 'HamamatsuC11440',\n",
" 'ComputerName': 'POLARISNEW',\n",
" 'DescriptionVersion': '5',\n",
" 'ExposureTime': '10000',\n",
" 'Identifier': '25d71736-eb63-4a72-885e-59dea2f408e5',\n",
" 'ImageType': 'FullResolution',\n",
" 'InstrumentType': 'Polaris',\n",
" 'IsUnmixedComponent': 'False',\n",
" 'Objective': '20x',\n",
" 'OperatorName': 'scopecore',\n",
" 'ScanProfile': {'root': {'@ref': 'ref0',\n",
" '@s_v': '1',\n",
" 'AlgorithmPath': None,\n",
" 'BarcodeFormats': 'NoFormat',\n",
" 'CameraSettings': {'@ref': 'ref1',\n",
" 'Binning': '1',\n",
" 'Bits': '12',\n",
" 'FrameAverageCount': '1',\n",
" 'Gain': '1',\n",
" 'MirrorImage': 'false',\n",
" 'Offset': '0',\n",
" 'ROI': '0, '\n",
" '0, '\n",
" '1920, '\n",
" '1440',\n",
" 'ReadoutSpeed': 'Auto',\n",
" 'RotateImage': 'false',\n",
" 'V': '3'},\n",
" 'Compression': 'JPEG',\n",
" 'CoverslipThickness': 'Normal',\n",
" 'FieldSaturationProtectionType': 'All',\n",
" 'JPEGQuality': '75',\n",
" 'Mode': 'im_Brightfield',\n",
" 'MsiBands': {'@ref': 'ref45',\n",
" 'MsiBands-i': {'@ref': 'ref46',\n",
" '@subtype': 'SpectralSegmentBasis',\n",
" 'BandIndex': '1',\n",
" 'ExcitedFilterPair': {'@ref': 'ref47',\n",
" 'BandIndex': '0',\n",
" 'FilterPair': {'@ref': 'ref7'},\n",
" 'V': '1'},\n",
" 'Exposure': {'@dim': '14',\n",
" '@ref': 'ref49',\n",
" 'Exposure-i': 'AAAgQQAAIEEAACBBAAAgQQAAIEEAACBBAAAgQQAAIEEAACBBAAAgQQAAIEEAACBBAAAgQQAAIEE='},\n",
" 'TunableFilterEngaged': 'true',\n",
" 'V': ['1',\n",
" '3'],\n",
" 'WavelengthGroup': {'@ref': 'ref48',\n",
" 'TunableFilterBandwidth': 'Broad',\n",
" 'TunableFilterStates': None,\n",
" 'V': '1'}}},\n",
" 'MsiFocusBand': 'Green',\n",
" 'MsiResolution': {'@ref': 'ref3',\n",
" 'Binning': '2',\n",
" 'Magnification': '20',\n",
" 'ObjectiveName': '20x',\n",
" 'PixelSizeMicrons': '0.5',\n",
" 'V': '1'},\n",
" 'Name': '40x',\n",
" 'OpalKitType': 'None',\n",
" 'OverviewBand': None,\n",
" 'SampleIsTMA': 'false',\n",
" 'ScanBands': {'@ref': 'ref4',\n",
" 'ScanBands-i': {'@ref': 'ref5',\n",
" '@subtype': 'SpectralSegmentBasis',\n",
" 'BandIndex': '0',\n",
" 'ExcitedFilterPair': {'@ref': 'ref6',\n",
" 'BandIndex': '0',\n",
" 'FilterPair': {'@ref': 'ref7',\n",
" 'EmissionFilter': {'@ref': 'ref8',\n",
" 'Bands': {'@ref': 'ref12',\n",
" 'Bands-i': [{'@ref': 'ref13',\n",
" 'AutoExposeType': 'aet_Brightfield',\n",
" 'FactoryDefined': 'true',\n",
" 'HomeWavelength': '540',\n",
" 'Name': 'Color',\n",
" 'V': '2',\n",
" 'WavelengthGroup': {'@ref': 'ref14',\n",
" 'TunableFilterBandwidth': 'Narrow',\n",
" 'TunableFilterStates': {'@dim': '5',\n",
" '@ref': 'ref15',\n",
" 'TunableFilterStates-i': [{'@ref': 'ref16',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '460'},\n",
" {'@ref': 'ref17',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '500'},\n",
" {'@ref': 'ref18',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '540'},\n",
" {'@ref': 'ref19',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '580'},\n",
" {'@ref': 'ref20',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '620'}]},\n",
" 'V': '1'}},\n",
" {'@ref': 'ref21',\n",
" 'AutoExposeType': 'aet_Brightfield',\n",
" 'FactoryDefined': 'true',\n",
" 'HomeWavelength': '540',\n",
" 'Name': 'Multispectral',\n",
" 'V': '2',\n",
" 'WavelengthGroup': {'@ref': 'ref22',\n",
" 'TunableFilterBandwidth': 'Narrow',\n",
" 'TunableFilterStates': {'@dim': '14',\n",
" '@ref': 'ref23',\n",
" 'TunableFilterStates-i': [{'@ref': 'ref24',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '440'},\n",
" {'@ref': 'ref25',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '460'},\n",
" {'@ref': 'ref26',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '480'},\n",
" {'@ref': 'ref27',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '500'},\n",
" {'@ref': 'ref28',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '520'},\n",
" {'@ref': 'ref29',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '540'},\n",
" {'@ref': 'ref30',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '560'},\n",
" {'@ref': 'ref31',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '580'},\n",
" {'@ref': 'ref32',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '600'},\n",
" {'@ref': 'ref33',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '620'},\n",
" {'@ref': 'ref34',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '640'},\n",
" {'@ref': 'ref35',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '660'},\n",
" {'@ref': 'ref36',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '680'},\n",
" {'@ref': 'ref37',\n",
" 'Retardances': None,\n",
" 'V': '1',\n",
" 'Wavelength': '700'}]},\n",
" 'V': '1'}}]},\n",
" 'FixedFilter': {'@ref': 'ref9',\n",
" 'Manufacturer': 'None',\n",
" 'Name': 'Brightfield',\n",
" 'PartNumber': 'None',\n",
" 'TransmissionBands': {'@dim': '1',\n",
" '@ref': 'ref10',\n",
" 'TransmissionBands-i': {'@ref': 'ref11',\n",
" 'Max': '700',\n",
" 'Min': '440',\n",
" 'V': '1'}},\n",
" 'V': '2'},\n",
" 'V': '1'},\n",
" 'ExcitationFilter': {'@ref': 'ref38',\n",
" 'BandNames': {'@dim': '1',\n",
" '@ref': 'ref39',\n",
" 'BandNames-i': 'None'},\n",
" 'FixedFilter': {'@ref': 'ref40',\n",
" 'Manufacturer': 'None',\n",
" 'Name': 'BrightField',\n",
" 'PartNumber': 'None',\n",
" 'TransmissionBands': {'@dim': '1',\n",
" '@ref': 'ref41',\n",
" 'TransmissionBands-i': {'@ref': 'ref42',\n",
" 'Max': '700',\n",
" 'Min': '440',\n",
" 'V': '1'}},\n",
" 'V': '2'},\n",
" 'V': '2'},\n",
" 'FactoryDefined': 'true',\n",
" 'V': '3'},\n",
" 'V': '1'},\n",
" 'Exposure': {'@dim': '5',\n",
" '@ref': 'ref44',\n",
" 'Exposure-i': 'AAAgQQAAIEEAACBBAAAgQQAAIEE='},\n",
" 'TunableFilterEngaged': 'true',\n",
" 'V': ['1',\n",
" '3'],\n",
" 'WavelengthGroup': {'@ref': 'ref43',\n",
" 'TunableFilterBandwidth': 'Broad',\n",
" 'TunableFilterStates': None,\n",
" 'V': '1'}}},\n",
" 'ScanColorTable': {'@ref': 'ref50'},\n",
" 'ScanEntireCoverslipRegion': 'false',\n",
" 'ScanFocusBand': 'Green',\n",
" 'ScanResolution': {'@ref': 'ref2',\n",
" 'Binning': '1',\n",
" 'Magnification': '40',\n",
" 'ObjectiveName': '20x',\n",
" 'PixelSizeMicrons': '0.25',\n",
" 'V': '1'},\n",
" 'ScanSaturationProtectionType': 'None',\n",
" 'SelectionStrategy': None,\n",
" 'UnmixingLibrary': None,\n",
" 'V': '10'}},\n",
" 'SignalUnits': '64',\n",
" 'SlideID': '237568',\n",
" 'StudyName': '20230920 Ping - Mohammed',\n",
" 'ValidationCode': 'FF80DFDBADB931979B4C90A9BB1B8CB27D06EFE1509807575346A0FD4B8E22C7'}}\n"
]
}
],
"source": [
"from xml.etree import ElementTree\n",
"from tifffile import TiffFile\n",
"\n",
"from pprint import pprint\n",
"\n",
"with TiffFile(dat_path) as tif:\n",
" for page in tif.series[0].pages:\n",
" data = page.description\n",
" root = ElementTree.fromstring(data)\n",
" dict = etree_to_dict(root)\n",
" \n",
"pprint(dict)"
]
},
{
"cell_type": "code",
"execution_count": 20,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"The magnification is: 40, and the pixel size in microns is: 0.25\n"
]
}
],
"source": [
"mag = dict['PerkinElmer-QPI-ImageDescription']['ScanProfile']['root']['ScanResolution']['Magnification']\n",
"mpp = dict['PerkinElmer-QPI-ImageDescription']['ScanProfile']['root']['ScanResolution']['PixelSizeMicrons']\n",
"\n",
"print('The magnification is: {}, and the pixel size in microns is: {}'.format(mag, mpp))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"As we can see from the xml file, the magnification is \"40\" and the pixel per micron is \"0.25\". We will use these information to create a fake Aperio-like .tiff file."
]
},
{
"cell_type": "code",
"execution_count": 21,
"metadata": {},
"outputs": [],
"source": [
"# first write out the tiff for tiling\n",
"tiff_imwrite_res = os.path.join(out_path, 'tf_imwrite.tif')\n",
"imwrite(tiff_imwrite_res, image_stack, photometric='rgb', planarconfig='contig', metadata={'axes': 'YXC'})"
]
},
{
"cell_type": "code",
"execution_count": 22,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"0"
]
},
"execution_count": 22,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"tiff_pyramid_res = os.path.join(out_path, 'tf_pyramid.tif')\n",
"conversion_cmd_str = \" \".join([\"vips\", \"im_vips2tiff\", tiff_imwrite_res, tiff_pyramid_res + \":jpeg:95,tile:256x256,pyramid\"])\n",
"os.system(conversion_cmd_str) "
]
},
{
"cell_type": "code",
"execution_count": 25,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"0"
]
},
"execution_count": 25,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"# Spoof the openslide properties\n",
"metadata_str = \"\\\"Aperio |AppMag = {}|MPP = {}\\\"\".format(mag, mpp)\n",
"tag_cmd_str = \" \".join([\"tifftools\", \"set\", \"-y\", \"-s\", \"ImageDescription\", metadata_str, tiff_pyramid_res])\n",
"os.system(tag_cmd_str)"
]
},
{
"cell_type": "code",
"execution_count": 27,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"tifftools set -y -s ImageDescription \"Aperio Fake |AppMag = 40|MPP = 0.25\" /home/alau/dev/tool/dat/cca_tiff/tf_pyramid.tif\n"
]
}
],
"source": [
"print(tag_cmd_str)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "tiatoolbox",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.7"
}
},
"nbformat": 4,
"nbformat_minor": 2
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment