Search code examples
c++tifflibtiff

Read TIFF with non-standard bit depth (12-bit) in C++


I've been struggling for the last couple of days trying to find a way of reading TIF image files which contain non-standard depth (12-bit per pixel) channels coming from high-speed cameras. So far, I've tested OpenCV, libTIFF, and TinyTIFF without success (throw same error: only 8,16,24, or 32 bit TIFFs are supported).

I guess at this point, either I'll have to somehow read the file as a binary and work from there (no idea how) or use imagemagick's convert utility to set the channel's depth to 16-bit. I'd really like to avoid the latter, since I want to make my code as light-weight and self-contained as possible. I'm processing hundreds of thousands of images, thus reading them two times (one to convert, one to post-process) seems quite counter-productive. Any Ideas?

As an example, to reproduce the error (use opencv, and libtiff):

        TIFF* tif = TIFFOpen(imageName.c_str(), "r");

    // Create a matrix to hold the tif image in
    Mat image;

    // check the tif is open
    if (tif) {
        do {
            unsigned int width, height;
            uint32* raster;

            // get the size of the tiff
            TIFFGetField(tif, TIFFTAG_IMAGEWIDTH, &width);
            TIFFGetField(tif, TIFFTAG_IMAGELENGTH, &height);

            uint npixels = width * height; // get the total number of pixels

            raster = (uint32*)_TIFFmalloc(npixels * sizeof(uint32)); // allocate temp memory (must use the tiff library malloc)
            if (raster == NULL) // check the raster's memory was allocaed
            {
                TIFFClose(tif);
                throw bad_alloc();
            }

            // Check the tif read to the raster correctly
            if (!TIFFReadRGBAImage(tif, width, height, raster, 0))
            {
                TIFFClose(tif);
                throw runtime_error("Could not read the TIF appropriately");
            }

            image = Mat(width, height, CV_8UC4); // create a new matrix of w x h with 8 bits per channel and 4 channels (RGBA)

            // iterate over all the pixels of the tif
            for (uint x = 0; x < width; x++)
                for (uint y = 0; y < height; y++)
                {
                    uint32& TiffPixel = raster[y * width + x]; // read the current pixel of the TIF
                    Vec4b& pixel = image.at<Vec4b>(Point(y, x)); // read the current pixel of the matrix
                    pixel[0] = TIFFGetB(TiffPixel); // Set the pixel values as BGRA
                    pixel[1] = TIFFGetG(TiffPixel);
                    pixel[2] = TIFFGetR(TiffPixel);
                    pixel[3] = TIFFGetA(TiffPixel);
                }

            _TIFFfree(raster); // release temp memory
            // Rotate the image 90 degrees couter clockwise
            image = image.t();
            flip(image, image, 0);
            imshow("TIF Image", image); // show the image
            waitKey(0); // wait for anykey before displaying next
        } while (TIFFReadDirectory(tif)); // get the next tif
        TIFFClose(tif); // close the tif file
    }

And the input image is the following:

https://drive.google.com/file/d/15TR2mnczo0i6dRzmT1jzPIMoNH61DJi1/view?usp=sharing

EDIT 1

The camera model is the following:

https://photron.com/wp-content/uploads/2022/01/NOVA_4models_Rev.2022.01.11.pdf

All photographs come from the same Camera, with same bit-depth and appear to be uncompressed.

** TIFFINFO ** on a randomly selected tif outputs the following:

=== TIFF directory 0 ===
TIFF Directory at offset 0x22f5e (143198)
  Image Width: 258 Image Length: 370
  Bits/Sample: 12
  Compression Scheme: None
  Photometric Interpretation: min-is-black
  Orientation: row 0 top, col 0 lhs
  Samples/Pixel: 1
  Rows/Strip: 21
  Planar Configuration: single image plane

EDIT 2

I further tried ImageMagick's API, assuming that 'convert' can read TIFFs with arbitrary bitdepths:

// Create base image 
Image image;
Mat cvImage;
try{
    image.read(imageName);
    // Set the image type to TrueColor DirectClass representation.
    image.type(GrayscaleType);
    // Ensure that there is only one reference to underlying image 
    // If this is not done, then image pixels will not be modified.
    //image.modifyImage();

    // Allocate pixel view 
    Pixels view(image);

    // Set all pixels in region anchored at 38x36, with size 160x230 to green. 
    size_t columns = view.columns(); size_t rows = view.rows();

    cvImage = Mat(columns, rows, CV_8UC(1)); // create a new matrix of w x h with 8 bits per channel and 4 channels (RGBA)
    Quantum* pixels = view.get(0, 0, columns, rows);
    for (ssize_t row = 0; row < rows; ++row)
        for (ssize_t column = 0; column < columns; ++column)
        {
            uchar& pixel = cvImage.at<uchar>(cv::Point(column, row)); // read the current pixel of the matrix

            pixel = *pixels++; // Set the pixel values as BGRA
        }

    imshow("TIF Image", cvImage); // show the image
    cv::waitKey(0); // wait for anykey before displaying next
} catch (Magick::Exception& error_)
{
    cout << "Caught exception: " << error_.what() << endl;
} 

Unfortunately, the read method reads a zero size image. It doesn't crash even! So not luck yet.

EDIT 3: S§#TTY SOLUTION

Use

mogrify -format png *.tif

Solution

  • In order to read the data from your TIFF file without any library or heavy-weight tools, you need to understand it first. You can get the best insight using exiftool and tiffinfo (which is part of libtiff).

    First, exiftool gives:

    exiftool -v YOURFILE.TIF
    
      ExifToolVersion = 12.30
      FileName = C001H001S0011000001.tif
      Directory = .
      FileSize = 143480
      FileModifyDate = 1659126064
      FileAccessDate = 1659126099
      FileInodeChangeDate = 1659126064
      FilePermissions = 33188
      FileType = TIFF
      FileTypeExtension = TIF
      MIMEType = image/tiff
      ExifByteOrder = II
      + [IFD0 directory with 11 entries]
      | 0)  ImageWidth = 258
      | 1)  ImageHeight = 370
      | 2)  BitsPerSample = 12
      | 3)  Compression = 1
      | 4)  PhotometricInterpretation = 1
      | 5)  StripOffsets = 8 8135 16262 24389 32516 40643 48770 56897 65024 73151 81278 894[snip]
      | 6)  Orientation = 1
      | 7)  SamplesPerPixel = 1
      | 8)  RowsPerStrip = 21
      | 9)  StripByteCounts = 8127 8127 8127 8127 8127 8127 8127 8127 8127 8127 8127 8127 8[snip]
      | 10) PlanarConfiguration = 1
    

    The information following the + sign is what interests us.


    And then tiffinfo yields:

    tiffinfo -s YOURFILE.TIF
    
    === TIFF directory 0 ===
    TIFF Directory at offset 0x22f5e (143198)
      Image Width: 258 Image Length: 370
      Bits/Sample: 12
      Compression Scheme: None
      Photometric Interpretation: min-is-black
      Orientation: row 0 top, col 0 lhs
      Samples/Pixel: 1
      Rows/Strip: 21
      Planar Configuration: single image plane
      18 Strips:
          0: [       8,     8127]
          1: [    8135,     8127]
          2: [   16262,     8127]
          3: [   24389,     8127]
          4: [   32516,     8127]
          5: [   40643,     8127]
          6: [   48770,     8127]
          7: [   56897,     8127]
          8: [   65024,     8127]
          9: [   73151,     8127]
         10: [   81278,     8127]
         11: [   89405,     8127]
         12: [   97532,     8127]
         13: [  105659,     8127]
         14: [  113786,     8127]
         15: [  121913,     8127]
         16: [  130040,     8127]
         17: [  138167,     5031]
    

    Next we need to look at your file in hex, which we can do by dumping with xxd. If you don't have xxd, you can use this website:

    xxd YOURFILE.TIF
    
    00000000: 4949 2a00 5e2f 0200 0a00 9109 c09f 0990  II*.^/..........
    00000010: a10a 009d 08d0 9309 b08e 0980 9409 5099  ..............P.
    00000020: 0a10 9508 b091 0940 8f08 b08e 0960 9209  .......@.....`..
    00000030: 3097 0900 9a09 209b 09d0 9c09 a0ab 0a00  0..... .........
    00000040: 9909 408d 08a0 9a08 708c 08d0 9a09 e095  [email protected].......
    

    Then we need to look at the first IFD "Image File Directory" which starts at byte 143199:

    tail -c +143199 C001H001S0011000001.tif | xxd
    
    00000000: 0b00 0001 0300 0100 0000 0201 0000 0101  ................
    00000010: 0300 0100 0000 7201 0000 0201 0300 0100  ......r.........
    00000020: 0000 0c00 0000 0301 0300 0100 0000 0100  ................
    00000030: 0000 0601 0300 0100 0000 0100 0000 1101  ................
    00000040: 0400 1200 0000 e82f 0200 1201 0300 0100  ......./........
    00000050: 0000 0100 0000 1501 0300 0100 0000 0100  ................
    00000060: 0000 1601 0300 0100 0000 1500 0000 1701  ................
    00000070: 0400 1200 0000 3030 0200 1c01 0300 0100  ......00........
    00000080: 0000 0100 0000 0000 0000 0800 0000 c71f  ................
    00000090: 0000 863f 0000 455f 0000 047f 0000 c39e  ...?..E_........
    000000a0: 0000 82be 0000 41de 0000 00fe 0000 bf1d  ......A.........
    000000b0: 0100 7e3d 0100 3d5d 0100 fc7c 0100 bb9c  ..~=..=]...|....
    000000c0: 0100 7abc 0100 39dc 0100 f8fb 0100 b71b  ..z...9.........
    000000d0: 0200 bf1f 0000 bf1f 0000 bf1f 0000 bf1f  ................
    000000e0: 0000 bf1f 0000 bf1f 0000 bf1f 0000 bf1f  ................
    000000f0: 0000 bf1f 0000 bf1f 0000 bf1f 0000 bf1f  ................
    00000100: 0000 bf1f 0000 bf1f 0000 bf1f 0000 bf1f  ................
    00000110: 0000 bf1f 0000 a713 0000                 ..........
    

    So, let's look at the first xxd dump:

    • bytes 0, 1 - they make II for Intel byte-order, so all numbers in the file are little-endian. This will not change amongst your images.

    • bytes 2, 3 - they make 42 which is the TIFF version. These will never change.

    • bytes 4, 5, 6, 7 make the offset 0x22f5e, or 141936 to the first IFD "Image File Directory" so we need to go there to find the details about your image

    • we'll come back to byte 8 onwards in a moment...

    So now we need to jump to offset 141937 to find the first IFD and this is where the second xxd dump starts.

    It begins with 0b which means there are 11 tags, which is what exiftool is showing you with tags 0 through 10 after the + sign.

    Each tag is 12 bytes, and there is a list of them here.

    A few interesting ones are:

    • 0x100 - width
    • 0x101 - length
    • 0x102 - bits per sample
    • 0x104 - compression

    The first tag tells you the width is 258 (0x102), the second tells you the height is 370 (0x172) and the third is 0c which tells you there are 12 bits/pixel. And so on. All this should not change between your images.

    The most interesting is the 6th tag which are the strip offsets of 8, 8135...

    That means that if you go right back to the start and to byte 8, that corresponds to your first pixel.


    TLDR;

    So, you can actually ignore everything above if you just want to load the image into an OpenCV Mat. All you need to do is create an empty uint16 Mat with width=258 and height=370. Then seek() to byte 8 of your file (opened in binary mode) and repeat the following till you have read 258x370 pixels:

    read 3 bytes, i.e. 24 bits, i.e. 2 pixels of 12-bits each
    take 1st byte shifted left by 4 bits and top half of 2nd byte and put in next pixel of `Mat`
    take bottom half of 2nd byte shifted left by 8 bits and OR it with 3rd byte and put result in next pixel of `Mat`.
    

    If you think about it, you have 258x370 pixels with 12 bits each, or 1.5 bytes, that makes 143190 bytes, starting at byte 8 and ending just before IFD0 at byte offset 143199.


    If you want to check your decoding, you can output your file as a NetPBM PGM format with ImageMagick like this:

    magick YOURIMAGE.TIF -compress none PGM:-
    
    P2
    258 370
    65535
    2561 2321 2497 2545 2449 2577 2561 2513 2257 2353 2481 2273 2433 2369 2385 2449 2577 2385 2225 2321 2369 2289 2225 2273 2401 2337 2353 2417 2305 2465 2337 2481 2513 2497 2465 2737 2561 2449 2369 2257 2209 2465 2160 2241 2257 2465 2529 2385 2385 2385 2144 2305 2209 2225 2369 2353 2241 2225 2144 2257 2176 2128 2193 2321 2096 2128 2209 2176 2193 2257 2225 2080 2176 2225 2080 2064 1952 2144 2209 1968 2000 2321 2193 2176 2305 2193 2096 1968 1984 2000 2128 2401 2241 1936 1904 2112 2016 2064 2241 2016 2048 1888 2048 2032 2048 2032 2096 2112 2032 1984 1728 1920 1904 1792 1840 1936 1936 1824 2064 2016 1936 1968 1968 1904 2128 1824 2064 2048 2016 2321 2032 1952 1904 1920 2032 1904 1712 1904 1872 1808 1808 1728 1936 1792 1984 1856 1936 1888 1824 1904 1728 1840 2048 1888 2032 2048 1952 1824 1856 1792 1648 1776 1744 2000 1952 1824 1728 1968 1792 1872 1872 2016 1808 1824 1680 1456 1760 1792 1808 1712 1808 1936 1936 1776 1808 2032 1984 1776 1920 1840 1936 1888 1888 1856 2032 1872 1664 1824 1984 1824 1632 1696 1712 1872 1440 1632 1712 1792 1872 1696 1808 1872 1904 1696 1840 1776 1728 1888 1744 1568 1728 1648 1792 1632 1744 1616 1552 1888 1776 1776 1888 1792 1696 1696 1552 1456 1632 1632 1568 1792 1872 1616 1616 1536 1584 1696 1616 1536 1712 1584 1536 1456 1456 1488 1776 1696 1568 1536 ... ...
    

    The first 3 lines are header information then you see the first few pixel values... 2561, 2321, 2497,2545, 2449.


    Just as a quick check that all my foregoing analysis is correct, I wrote a little Python proof-of-concept and commented it pretty heavily so you can see how to get C++.

    #!/usr/bin/env python3
    
    import numpy as np
    from PIL import Image
    
    # Fix image width and height
    w, h = 258, 370
    
    # Load the TIFF image, starting 8 bytes in from the beginning and loading w*h*3/2 bytes because there are 1.5 bytes/pixel
    Pixels12 = np.fromfile('C001H001S0011000001.tif', dtype=np.uint8, offset=8, count=int((w*h*3)/2))
    
    # p is an index into the array "Pixels12" of bytes
    p = 0
    
    # Pixels is a list of pixel values that we will append to as we decode
    Pixels = []
    
    # Iterate over all pixels - remembering we will generate 2 new pixels per iteration
    for i in range(int(w*h/2)):
        # Pick up next three bytes from Pixels12
        b0 = Pixels12[p]
        b1 = Pixels12[p+1]
        b2 = Pixels12[p+2]
        p += 3
    
        # Split the 2nd of the 3 bytes into a low and a high 4-bit nibble
        hiNibble = (b1 >> 4) & 0x0f
        loNibble = (b1       & 0x0f)
    
        # Generate 2 output pixels and append to list
        Pixels.append((b0<<4) | hiNibble)
        Pixels.append((b2   ) | (loNibble<<8))
    
    # Everything after here is not necessary for you - it is just me saving the file to visualise
    
    # Make Numpy array from list and reshape to height and width
    na = np.array(Pixels,dtype=np.uint16).reshape((h,w))
    
    # Save as PNG to visualize
    Image.fromarray(na).save('z.png')
    

    enter image description here


    Note that your image is stored with low contrast and appears very dark when extracted from 12-bit, so you will need to stretch the contrast for best viewing. Just FYI, I used:

    magick EXTRACTED_IMAGE.PNG -auto-level NICE_CONTRAST.PNG