Converting the image file to a bitmap format makes for the easiest way to read a picture using VHDL. Support for the BMP raster graphics image file format is built into the Microsoft Windows operating system. That makes BMP a suitable image format for storing photos for use in VHDL testbenches.

In this article, you will learn how to read a binary image file like BMP and store the data in dynamic memory in the simulator. We will use an example image processing module for converting the image to grayscale, this will be our device under test (DUT). Finally, we write the output from the DUT to a new image that we can compare visually to the original.

This blog post is part of a series about using the TEXTIO library in VHDL. Read the other articles here:

How to initialize RAM from file using TEXTIO

Stimulus file read in testbench using TEXTIO

Why bitmap is the best format for VHDL

The most common image file formats on the internet are JPEG and PNG. Both of them use compression, JPEG is lossy while PNG is lossless. Most formats offer some form of compression because this can dramatically reduce the storage size of the image. While this is fine for normal usage, it’s not ideal for reading in a VHDL testbench.

Smiley as raster graphics
A 24×24 pixel image of a smiley face magnified 10x to demonstrate pixel boundaries

To be able to process an image in software or hardware you need to have access to the raw pixel data within your application. You want to have the color and luminance data stored in a matrix of bytes, this is called bitmap or raster graphics.

Most of the well-known image editors such as Photoshop or GIMP are raster-based. They can open a wide range of image formats, but they are all converted to raster graphics internally in the editor.

You can do this in VHDL too, but that would require a considerable coding effort because there aren’t any ready-made solutions for decoding compressed images. A better solution is to convert the test input images to a bitmap format like BMP manually or by incorporating it into the script that launches your testbench.

The BMP image file format

The BMP file format is well documented on Wikipedia. This format has many different variants, but we’re going to agree on some specific settings that will make it a lot easier for us. To create our input images, we open them in Microsoft Paint which comes pre-installed with Windows. Then, we click File→Save as, select Save as type: 24-bit Bitmap (*bmp; *.dib). Name the file something ending with the .bmp suffix and click save.

By making sure that the file is created like this we can assume that the header always is the 54 bytes long BITMAPINFOHEADER variant with pixel format RGB24 mentioned on the Wikipedia page. Furthermore, we’ll only care about a few selected fields within the header. The table below shows the header fields that we are going to read.

Offset Size Expected (Hex) Description
0 2 “BM” (42 4D) ID field
10 4 54 (36 00 00 00) Pixel array offset
14 4 40 (28 00 00 00) Header size
18 4 Read value Image width in pixels
22 4 Read value Image height in pixels
26 1 1 (01) Number of color planes
28 1 24 (18) Number of bits per pixel

The values marked in green are the only ones that we really need to look at because we know which values to expect in the other header fields. If you agreed to only use images of predefined fixed dimensions every time, you could skip the entire header and start reading at byte offset number 54 within the BMP file, that’s where the pixel data will be found.

Nevertheless, we will check that the other listed values are as expected. It’s not difficult to do since we are already reading the header. It also provides a safeguard against user errors, should you or one of your colleagues supply an image of the wrong encoding to the testbench anytime in the future.

The test case

This blog post is about how to read an image from file in a VHDL testbench, but for completeness, I have included an example DUT. We will stream the pixel data through the DUT as we read the image. Finally, we write the results to another output BMP file which can be examined in your favorite picture viewer.

entity grayscale is
  port (
    -- RGB input
    r_in : in std_logic_vector(7 downto 0);
    g_in : in std_logic_vector(7 downto 0);
    b_in : in std_logic_vector(7 downto 0);

    -- RGB output
    r_out : out std_logic_vector(7 downto 0);
    g_out : out std_logic_vector(7 downto 0);
    b_out : out std_logic_vector(7 downto 0)
  );
end grayscale; 

The code above shows the entity of our DUT. The grayscale module takes the 24-bit RGB data for one pixel as an input and converts it to a grayscale representation which is presented on the output. Note that the output pixel represents a shade of gray still within the RGB color space, we’re not converting the BMP into a grayscale BMP which is a different format.

The module is purely combinational, there’s no clock or reset input. The result immediately appears on the output when something is assigned to the input. For simplicity, the conversion to grayscale uses a fixed-point approximation of the luma (brightness) value according to the ITU-R BT.2100 RGB to luma coding system.

You can download the code for the grayscale module and the entire project by using the form below.

Need the ModelSim project files?

Let me send you a Zip with everything you need to get started in 30 seconds

How does it work?

Tested on Windows and Linux How it works

    Unsubscribe at any time

    The picture of the Boeing 747 that you are seeing below will be our example input image. That is, it’s not the actual BMP image that’s embedded in this blog post, that wouldn’t be possible. It’s a JPEG representation of the BMP image which we are going to read in our testbench. You can request the original BMP image by leaving your email address in the form above and you will receive it straight away in your inbox.

    Decommissioned Boeing 747 in color
    The input image; boeing.bmp. I shot this picture of a Boeing 747 at the Bangkok Airplane Graveyard.

    The test image is 1000 x 1000 pixels large. Although, the code presented in this article should work with any image dimensions as long as it’s in the BITMAPINFOHEADER 24-bit BMP format. However, reading in a large image will take a lot of simulation time because file access in most VHDL simulators is slow. This image is 2930 kB and takes a few seconds to load in ModelSim.

    Import the TEXTIO library

    To read from or write to files in VHDL you need to import the TEXTIO library. Make sure that you include the lines from the listing below at the top of your VHDL file. We also need to import the finish keyword from the standard package to stop the simulation when all tests have completed.

    use std.textio.all;
    use std.env.finish;
    

    The above statements require VHDL-2008 or newer to be used.

    Custom type declarations

    We will declare a few custom types at the start of the declarative region of our testbench. The format of the data structures for storing pixel data depends on what kind of input the DUT expects. The grayscale module expects three bytes that each represent one of the color components red, green, and blue. Because it operates on one pixel at the time, we are free to store the pixel set as we want.

    As we can see from the code below, we first declare a header_type array which we will use to store all the header data in. We will examine some fields within the header, but we also need to store it because we are going to write the processed image data to a new file at the end of the testbench. Then, we need to include the original header in the output image.

    type header_type  is array (0 to 53) of character;
    
    type pixel_type is record
      red : std_logic_vector(7 downto 0);
      green : std_logic_vector(7 downto 0);
      blue : std_logic_vector(7 downto 0);
    end record;
    
    type row_type is array (integer range <>) of pixel_type;
    type row_pointer is access row_type;
    type image_type is array (integer range <>) of row_pointer;
    type image_pointer is access image_type;
    

    The second statement declares a record named pixel_type. This custom type will act as a container for the RGB data for one pixel.

    Finally, the dynamic data structures for storing all pixels are declared. While row_type is an unconstrained array of the pixel_type, the row_pointer is an access type to it, a VHDL pointer. Similarly, we construct an unconstrained image_type array to store all the rows of pixels in.

    Thus, the image_pointer type will function as a handle to the full image in dynamically allocated memory.

    Instantiating the DUT

    At the end of the declarative region, we declare the interface signals for the DUT, as shown below. The input signals are postfixed with _in and the output signals with _out. This allows us to easily identify them in the code as well as in the waveform. The DUT is instantiated at the start of the architecture with the signals assigned through the port map.

    signal r_in : std_logic_vector(7 downto 0);
    signal g_in : std_logic_vector(7 downto 0);
    signal b_in : std_logic_vector(7 downto 0);
    signal r_out : std_logic_vector(7 downto 0);
    signal g_out : std_logic_vector(7 downto 0);
    signal b_out : std_logic_vector(7 downto 0);
    
    begin
    
    DUT :entity work.grayscale(rtl)
    port map (
      r_in => r_in,
      g_in => g_in,
      b_in => b_in,
      r_out => r_out,
      g_out => g_out,
      b_out => b_out
    );
    

    Process variables and file handles

    We will create one single testbench process to contain all of the file reading and writing. The declarative region of the process is shown below. We start by declaring a new char_file type to define the data type that we wish to read from the input image file. The BMP file is binary encoded; therefore we want to operate on bytes, the character type in VHDL. On the next two lines, we use the type to open an input and an output file.

    process
      type char_file is file of character;
      file bmp_file : char_file open read_mode is "boeing.bmp";
      file out_file : char_file open write_mode is "out.bmp";
      variable header : header_type;
      variable image_width : integer;
      variable image_height : integer;
      variable row : row_pointer;
      variable image : image_pointer;
      variable padding : integer;
      variable char : character;
    begin
    

    Next, we declare a variable to contain the header data, as well as two integer variables for holding the width and height of the image. After that, we declare a row pointer and an image pointer. The latter one will be our handle to the complete image once it has been read from file.

    Finally, we declare two convenience variables; padding of type integer and char of type character. We will use these to store values that we read from the file temporarily.

    Reading the BMP header

    At the start of the process body, we read the entire header from the BMP file into the header variable, as shown in the code below. The header is 54 bytes long, but instead of using the hard-coded value we get the range to iterate over by referencing the header_type'range attribute. You should always use attributes when you can to keep the constant values defined as few places as possible.

      for i in header_type'range loop
        read(bmp_file, header(i));
      end loop;
    

    Then follows a few assert statements where we check that some of the header fields are as expected. This is a safe-guard against user error as we don’t use the read values for anything, we just check that they are as expected. The expected values are the ones listed in this table, shown earlier in the article.

    The code below shows the assert statements, each with a report statement describing the error and a severity failure statement to stop the simulation if the asserted expression is false. We need to use a raised severity level because at least with the default settings in ModelSim, it will just print an error message and continue the simulation.

      -- Check ID field
      assert header(0) = 'B' and header(1) = 'M'
        report "First two bytes are not ""BM"". This is not a BMP file"
        severity failure;
    
      -- Check that the pixel array offset is as expected
      assert character'pos(header(10)) = 54 and
        character'pos(header(11)) = 0 and
        character'pos(header(12)) = 0 and
        character'pos(header(13)) = 0
        report "Pixel array offset in header is not 54 bytes"
        severity failure;
    
      -- Check that DIB header size is 40 bytes,
      -- meaning that the BMP is of type BITMAPINFOHEADER
      assert character'pos(header(14)) = 40 and
        character'pos(header(15)) = 0 and
        character'pos(header(16)) = 0 and
        character'pos(header(17)) = 0
        report "DIB headers size is not 40 bytes, is this a Windows BMP?"
        severity failure;
    
      -- Check that the number of color planes is 1
      assert character'pos(header(26)) = 1 and
        character'pos(header(27)) = 0
        report "Color planes is not 1" severity failure;
    
      -- Check that the number of bits per pixel is 24
      assert character'pos(header(28)) = 24 and
        character'pos(header(29)) = 0
        report "Bits per pixel is not 24" severity failure;
    

    Then we read the image width and height fields from the header. These are the only two values that we are actually going to use. Therefore, we assign them to the image_width and image_height variables. As we can see from the code below, we have to multiply the subsequent bytes with the weighted power of two values to convert the four-byte header fields into proper integer values.

      -- Read image width
      image_width := character'pos(header(18)) +
        character'pos(header(19)) * 2**8 +
        character'pos(header(20)) * 2**16 +
        character'pos(header(21)) * 2**24;
    
      -- Read image height
      image_height := character'pos(header(22)) +
        character'pos(header(23)) * 2**8 +
        character'pos(header(24)) * 2**16 +
        character'pos(header(25)) * 2**24;
    
      report "image_width: " & integer'image(image_width) &
        ", image_height: " & integer'image(image_height);
    

    Finally, we print the read height and width to the simulator console by using the report statement.

    Reading the pixel data

    We need to find out how many bytes of padding there will be on each line before we can start reading in the pixel data. The BMP format requires that each row of pixels are padded to a multiple of four bytes. In the code below we take care of this with a one-liner formula using the modulo operator on the image width.

      -- Number of bytes needed to pad each row to 32 bits
      padding := (4 - image_width*3 mod 4) mod 4;
    

    We also have to reserve space for all the rows of pixel data that we’re going to read. The image variable is an access type, a VHDL pointer. To make it point to a writable memory space we use the new keyword to reserve space for image_height number of rows in dynamic memory, as shown below.

      -- Create a new image type in dynamic memory
      image := new image_type(0 to image_height - 1);
    

    Now it is time to read the image data. The listing below shows the for-loop which reads the array of pixels, row by row. For each row, we reserve space for a new row_type object, pointed to by the row variable. Then, we read the expected number of pixels, first the blue, then the green, and finally the red color. This is the ordering according to the 24-bit BMP standard.

      for row_i in 0 to image_height - 1 loop
    
        -- Create a new row type in dynamic memory
        row := new row_type(0 to image_width - 1);
    
        for col_i in 0 to image_width - 1 loop
    
          -- Read blue pixel
          read(bmp_file, char);
          row(col_i).blue :=
            std_logic_vector(to_unsigned(character'pos(char), 8));
    
          -- Read green pixel
          read(bmp_file, char);
          row(col_i).green :=
            std_logic_vector(to_unsigned(character'pos(char), 8));
    
          -- Read red pixel
          read(bmp_file, char);
          row(col_i).red :=
            std_logic_vector(to_unsigned(character'pos(char), 8));
    
        end loop;
    
        -- Read and discard padding
        for i in 1 to padding loop
          read(bmp_file, char);
        end loop;
    
        -- Assign the row pointer to the image vector of rows
        image(row_i) := row;
    
      end loop;
    

    After reading the payload for each line we read and discard the extra padding bytes (if any). Finally, at the end of the loop, we assign the new dynamic row of pixels to the correct slot of the image array. When the for-loop terminates the image variable should contain pixel data for the entire BMP image.

    Testing the DUT

    The grayscale module uses only combinational logic, so we don’t need to worry about any clock or reset signals. The code below goes through every pixel in every row while writing the RGB values to the DUT inputs. After assigning the input values we wait for 10 nanoseconds to let all the delta cycle delays within the DUT unwind. Any time values larger than 0 will work, or even wait for 0 ns; repeated enough times.

      for row_i in 0 to image_height - 1 loop
        row := image(row_i);
    
        for col_i in 0 to image_width - 1 loop
    
          r_in <= row(col_i).red;
          g_in <= row(col_i).green;
          b_in <= row(col_i).blue;
          wait for 10 ns;
    
          row(col_i).red := r_out;
          row(col_i).green := g_out;
          row(col_i).blue := b_out;
    
        end loop;
      end loop;
    

    When the program comes out of the wait statement the DUT outputs should contain the RGB values for the grayscale color for this pixel. At the end of the loop, we let the DUT output replace the pixel values that we read from the input BMP file.

    Writing the output BMP file

    At this point, all of the pixels in the image variable should have been manipulated by the DUT. It is time to write the image data to the out_file object, which points to a local file named “out.bmp”. In the code below we run through every pixel in the header bytes which we have stored from the input BMP file, and write them to the output file.

      for i in header_type'range loop
        write(out_file, header(i));
      end loop;
    

    After the header, we need to write the pixels in the order that we read them from the input file. The two nested for-loops in the listing below take care of that. Note that after each row we use the deallocate keyword to free the dynamically allocated memory for each row. Garbage collection is only included in VHDL-2019, in previous versions of VHDL you can expect memory leaks if you omit this line. At the end of the for-loop, we write padding bytes if needed to bring the row length to a multiple of 4 bytes.

      for row_i in 0 to image_height - 1 loop
        row := image(row_i);
    
        for col_i in 0 to image_width - 1 loop
    
          -- Write blue pixel
          write(out_file,
            character'val(to_integer(unsigned(row(col_i).blue))));
    
          -- Write green pixel
          write(out_file,
            character'val(to_integer(unsigned(row(col_i).green))));
    
          -- Write red pixel
          write(out_file,
            character'val(to_integer(unsigned(row(col_i).red))));
    
        end loop;
    
        deallocate(row);
    
        -- Write padding
        for i in 1 to padding loop
          write(out_file, character'val(0));
        end loop;
    
      end loop;
    

    After the loop has terminated, we deallocate the memory space for the image variable, as shown below. Then we close the files by calling file_close on the file handles. This is not strictly necessary in most simulators because the file is implicitly closed when the subprogram or process terminates. Nevertheless, it’s never wrong to close files when you are done with them.

      deallocate(image);
    
      file_close(bmp_file);
      file_close(out_file);
    
      report "Simulation done. Check ""out.bmp"" image.";
      finish;
    end process;
    

    At the end of the testbench process, we print a message to the ModelSim console that the simulation is over, with a hint of where the output image can be found. The finish keyword requires VHDL-2008, it’s an elegant way to stop the simulator after all tests have completed.

    Need the ModelSim project files?

    Let me send you a Zip with everything you need to get started in 30 seconds

    How does it work?

    Tested on Windows and Linux How it works

      Unsubscribe at any time

      The output BMP image

      The image below shows what the “out.bmp” file looks like after the testbench has completed. The actual file shown in this blog post is a JPEG because BMPs are not suitable for embedding on webpages, but you can leave your email address in the form above to get a zip with the full project including the “boeing.bmp” file.

      Decommissioned Boeing 747 in grayscale
      The output image out.bmp after the color has been changed to grayscale by our VHDL code.

      Last remarks

      For image processing in FPGA, the YUV color encoding scheme is often used instead of RGB. In YUV the luma (luminance) component, Y, is kept separate from the color information. The YUV format is more closely mapped to human visual perception. Fortunately, it’s easy to convert between RGB and YUV.

      Converting RGB to CMYK is a bit more complicated because there isn’t a one-to-one pixel formula.

      Another alternative when using such exotic encoding schemes is to invent your own image file format. Simply store the pixel arrays in a custom file format suffixed by “.yuv” or “.cmyk”. There’s no need for a header when you know what kind of image format the pixels will have, just go ahead and read it in your testbench.

      You can always incorporate a software conversion into your design flow. For example, automatically convert a PNG image to the BMP format by using standard command-line image conversion software before the simulation starts. Then, read it in your testbench using VHDL as you have learned from this article.

      Leave a Reply

      Your email address will not be published. Required fields are marked *

      This site uses Akismet to reduce spam. Learn how your comment data is processed.