Search code examples
c#imagebitmapgdi+

Why does the Bitmap constructor not retain the frame information?


Why does the Bitmap constructor not retain the frame information? In the example below using imagePath as a path to a 3 page tiff image, the first test works fine showing the different sizes of three pages. However, the second test only shows the first page's size.

/* Returns the following...
Page:1  W/H: 7650/9900
Page:2  W/H: 3600/2025
Page:3  W/H: 3600/2025
Page:1  W/H: 7650/9900
(missing page 2 and 3)
*/

// Test opening using the Image.FromFile method
using (Image img = Image.FromFile(imagePath))
{
    int p = img.GetFrameCount(FrameDimension.Page);

    for (int i = 0; i < p; i++)
    {
        img.SelectActiveFrame(FrameDimension.Page, i);
        Debug.WriteLine($"Page:{i + 1}  W/H: {img.Width}/{img.Height}");
    }
}

// Testing opening using the Bitmap constructor
using (Image orgImg = Image.FromFile(imagePath))
{
    using (Image img = new Bitmap(orgImg))
    {
        int p = img.GetFrameCount(FrameDimension.Page);

        for (int i = 0; i < p; i++)
        {
            img.SelectActiveFrame(FrameDimension.Page, i);
            Debug.WriteLine($"Page:{i + 1}  W/H: {img.Width}/{img.Height}");
        }
    }
}

As a follow up question, doing the following I can get the same results as the first test. But is there a better way of gaining access to the entire frame collection again like the first test without having to create another Bitmap/Image object?

/* Returns the following...
Page:1  W/H: 7650/9900
Page:2  W/H: 3600/2025
Page:3  W/H: 3600/2025
*/

// Testing opening by creating a new Image object
using (Image orgImg = Image.FromFile(imagePath))
{
    using (Image img = (Image)orgImg.Clone())
    {
        int p = img.GetFrameCount(FrameDimension.Page);

        for (int i = 0; i < p; i++)
        {
            img.SelectActiveFrame(FrameDimension.Page, i);
            Debug.WriteLine($"Page:{i + 1}  W/H: {img.Width}/{img.Height}");
        }
    }
}

Solution

  • As a preface:

    • System.Drawing is a surprisingly thin wrapper over GDI+ (aka gdiplus).
    • System.Drawing.Image is an abstract class (and the supertype of Bitmap); corresponds to GDI+'s C++ class Image. It has only two subclasses:
      • System.Drawing.Bitmap which corresponds to GDI+'s C++ class Bitmap.
      • System.Drawing.Imaging.Metafile corresponds to GDI+'s C++ class Metafile - these are (scalable) non-raster images which are basically a linear sequence of GDI drawing ops (so a biiit like PostScript) but in a binary format, and might be thought of as an ancient-ancestor to today's SVG. Metafiles are out of the scope of this answer, however.

    • The Bitmap class is surprisingly multifunctional, as it can represent any of:
      • Windows' (legacy) in-memory DIB and DDB bitmaps.
      • A (limited) variety of specific raster image file-formats that store/represent a single raster image, in-particular .bmp - which is essentially just a serialized DDB bitmap with extra file headers.
      • Another (also limited) variety of specific raster-image file formats that support multiple images (or "frames" or "pages"); in reality this is restricted to just the GIF and TIFF container formats.
      • And finally, any in-memory raster image loaded from a supported compressed image file format for which a GDI+-compatible codec has been installed, such as JPEG, PNG - or even HEIF/HEIC.
      • Somewhat disappointingly, despite how the .ico/.cur file-format is also a multi-image raster-image file, GDI+ will only load the first image with no way of loading the other images contained in the file. To work-around this, .NET includes the Icon and Cursor classes which provide some (rudimentary) support for getting to the other images in the icon-directory structure contained within the .ico/.cur file, but this is also out-of-scope.

    • When you use Image.FromFile(String filename) to load a single-image raster file (like .bmp, .jpeg or .png), then the image will be returned as a new Bitmap object, with any supported metadata (e.g. EXIF tags in a JPEG file) loaded into the PropertyItems collection.

    • When you use Image.FromFile(String fileName) to load a multi-image raster file (like an animated .gif or multi-page TIFF) then it will also return a Bitmap object, which represents (and contains) the first image in the file - while any other frames/pages/image in the GIF or TIFF file can only be "swapped in" by using SelectActiveFrame on that specific Bitmap object - the SelectActiveFrame method significantly mutates the Bitmap object, including updating properties most people would reasonably assume were fixed and immutable once-loaded (like the .PixelFormat, .Width and .Height, crazy!).

    • When you use new Bitmap(Image orignal) or new Bitmap(Image original, Int32 width, Int32 height) take note that this does not create any kind of state-copy or clone of the Image source object. Instead, it simply draws it onto the new Bitmap via a temporary Graphics context.

      • The Bitmap class has a lot of these convenience/helper constructors, including many which don't have equivalents in GDI+ - some might even call it a mess - I believe this is because System.Drawing's was part of .NET Framework 1.x back in 2001/2002 when Microsoft's .NET library API designers were still throwing-around different ideas/experiments for how an OOP library should be like - hence why there's a confusing mess of constructors, static-factories and clone methods that all seem to do the same thing (until you realise they don't - as you found out).
    • So if you do want to create a proper clone of a Bitmap object which was loaded from a multi-image file on-disk then you need to use Image.Clone() which is inherited by Bitmap, but its return-type is Object (because this predates .NET's support for covariant return types), you'll need to add a cast yourself (maybe define an extension-method to reduce hassle in future?)


    But is there a better way of gaining access to the entire frame collection again like the first test without having to create another Bitmap/Image object?

    If you simply want to iterate over the images contained in the file (and you aren't making any changes to the image data: it's all read-only), then there's no need to clone anything, you should be able to re-iterate over the frames (i.e. 0 < i < Image.FrameCount).


    If you want to be able to directly access the raster data for multiple GIF frames (or TIFF pages) simultaneously then unfortunately it looks like you'll have to .Clone() the Bitmap for each concurrently-accessible raster-frame you want open - or copy each frame within a loop over FrameCount between LockBits+UnlockBits calls, as GDI+ throws an exception if you call .SelectActiveFrame without first calling UnlockBits.

    ...so this doesn't work:

    using( Bitmap gif = (Bitmap)Image.FromFile( @"dancingBaby.gif" ) ) // https://en.wikipedia.org/wiki/Dancing_baby
    {
        List<BitmapData> lockedBits = new List<BitmapData>();
    
        Int32 cnt = gif.GetFrameCount( FrameDimension.Time );
        for( Int32 i = 0; i < cnt; i++ )
        {
            #warning The `SelectActiveFrame` call below will fail when it tries to lock the second image/frame/page in a GIF or TIFF file:
            gif.SelectActiveFrame( FrameDimension.Time, frameIndex: i );// <-- When `i == 1` then this line throws: InvalidOperationException: "Bitmap region is already locked."
            
            Rectangle  frameRect = new Rectangle( x: 0, y: 0, width: gif.Width, height: gif.Height );
            BitmapData frameLock = gif.LockBits( frameRect, ImageLockMode.ReadOnly, gif.PixelFormat );
            
            lockedBits.Add( frameLock );
        }
    
        try
        {
            foreach( BitmapData bd in lockedBits )
            {
                // Do stuff
            }
        }
        finally
        {
            foreach( BitmapData bd in lockedBits )
            {
                gif.UnlockBits( bd );
            }
        }
    }
    

    Instead you'll have to do this:

    using( Bitmap gif = (Bitmap)Image.FromFile( @"underConstruction-best-viewed-with-netscapeNavigator-3.0.gif" ) )
    {
        List<Byte[]> buffers = new List<Byte[]>();
    
        Int32 cnt = gif.GetFrameCount( FrameDimension.Time );
        for( Int32 i = 0; i < cnt; i++ )
        {
            gif.SelectActiveFrame( FrameDimension.Time, frameIndex: i );
            
            Rectangle  frameRect = new Rectangle( x: 0, y: 0, width: gif.Width, height: gif.Height );
            BitmapData frameLock = gif.LockBits( frameRect, ImageLockMode.ReadOnly, gif.PixelFormat );
            try
            {
                Byte[] frameBuffer = new Byte[ /* width * height * pixel-format-byte-length */ ]
                buffers.Add( frameBuffer );
    
                // TODO: Copy from `frameLock.Scan0` into `frameBuffer`.
    
                // Do stuff
            }
            finally
            {
                gif.UnlockBits( frameLock );
            }
        }
    }
    

    I spent some time digging in System.Drawing and GDI+'s internals to see if there was any way to test/check if an Image or Bitmap was loaded-from-file (with all other frames, if available) or is a single-frame copy (with information-loss). Unfortunately I don't think it's possible to differentiate between (for example) a correctly-loaded TIFF file containing 1 image and the scenario where a Bitmap object was incorrectly cloned from a multi-image TIFF file such that it only contains 1 image.