imageio.imread(): Retains the original grayscale values when reading a png file

199 views Asked by At

I would like to process a 12-bit grayscale image saved in png format. A monochromatic 12-bit camera was used as the signal source. The image file was created with NI Vision IMAQ Write File2.vi. The maximum pixel value is 419 counts. When I load the image with Python3 imageio-2.32.0 imageio.imread("testimagefile"), it seems that the image is scaled automatically, the maximum value is now 55019 counts. How can I keep the original grayscale value?

2

There are 2 answers

1
AlexK On

I finally got an answer on the NI-Forum. Many thanks to Andrew Dmitriev.

The NI's 16 bit PNG images stored together with sBIT. It means that the pixel's values are shifted to the highest bit (this is a part of PNG Standard, but most libraries aren't aware about this). Refer to http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html#C.sBIT

In additional, the Offset for signed representation stored in first two bytes of the scAl chunk. So, all what you need - just read "RAW" png data, then perform shift according sBIT value, then subtract offset taken from scAl chunk. That is all (if you want to get things done without "extra" DLL). Attached example for both I16 and U16 images.

By the way - there is a bug in PNG encoder from NI. Instead of logical shifting they performing rotating (in some strange form), so the less significant bits which supposed to be zeroes - are not zeroes, as result we have these strange values mentioned above (for example, when the range 0...1000 turned to 0...64062. It should be 0...64000 instead). But this is not a big problem, because these bits goes away after shifting back.

Also in Manual it wrongly stated that Use Bit Depth input is applicable to signed images only. On signed images this control will cut off negative values, but on unsigned images (when used together IMAQ Image Bit Depth) this will allow to save "native" 16 bit values, so the image can be opened in third-party libraries without conversion.

Here is the code using openCV by dgagnon05:

import cv2
import struct
import binascii
import numpy as np
from matplotlib import pyplot as plt

def __getSBit(bytes):
    bytes = bytes[8:]
    sBit = 0
    while bytes:
        length = struct.unpack('>I', bytes[:4])[0]
        bytes = bytes[4:]
        chunk_type = bytes[:4]
        bytes = bytes[4:]
        chunk_data = bytes[:length]
        bytes = bytes[length:]
        if chunk_type == b'sBIT':
            sBit = int.from_bytes(chunk_data, "big")
            break
        bytes = bytes[4:]
    return sBit

def __getOffset(bytes):
    bytes = bytes[8:]
    Offset = 0
    sBit = 0
    while bytes:
        length = struct.unpack('>I', bytes[:4])[0]
        bytes = bytes[4:]
        chunk_type = bytes[:4]
        bytes = bytes[4:]
        chunk_data = bytes[:length]
        bytes = bytes[length:]
        if chunk_type == b'scAl':
            chunk_data_part = chunk_data[:2]
            Offset = 65536 - int.from_bytes(chunk_data_part, "big")
            break
        bytes = bytes[4:]
    return Offset

def getSigBits(filename):
    with open(filename, 'rb') as f:
        bytes = f.read()
    return __getSBit(bytes)

def getOffset(filename):
    with open(filename, 'rb') as f:
        bytes = f.read()
    return __getOffset(bytes)

def shift_offset(image_src, shift, offset):
    height, width = image_src.shape
    counter=0;
    if shift<16:
        temp=np.zeros(image_src.shape,dtype=np.int16)
        temp=(image_src>> shift).astype(np.int16)
        temp=temp-offset
        return temp


def load_lv_i16(file_path):
    offset=getOffset(file_path)
    sigbits=getSigBits(file_path)
    image_src=cv2.imread(file_path, cv2.IMREAD_UNCHANGED)
    shift=16-sigbits
    image=shift_offset(image_src,shift,offset)
    return image

if __name__ == '__main__':
    file_path=r"LabVIEW_created_12bit_grayscale.png"
    image=load_lv_i16(file_path)

---------- UPDATE ----------

After answer from @Mark Setchell the better solution may be:

from pathlib import Path
import cv2
import numpy as np
from matplotlib import pyplot

file_path = 'LabVIEW_created_12bit_grayscale.png'
# Load PNG as bytes and find "sBIT" and NI-Vision "scAl" chunk
png = Path(file_path).read_bytes()
sBIToffset = png.find(b'sBIT')
scAl = png.find(b'scAl') # scAl is a constant offset

if sBIToffset>0:
    # 4 bytes before "sBIT" tell us how many values there are - could be 1-4 values
    nValues = int.from_bytes(png[sBIToffset-4:sBIToffset], byteorder="big")
    values  = list(png[sBIToffset+4:sBIToffset+4+nValues])

offset=0
if scAl>0:
    offset = 65536 - int.from_bytes(png[scAl+4:scAl+6], "big")

if len(values)==1:
    image_src=cv2.imread(file_path, cv2.IMREAD_UNCHANGED)
    shift = 16-values[0]
    if shift<16:
        temp=np.zeros(image_src.shape,dtype=np.int16)
        image=(image_src>> shift).astype(np.int16)
    if offset>0:
        image=image-offset
    print(cv2.minMaxLoc(image))
else:
    print("Seems not to be a greyscale image!")
12
Mark Setchell On

Updated Code

#!/usr/bin/env python3

from pathlib import Path

# Load PNG as bytes
png = Path('image.png').read_bytes()

# Find "sBIT" chunk
sBIToffset = png.find(b'sBIT')
if sBIToffset:
    # 4 bytes before "sBIT" tell us how many values there are - could be 1-4 values
    nValues = int.from_bytes(png[sBIToffset-4:sBIToffset], byteorder="big")
    values  = list(png[sBIToffset+4:sBIToffset+4+nValues])
    print(f'Found sBIT chunk at offset: {sBIToffset} with value(s): {values}')

# Find "scAl" chunk
scAloffset = png.find(b'scAl')
if scAloffset:
    scAl = int.from_bytes(png[scAloffset+4:scAloffset+6], byteorder="big")
    value = 65536 - scAl
    print(f'Found scAl chunk at offset: {scAloffset} with value: {value}')

Sample Output

Found sBIT chunk at offset: 37 with value(s): [11]
Found scAl chunk at offset: 64 with value: 1000

Original Answer

This is an alternative to the (IMHO really rather poor) implementation of finding the sBIT chunk and extracting the scale factors.

PNG chunks look like this:

enter image description here

Source: Wikipedia

The sBIT chunk looks like this in hex:

00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452  .PNG........IHDR
00000010: 0000 056c 0000 040e 1000 0000 003a b9f2  ...l.........:..
00000020: 5200 0000 0173 4249 5409 910d 6b0f 0000  R....sBIT...k...

In hex, the 73 42 49 54 (in the middle of the third line) represents sBIT, so the length is the 4 bytes before that, i.e. 00 00 00 01 and that means there is 1 value in the chunk - which is to be expected in a greyscale image. There might be up to 4 values depending on the image type (grey, grey+alpha, RGB, RGBA). That means only 1 byte is used in this image. That byte is 09 meaning the value is 9.

As such, you can more simply extract the sBIT(s) like this:

#!/usr/bin/env python3

from pathlib import Path

# Load PNG as bytes and find "sBIT" chunk
png = Path('120130.png').read_bytes()
sBIToffset = png.find(b'sBIT')
if sBIToffset:
    # 4 bytes before "sBIT" tell us how many values there are - could be 1-4 values
    nValues = int.from_bytes(png[sBIToffset-4:sBIToffset], byteorder="big")
    values  = list(png[sBIToffset+4:sBIToffset+4+nValues])
    print(f'Found sBIT chunk at offset: {sBIToffset} with value(s): {values}')

Note that you can detect the presence of rare chunks in a PNG file using:

pngcheck -vv SOMEIMAGE.PNG

You can also grep them:

grep sBIT SOMEIMAGE.PNG

I describe the other implementation as "rather poor" because it spends its time shuffling the entire content of the file around in memory and doesn't handle multiple significant bits for multi-channel images - it also reads the file twice and....

I am aware of that my code is also imperfect insofar as it doesn't check the CRC, but I didn't want to muddy the code. If checking the CRC is important, my answer here shows how to do that.